From 9a399e564cc663a77218b444b142f000ca20487c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 04:42:47 +0000 Subject: [PATCH] iOS security hardening: Keychain WhenUnlocked, local-only clipboard, https-only server, tmp file protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up from the iOS/Swift security review of the nimshare app (no backend changes): - Keychain (Keychain.swift): store the JWT with kSecAttrAccessibleWhenUnlockedThisDeviceOnly instead of AfterFirstUnlock, so a live session token is not decryptable while the device is locked. ThisDeviceOnly is kept (no iCloud sync / backup leak). - Clipboard: copy API tokens (ApiTokensView) and revealed license keys (KeyStoreView, KeyStoreLicensesView) with .localOnly + a 60 s expiry, so secrets don't sync via Universal Clipboard and auto-clear. Share-link URLs are unchanged (not secrets). - Server config (ServerConfigView): accept https:// only; reject cleartext http:// (which ATS already blocks at runtime) with a clear message. - Downloads (TmpFile): sanitize the server-provided filename (strip path separators / traversal) and set FileProtectionType.completeUnlessOpen on the temp directory. Deferred (separate follow-up): optional biometric (Face ID) app-lock and a client-side token max-age — a UX feature rather than a surgical hardening tweak. Note: iOS/UIKit can't be compiled on this Linux runner; changes are surgical and were hand-reviewed — build verification happens in Xcode. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MjfiPG6J7tTFm1dFx5zYmh --- ios/NimShare/Sources/ApiTokensView.swift | 8 +++++++- .../Sources/KeyStoreLicensesView.swift | 11 ++++++++++- ios/NimShare/Sources/KeyStoreView.swift | 11 ++++++++++- ios/NimShare/Sources/Keychain.swift | 7 ++++++- ios/NimShare/Sources/ServerConfigView.swift | 9 ++++++--- ios/NimShare/Sources/TmpFile.swift | 18 +++++++++++++++++- 6 files changed, 56 insertions(+), 8 deletions(-) diff --git a/ios/NimShare/Sources/ApiTokensView.swift b/ios/NimShare/Sources/ApiTokensView.swift index e581e17..3de7524 100644 --- a/ios/NimShare/Sources/ApiTokensView.swift +++ b/ios/NimShare/Sources/ApiTokensView.swift @@ -89,7 +89,13 @@ struct ApiTokensView: View { } Section { Button { - UIPasteboard.general.string = c.rawToken + // v1.11.82 (Security-Review): API-Token ist ein Secret — nur lokal + // in die Zwischenablage (kein Universal-Clipboard-Sync auf andere + // Geräte) und nach 60 s automatisch löschen. + UIPasteboard.general.setItems( + [["public.utf8-plain-text": c.rawToken]], + options: [.localOnly: true, + .expirationDate: Date().addingTimeInterval(60)]) } label: { Label("Kopieren", systemImage: "doc.on.doc") } } } diff --git a/ios/NimShare/Sources/KeyStoreLicensesView.swift b/ios/NimShare/Sources/KeyStoreLicensesView.swift index 5d14f01..e50233f 100644 --- a/ios/NimShare/Sources/KeyStoreLicensesView.swift +++ b/ios/NimShare/Sources/KeyStoreLicensesView.swift @@ -80,7 +80,16 @@ struct KeyStoreLicensesView: View { KeyStoreLicenseAddSheet { Task { await load() } } } .alert("Lizenzschlüssel", isPresented: Binding(get: { revealedValue != nil }, set: { if !$0 { revealedValue = nil } })) { - Button("Kopieren") { UIPasteboard.general.string = revealedValue } + Button("Kopieren") { + // v1.11.82 (Security-Review): Lizenzschlüssel ist ein Secret — lokal-only + // in die Zwischenablage und nach 60 s automatisch löschen. + if let v = revealedValue { + UIPasteboard.general.setItems( + [["public.utf8-plain-text": v]], + options: [.localOnly: true, + .expirationDate: Date().addingTimeInterval(60)]) + } + } Button("Schließen", role: .cancel) { revealedValue = nil } } message: { Text(revealedValue ?? "") } .alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { diff --git a/ios/NimShare/Sources/KeyStoreView.swift b/ios/NimShare/Sources/KeyStoreView.swift index cf155d4..b4b6031 100644 --- a/ios/NimShare/Sources/KeyStoreView.swift +++ b/ios/NimShare/Sources/KeyStoreView.swift @@ -102,7 +102,16 @@ struct KeyStoreView: View { KeyStoreEntrySheet(existing: e) { Task { await load() } } } .alert("Lizenzschlüssel", isPresented: Binding(get: { revealedValue != nil }, set: { if !$0 { revealedValue = nil } })) { - Button("Kopieren") { UIPasteboard.general.string = revealedValue } + Button("Kopieren") { + // v1.11.82 (Security-Review): Lizenzschlüssel ist ein Secret — lokal-only + // in die Zwischenablage und nach 60 s automatisch löschen. + if let v = revealedValue { + UIPasteboard.general.setItems( + [["public.utf8-plain-text": v]], + options: [.localOnly: true, + .expirationDate: Date().addingTimeInterval(60)]) + } + } Button("Schließen", role: .cancel) { revealedValue = nil } } message: { Text(revealedValue ?? "") } .alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { diff --git a/ios/NimShare/Sources/Keychain.swift b/ios/NimShare/Sources/Keychain.swift index 631698c..a2b0f84 100644 --- a/ios/NimShare/Sources/Keychain.swift +++ b/ios/NimShare/Sources/Keychain.swift @@ -19,7 +19,12 @@ enum Keychain { kSecAttrService as String: service, kSecAttrAccount as String: key, kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + // v1.11.82 (Security-Review): WhenUnlocked statt AfterFirstUnlock — das + // Voll-Session-Token darf nur bei entsperrtem Gerät entschlüsselbar sein, + // nicht dauerhaft nach dem ersten Unlock seit Boot. ThisDeviceOnly bleibt + // (kein iCloud-Sync, kein Backup-Leak). Die App liest den Token ohnehin nur + // im Vordergrund (Login/Bootstrap/Request-Header), kein Background-Bedarf. + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, ] return SecItemAdd(q as CFDictionary, nil) == errSecSuccess } diff --git a/ios/NimShare/Sources/ServerConfigView.swift b/ios/NimShare/Sources/ServerConfigView.swift index cc733ce..5b82cd6 100644 --- a/ios/NimShare/Sources/ServerConfigView.swift +++ b/ios/NimShare/Sources/ServerConfigView.swift @@ -129,13 +129,16 @@ struct ServerConfigView: View { private var isValid: Bool { let trimmed = input.trimmingCharacters(in: .whitespaces) guard let u = URL(string: trimmed), let s = u.scheme else { return false } - return (s == "http" || s == "https") && u.host != nil + // v1.11.82 (Security-Review): nur HTTPS. Cleartext-HTTP würde das Bearer-Token + // im Klartext übertragen; ATS blockt es ohnehin zur Laufzeit, hier lehnen wir es + // schon in der Eingabe ab (klare Fehlermeldung statt stiller Verbindungsfehler). + return s.lowercased() == "https" && u.host != nil } private func save() { let trimmed = input.trimmingCharacters(in: .whitespaces) - guard let u = URL(string: trimmed) else { - error = "Ungültige URL" + guard let u = URL(string: trimmed), u.scheme?.lowercased() == "https", u.host != nil else { + error = "Bitte eine gültige HTTPS-URL angeben (http:// wird aus Sicherheitsgründen nicht unterstützt)." return } auth.setServer(u) diff --git a/ios/NimShare/Sources/TmpFile.swift b/ios/NimShare/Sources/TmpFile.swift index dc087cf..ced15c7 100644 --- a/ios/NimShare/Sources/TmpFile.swift +++ b/ios/NimShare/Sources/TmpFile.swift @@ -25,7 +25,23 @@ enum TmpFile { // Verzeichnis vorbereiten — wir ignorieren „exists"-Fehler; das // UUID ist neu, kann eigentlich nicht kollidieren. try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) - return base.appendingPathComponent(filename.isEmpty ? "file" : filename) + // v1.11.82 (Security-Review): Datenschutzklasse für (potenziell sensible) Downloads + // — lesbar solange offen (QuickLook/AVPlayer), sonst bei gesperrtem Gerät geschützt. + // Best-effort; das tmp-Verzeichnis wird von iOS ohnehin nicht ins Backup übernommen. + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUnlessOpen], ofItemAtPath: base.path) + return base.appendingPathComponent(Self.sanitizedFilename(filename)) + } + + /// v1.11.82 (Security-Review): server-gelieferter Dateiname darf keine Pfad-Separatoren + /// oder „..“ einschleusen. Nur der letzte Pfad-Bestandteil, Separatoren entschärft. + /// (Der Schreibpfad bleibt so oder so im App-Sandbox-tmp, das ist Defense-in-Depth.) + private static func sanitizedFilename(_ name: String) -> String { + var n = (name as NSString).lastPathComponent + n = n.replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "\\", with: "_") + n = n.trimmingCharacters(in: CharacterSet(charactersIn: " .")) + return n.isEmpty ? "file" : n } /// iPad-safe Share-Sheet. Auf iPhone verhält es sich wie üblich modal,