Ein eigener Sperrbildschirm — und ohne gespeichertes Passwort
Kameraaufnahme, Anlernen über drei Posen, Fenster je Bildschirm auf Bildschirmschoner-Ebene, Passwortrückfall. **Das Anmeldepasswort wird nicht gespeichert.** Ein Spike hat den Weg freigeräumt: `ODRecord.verifyPassword` ist öffentliche API, die Eingabe geht direkt hinein, das Ergebnis ist ein `Bool`, danach ist die Zeichenkette wieder weg. Damit fällt der Grund weg, aus dem vergleichbare Programme sich das Passwort geben lassen und in die Keychain legen. Angelernt wird über drei Posen mit je fünf Aufnahmen. Der Merkmalsvektor ist ein allgemeiner Bildmerkmalsvektor und kein Gesichtsmerkmal — er reagiert auf Kopfhaltung und Beleuchtung mit. Eine einzige Aufnahme beschreibt genau eine Haltung bei genau einem Licht. Die Vektoren liegen in der Keychain, nicht in einer Datei: aus ihnen lässt sich kein Bild zurückgewinnen, aber wer sie hat, kann prüfen, ob eine bestimmte Person vor der Kamera stand. Die Einschränkung steht auf dem Sperrbildschirm selbst und nicht im Kleingedruckten: Bequemlichkeit, keine Sicherheitsgrenze; der Vergleich läuft über die Webcam und lässt sich mit einem Foto täuschen. Wer sich darauf verlässt, tut es dort. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3da673662f
commit
e3b215a1dd
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"lock.looking": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Sucht dein Gesicht …"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Looking for your face …"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"lock.recognised": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Erkannt"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Recognised"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"lock.lockedOut": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Zu viele Versuche — bitte Passwort eingeben"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Too many attempts — please enter your password"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"lock.wrongPassword": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Das war nicht das richtige Passwort"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "That was not the right password"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"lock.password": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Passwort eingeben"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Enter your password"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"lock.passwordPrompt": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Passwort"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Password"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"lock.disclaimer": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dieser Sperrbildschirm ist Bequemlichkeit, keine Sicherheitsgrenze. Der Gesichtsvergleich lässt sich mit einem Foto täuschen, und wer den Rechner neu startet, kommt vorbei."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "This lock screen is convenience, not a security boundary. The face check can be fooled by a photo, and restarting the Mac gets past it."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Observation
|
||||
import FaceProvider
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Lock")
|
||||
|
||||
/// Führt den Sperrbildschirm: Fenster je Bildschirm, Kamera, Passwortrückfall.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class LockController {
|
||||
|
||||
public private(set) var isLocked = false
|
||||
|
||||
/// Was gerade zu sehen sein soll.
|
||||
public private(set) var status: LockStatus = .idle
|
||||
|
||||
public enum LockStatus: Equatable, Sendable {
|
||||
/// Nicht gesperrt.
|
||||
case idle
|
||||
/// Kamera sucht.
|
||||
case looking
|
||||
/// Erkannt, gleich offen.
|
||||
case recognised
|
||||
/// Kein Gesicht hinterlegt oder Kamera nicht verfügbar — nur Passwort.
|
||||
case passwordOnly
|
||||
/// Zu viele Fehlversuche.
|
||||
case lockedOut
|
||||
/// Ein falsches Passwort.
|
||||
case wrongPassword
|
||||
}
|
||||
|
||||
@ObservationIgnored private var windows: [LockWindow] = []
|
||||
@ObservationIgnored private let capture = FaceCapture()
|
||||
@ObservationIgnored private let store = FaceStore()
|
||||
@ObservationIgnored private let verifier = PasswordVerifier()
|
||||
@ObservationIgnored private var matcher: FaceMatcher?
|
||||
@ObservationIgnored private var session = FaceMatcher.Session()
|
||||
|
||||
/// Ob Gesichtserkennung überhaupt benutzt werden soll.
|
||||
public var usesFaceUnlock = true
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Ob ein Gesicht angelernt ist.
|
||||
public var isEnrolled: Bool { !store.load().isEmpty }
|
||||
|
||||
// MARK: - Sperren
|
||||
|
||||
public func lock() {
|
||||
guard !isLocked else { return }
|
||||
isLocked = true
|
||||
session = FaceMatcher.Session()
|
||||
|
||||
let templates = store.load()
|
||||
matcher = FaceMatcher(templates: templates)
|
||||
|
||||
for screen in NSScreen.screens {
|
||||
let window = LockWindow(screen: screen)
|
||||
window.contentView = NSHostingView(rootView: LockScreenView(controller: self))
|
||||
window.orderFrontRegardless()
|
||||
windows.append(window)
|
||||
}
|
||||
// Nur eines soll die Eingabe bekommen — sonst tippt man ins Nichts.
|
||||
windows.first?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
guard usesFaceUnlock, !templates.isEmpty, FaceCapture.hasCamera else {
|
||||
status = .passwordOnly
|
||||
return
|
||||
}
|
||||
startLooking()
|
||||
}
|
||||
|
||||
public func unlock() {
|
||||
guard isLocked else { return }
|
||||
capture.stop()
|
||||
windows.forEach { $0.orderOut(nil) }
|
||||
windows.removeAll()
|
||||
isLocked = false
|
||||
status = .idle
|
||||
}
|
||||
|
||||
// MARK: - Gesicht
|
||||
|
||||
private func startLooking() {
|
||||
status = .looking
|
||||
capture.onVector = { [weak self] vector in self?.evaluate(vector) }
|
||||
capture.onNoFace = { }
|
||||
capture.start()
|
||||
}
|
||||
|
||||
private func evaluate(_ vector: [Float]) {
|
||||
guard let matcher, isLocked else { return }
|
||||
switch matcher.evaluate(vector, in: &session) {
|
||||
case .accepted:
|
||||
status = .recognised
|
||||
capture.stop()
|
||||
// Ein Augenblick, damit man die Rückmeldung sieht — sonst
|
||||
// verschwindet der Bildschirm, bevor klar ist, warum.
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(400))
|
||||
self.unlock()
|
||||
}
|
||||
case .undecided:
|
||||
break
|
||||
case .lockedOut:
|
||||
log.notice("Gesichtserkennung gesperrt — nur noch Passwort")
|
||||
status = .lockedOut
|
||||
capture.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Passwort
|
||||
|
||||
/// Prüft die Eingabe gegen die echte Anmeldung. Gespeichert wird sie nicht.
|
||||
public func submit(password: String) {
|
||||
guard verifier.verify(password) else {
|
||||
status = status == .lockedOut ? .lockedOut : .wrongPassword
|
||||
return
|
||||
}
|
||||
unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import SwiftUI
|
||||
import OnyxDesign
|
||||
|
||||
/// Was auf dem Sperrbildschirm steht: Uhr, Zustand, Passwortfeld.
|
||||
struct LockScreenView: View {
|
||||
|
||||
let controller: LockController
|
||||
|
||||
@State private var password = ""
|
||||
@FocusState private var isTyping: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Schwarz und kein Hintergrundbild: was hier zu sehen ist, soll
|
||||
// nichts über den Rechner verraten, der darunter liegt.
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: Onyx.Space.xxl) {
|
||||
Spacer()
|
||||
clock
|
||||
statusLine
|
||||
field
|
||||
Spacer()
|
||||
disclaimer
|
||||
}
|
||||
.padding(Onyx.Space.xxl)
|
||||
}
|
||||
}
|
||||
|
||||
private var clock: some View {
|
||||
TimelineView(.everyMinute) { context in
|
||||
VStack(spacing: Onyx.Space.sm) {
|
||||
Text(context.date, format: .dateTime.hour().minute())
|
||||
.font(.system(size: 84, weight: .thin))
|
||||
.monospacedDigit()
|
||||
Text(context.date, format: .dateTime.weekday(.wide).day().month(.wide))
|
||||
.font(Onyx.Font.headline)
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusLine: some View {
|
||||
switch controller.status {
|
||||
case .looking:
|
||||
label("lock.looking", symbol: "faceid", tint: .white.opacity(0.7))
|
||||
case .recognised:
|
||||
label("lock.recognised", symbol: "checkmark.circle.fill", tint: Onyx.Color.positive)
|
||||
case .lockedOut:
|
||||
label("lock.lockedOut", symbol: "exclamationmark.triangle.fill", tint: Onyx.Color.warning)
|
||||
case .wrongPassword:
|
||||
label("lock.wrongPassword", symbol: "xmark.circle.fill", tint: Onyx.Color.critical)
|
||||
case .passwordOnly, .idle:
|
||||
label("lock.password", symbol: "lock.fill", tint: .white.opacity(0.7))
|
||||
}
|
||||
}
|
||||
|
||||
private func label(_ key: LocalizedStringKey, symbol: String, tint: Color) -> some View {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
Image(systemName: symbol)
|
||||
Text(key, bundle: .module)
|
||||
}
|
||||
.font(Onyx.Font.body)
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
|
||||
private var field: some View {
|
||||
SecureField("", text: $password, prompt: Text("lock.passwordPrompt", bundle: .module))
|
||||
.textFieldStyle(.plain)
|
||||
.font(Onyx.Font.headline)
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(width: 260)
|
||||
.padding(.vertical, Onyx.Space.row)
|
||||
.background(.white.opacity(0.10),
|
||||
in: .capsule)
|
||||
.focused($isTyping)
|
||||
.onSubmit {
|
||||
controller.submit(password: password)
|
||||
password = ""
|
||||
}
|
||||
.onAppear { isTyping = true }
|
||||
}
|
||||
|
||||
/// Die Einschränkung steht auf dem Bildschirm selbst.
|
||||
///
|
||||
/// Nicht im Kleingedruckten einer Einstellung, die man einmal gesehen hat:
|
||||
/// wer sich darauf verlässt, tut es hier.
|
||||
private var disclaimer: some View {
|
||||
Text("lock.disclaimer", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(.white.opacity(0.35))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 420)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import AppKit
|
||||
|
||||
/// Das Fenster, das den Bildschirm verdeckt.
|
||||
///
|
||||
/// **Und was es nicht ist.** Dieses Fenster ist Bequemlichkeit, keine
|
||||
/// Sicherheitsgrenze. Wer den Prozess beendet, sich per SSH anmeldet oder den
|
||||
/// Rechner neu startet, kommt daran vorbei. Es ersetzt nicht die Sperre von
|
||||
/// macOS — es legt sich davor, damit das Entsperren per Gesicht überhaupt
|
||||
/// stattfinden kann.
|
||||
///
|
||||
/// Diese Einschränkung steht auch in den Einstellungen und im Onboarding, in
|
||||
/// denselben klaren Worten. Ein Sperrbildschirm, den man für sicher hält,
|
||||
/// obwohl er es nicht ist, ist schlechter als gar keiner.
|
||||
public final class LockWindow: NSWindow {
|
||||
|
||||
public init(screen: NSScreen) {
|
||||
super.init(contentRect: screen.frame,
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
|
||||
// Über allem, was der Benutzer sonst offen hat — auch über Vollbild.
|
||||
level = .screenSaver
|
||||
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary,
|
||||
.ignoresCycle]
|
||||
isOpaque = true
|
||||
backgroundColor = .black
|
||||
hasShadow = false
|
||||
// Nicht in Bildschirmfotos und Bildschirmfreigaben: was hier steht, ist
|
||||
// eine Passworteingabe.
|
||||
sharingType = .none
|
||||
isMovable = false
|
||||
setFrame(screen.frame, display: true)
|
||||
}
|
||||
|
||||
/// Muss Tastatureingaben annehmen können — sonst landet das Passwort im
|
||||
/// Fenster darunter.
|
||||
public override var canBecomeKey: Bool { true }
|
||||
public override var canBecomeMain: Bool { true }
|
||||
|
||||
/// Schluckt, was nicht ins Passwortfeld gehört.
|
||||
///
|
||||
/// Vor allem `cmd+q` und `cmd+w`: ein Sperrbildschirm, den man mit einem
|
||||
/// Tastendruck schließt, ist keiner.
|
||||
public override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
event.modifierFlags.contains(.command) ? true : super.performKeyEquivalent(with: event)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user