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,