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:
Scarriffle
2026-09-05 21:22:32 +02:00
co-authored by Claude Opus 5
parent 3da673662f
commit e3b215a1dd
23 changed files with 1605 additions and 1 deletions
@@ -0,0 +1,173 @@
import AVFoundation
import Vision
import CoreImage
import Observation
import os
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Face")
/// Die Kamera, und was Vision aus ihren Bildern macht.
///
/// Der Weg steht in `docs/spikes/G-gesichtsmerkmal.md`: Vision findet das
/// Gesicht, der Ausschnitt bekommt einen Merkmalsvektor, verglichen wird über
/// den Abstand. `VNGenerateFaceprintRequest` gibt es nicht — der öffentliche
/// Bildmerkmalsvektor tut es.
///
/// **Die Kamera läuft nur, solange sie gebraucht wird.** Ein Sperrbildschirm,
/// der die Kamera offen hält, ist eine leuchtende Kameraanzeige den ganzen Tag
/// — und die zu Recht.
@MainActor
@Observable
public final class FaceCapture: NSObject {
/// Ob die Kamera gerade läuft.
public private(set) var isRunning = false
/// Der zuletzt gesehene Fehler, für die Anzeige beim Anlernen.
public private(set) var failure: String?
/// Wird für jedes Bild gerufen, in dem ein Gesicht war.
@ObservationIgnored public var onVector: (([Float]) -> Void)?
/// Wird gerufen, wenn ein Bild **kein** Gesicht enthielt — damit das
/// Anlernen sagen kann „bitte ins Bild".
@ObservationIgnored public var onNoFace: (() -> Void)?
/// Öffentlich nur, damit die Vorschau daran hängen kann — bedient wird sie
/// über `start()` und `stop()`.
@ObservationIgnored public let session = AVCaptureSession()
@ObservationIgnored private let queue = DispatchQueue(label: "onyx.face.capture")
/// Wird auf dem Aufnahmefaden benutzt, nicht auf dem Hauptfaden.
/// `CIContext` ist für gleichzeitige Aufrufe ausgelegt, gilt Swift aber
/// nicht als übertragbar — die Ausnahme steht hier und nicht als Sperre um
/// jeden Zugriff.
@ObservationIgnored nonisolated(unsafe) private let context = CIContext()
/// Ob überhaupt eine Kamera da ist.
public static var hasCamera: Bool {
AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) != nil
|| AVCaptureDevice.default(for: .video) != nil
}
/// Fragt die Berechtigung an. `false` heißt: ohne Kamera geht es nicht
/// weiter, und das muss die Oberfläche sagen.
public static func requestAccess() async -> Bool {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized: return true
case .notDetermined: return await AVCaptureDevice.requestAccess(for: .video)
default: return false
}
}
public func start() {
guard !isRunning else { return }
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video,
position: .front)
?? AVCaptureDevice.default(for: .video) else {
failure = String(localized: "face.error.noCamera", bundle: .module)
return
}
session.beginConfiguration()
session.sessionPreset = .medium
session.inputs.forEach(session.removeInput)
session.outputs.forEach(session.removeOutput)
do {
let input = try AVCaptureDeviceInput(device: device)
guard session.canAddInput(input) else { throw CaptureError.cannotAddInput }
session.addInput(input)
} catch {
session.commitConfiguration()
failure = error.localizedDescription
return
}
let output = AVCaptureVideoDataOutput()
output.alwaysDiscardsLateVideoFrames = true
output.setSampleBufferDelegate(self, queue: queue)
guard session.canAddOutput(output) else {
session.commitConfiguration()
failure = String(localized: "face.error.noOutput", bundle: .module)
return
}
session.addOutput(output)
session.commitConfiguration()
failure = nil
isRunning = true
let session = session
queue.async { session.startRunning() }
}
public func stop() {
guard isRunning else { return }
isRunning = false
let session = session
queue.async { session.stopRunning() }
}
private enum CaptureError: Error { case cannotAddInput }
}
extension FaceCapture: AVCaptureVideoDataOutputSampleBufferDelegate {
/// **Die Bildarbeit bleibt auf dem Aufnahmefaden.** Vision auf dem
/// Hauptfaden hieße, die Oberfläche dreißigmal je Sekunde anzuhalten. Auf
/// den Hauptfaden geht nur das Ergebnis — eine Zahlenreihe.
///
/// Die Warteschlange ist seriell und `alwaysDiscardsLateVideoFrames` ist
/// gesetzt: es wird immer nur ein Bild bearbeitet, und was währenddessen
/// anfällt, wird verworfen statt angehäuft.
public nonisolated func captureOutput(_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection) {
guard let pixels = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
guard let vector = vector(from: pixels) else {
Task { @MainActor [weak self] in self?.onNoFace?() }
return
}
Task { @MainActor [weak self] in self?.onVector?(vector) }
}
/// Gesicht finden, ausschneiden, Merkmal rechnen. `nil`, wenn kein Gesicht
/// im Bild war.
///
/// **Zuschneiden ist nicht optional.** Ein Merkmalsvektor des ganzen Bildes
/// beschreibt zur Hälfte den Hintergrund — dann erkennt Onyx den Raum wieder
/// und nicht die Person.
private nonisolated func vector(from pixels: CVPixelBuffer) -> [Float]? {
let detect = VNDetectFaceRectanglesRequest()
try? VNImageRequestHandler(cvPixelBuffer: pixels, options: [:]).perform([detect])
guard let face = detect.results?.first else { return nil }
let image = CIImage(cvPixelBuffer: pixels)
let extent = image.extent
// Vision rechnet in Anteilen mit Ursprung unten links.
var box = CGRect(x: face.boundingBox.minX * extent.width,
y: face.boundingBox.minY * extent.height,
width: face.boundingBox.width * extent.width,
height: face.boundingBox.height * extent.height)
// Etwas Rand: das Rechteck endet an der Stirn, und Haaransatz und Kinn
// tragen zur Wiedererkennung bei.
box = box.insetBy(dx: -box.width * 0.15, dy: -box.height * 0.15).intersection(extent)
guard !box.isEmpty else { return nil }
guard let cg = context.createCGImage(image.cropped(to: box), from: box) else { return nil }
let print = VNGenerateImageFeaturePrintRequest()
try? VNImageRequestHandler(cgImage: cg, options: [:]).perform([print])
guard let observation = print.results?.first else { return nil }
return observation.onyxVector
}
}
extension VNFeaturePrintObservation {
/// Der Merkmalsvektor als Zahlenreihe.
var onyxVector: [Float] {
data.withUnsafeBytes { raw in
Array(raw.bindMemory(to: Float.self))
}
}
}
@@ -0,0 +1,114 @@
import SwiftUI
import AVFoundation
import Observation
import OnyxDesign
/// Führt durch das Anlernen: mehrere Posen, mehrere Aufnahmen je Pose.
///
/// **Warum mehrere Posen.** 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; wer sich danach anders hinsetzt, wird nicht erkannt.
@MainActor
@Observable
public final class FaceEnrollment {
public enum Pose: String, CaseIterable, Sendable {
case straight, left, right
public var localizedName: LocalizedStringKey {
switch self {
case .straight: "face.pose.straight"
case .left: "face.pose.left"
case .right: "face.pose.right"
}
}
}
/// Wie viele Aufnahmen je Pose.
public static let samplesPerPose = 5
public private(set) var pose: Pose = .straight
public private(set) var collected = 0
public private(set) var isFinished = false
/// Ob gerade ein Gesicht im Bild ist — für die Rückmeldung „bitte ins Bild".
public private(set) var seesFace = false
@ObservationIgnored private let capture = FaceCapture()
@ObservationIgnored private let store = FaceStore()
@ObservationIgnored private var templates: [FaceTemplate] = []
/// Erst auf Knopfdruck aufnehmen: sonst sammelt es fünf Bilder derselben
/// Sekunde ein, und das ist keine Vielfalt, sondern dasselbe fünfmal.
@ObservationIgnored private var isCollecting = false
public var failure: String? { capture.failure }
/// Für die Vorschau.
public var session: AVCaptureSession { capture.session }
public init() {}
public func start() {
capture.onVector = { [weak self] vector in self?.accept(vector) }
capture.onNoFace = { [weak self] in self?.seesFace = false }
capture.start()
}
public func stop() { capture.stop() }
/// Nimmt die aktuelle Pose auf.
public func captureCurrentPose() { isCollecting = true }
private func accept(_ vector: [Float]) {
seesFace = true
guard isCollecting, !isFinished else { return }
templates.append(FaceTemplate(pose: pose.rawValue, vector: vector))
collected += 1
guard collected >= Self.samplesPerPose else { return }
isCollecting = false
collected = 0
guard let next = Pose.allCases.firstIndex(of: pose).map({ $0 + 1 }),
next < Pose.allCases.count else {
finish()
return
}
pose = Pose.allCases[next]
}
private func finish() {
capture.stop()
store.save(templates)
isFinished = true
}
/// Verwirft alles Angelernte.
public func reset() {
capture.stop()
templates = []
collected = 0
pose = .straight
isFinished = false
store.clear()
}
}
/// Die Kameravorschau.
public struct CameraPreview: NSViewRepresentable {
private let session: AVCaptureSession
public init(session: AVCaptureSession) { self.session = session }
public func makeNSView(context: Context) -> NSView {
let view = NSView()
view.wantsLayer = true
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
view.layer = layer
return view
}
public func updateNSView(_ view: NSView, context: Context) {}
}
@@ -0,0 +1,71 @@
import Foundation
import Security
import os
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Face")
/// Wo die angelernten Gesichter liegen.
///
/// **In der Keychain und nicht in einer Datei.** Ein Merkmalsvektor ist kein
/// Bild und lässt sich nicht zurückverwandeln, aber er ist ein biometrisches
/// Datum: wer ihn hat, kann prüfen, ob eine bestimmte Person vor der Kamera
/// stand. Das gehört nicht in `Application Support`, wo jedes Programm des
/// Benutzers mitliest.
///
/// Dasselbe Vorgehen wie bei `CalendarrCredentials`.
public struct FaceStore: Sendable {
private static let service = "com.scarriffleservices.onyx.face"
private static let account = "templates"
public init() {}
public func load() -> [FaceTemplate] {
var query = Self.query()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return [] }
do {
return try JSONDecoder().decode([FaceTemplate].self, from: data)
} catch {
// Ein unlesbarer Bestand ist wie keiner: dann wird eben neu
// angelernt. Ihn stehen zu lassen hieße, dass die Anmeldung
// stumm nie wieder funktioniert.
log.error("Gesichtsdaten unlesbar — sie gelten als nicht vorhanden")
return []
}
}
public func save(_ templates: [FaceTemplate]) {
guard !templates.isEmpty else { clear(); return }
guard let data = try? JSONEncoder().encode(templates) else { return }
let query = Self.query()
let attributes: [String: Any] = [kSecValueData as String: data]
if SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecItemNotFound {
var insert = query
insert[kSecValueData as String] = data
// Nur auf diesem Gerät und nur nach dem ersten Entsperren: ein
// Gesichtsmerkmal hat auf keinem anderen Rechner etwas zu suchen.
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(insert as CFDictionary, nil)
if status != errSecSuccess {
log.error("Gesichtsdaten nicht speicherbar: \(status, privacy: .public)")
}
}
}
public func clear() {
SecItemDelete(Self.query() as CFDictionary)
}
private static func query() -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account]
}
}
@@ -0,0 +1,86 @@
{
"sourceLanguage": "en",
"strings": {
"face.error.noCamera": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Keine Kamera gefunden."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No camera found."
}
}
}
},
"face.error.noOutput": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Die Kamera lässt sich nicht auslesen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The camera cannot be read."
}
}
}
},
"face.pose.left": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dreh den Kopf leicht nach links"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Turn your head slightly left"
}
}
}
},
"face.pose.right": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dreh den Kopf leicht nach rechts"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Turn your head slightly right"
}
}
}
},
"face.pose.straight": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Schau geradeaus"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Look straight ahead"
}
}
}
}
},
"version": "1.0"
}
@@ -0,0 +1,62 @@
import Foundation
import OpenDirectory
import os
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Face")
/// Prüft eine Passworteingabe gegen die echte Anmeldung.
///
/// **Und speichert dabei nichts.** Die Zeichenkette geht direkt in
/// `ODRecord.verifyPassword`, das Ergebnis ist ein `Bool`, und danach ist sie
/// wieder weg. Siehe `docs/spikes/I-passwortpruefung.md`.
///
/// Damit fällt der Grund weg, aus dem vergleichbare Programme sich das
/// Anmeldepasswort geben lassen und in die Keychain legen. Ein gespeichertes
/// Anmeldepasswort ist die Sorte Sicherheitsschuld, die man nicht aufnimmt,
/// wenn es auch ohne geht.
public struct PasswordVerifier: Sendable {
private let username: String
public init(username: String = NSUserName()) {
self.username = username
}
/// `true`, wenn die Eingabe stimmt.
///
/// Ein Fehler beim Nachschlagen gilt als **falsch**, nicht als richtig: wer
/// hier großzügig ist, öffnet den Sperrbildschirm, sobald der
/// Verzeichnisdienst hakt.
public func verify(_ password: String) -> Bool {
guard !password.isEmpty else { return false }
do {
let node = try ODNode(session: ODSession.default(),
type: ODNodeType(kODNodeTypeAuthentication))
let record = try node.record(withRecordType: kODRecordTypeUsers,
name: username, attributes: nil)
try record.verifyPassword(password)
return true
} catch {
return false
}
}
/// Ob der Weg zum Benutzerdatensatz überhaupt offensteht.
///
/// Getrennt von `verify`, damit die Einstellungen es prüfen können, ohne
/// ein Passwort zu verlangen — und damit ein kaputter Verzeichnisdienst
/// nicht erst am Sperrbildschirm auffällt, wo man ihn am wenigsten
/// gebrauchen kann.
public var isAvailable: Bool {
do {
let node = try ODNode(session: ODSession.default(),
type: ODNodeType(kODNodeTypeAuthentication))
_ = try node.record(withRecordType: kODRecordTypeUsers,
name: username, attributes: nil)
return true
} catch {
log.error("Benutzerdatensatz nicht erreichbar — Sperrbildschirm ohne Rückfall")
return false
}
}
}