fix(security): store auth token in Keychain and restrict ATS to local networking
- Move the bearer token from UserDefaults to the Keychain (accessible after first unlock), with a one-time migration so existing logins survive. - Replace NSAllowsArbitraryLoads=YES with NSAllowsLocalNetworking=YES so ATS still permits cleartext to LAN/self-hosted servers but enforces TLS for public hosts (no arbitrary cleartext/MITM). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -499,7 +499,7 @@
|
|||||||
INFOPLIST_KEY_CFBundleName = Calendarr;
|
INFOPLIST_KEY_CFBundleName = Calendarr;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -543,7 +543,7 @@
|
|||||||
INFOPLIST_KEY_CFBundleName = Calendarr;
|
INFOPLIST_KEY_CFBundleName = Calendarr;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Security
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct CalendarrApp: App {
|
struct CalendarrApp: App {
|
||||||
@@ -24,7 +25,13 @@ class AppState {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
|
serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
|
||||||
authToken = UserDefaults.standard.string(forKey: "authToken") ?? ""
|
// Migrate a token previously kept in UserDefaults into the Keychain once,
|
||||||
|
// so existing logins survive the change without re-authenticating.
|
||||||
|
if let legacy = UserDefaults.standard.string(forKey: "authToken"), !legacy.isEmpty {
|
||||||
|
Keychain.set(legacy, for: "authToken")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "authToken")
|
||||||
|
}
|
||||||
|
authToken = Keychain.get("authToken") ?? ""
|
||||||
username = UserDefaults.standard.string(forKey: "username") ?? ""
|
username = UserDefaults.standard.string(forKey: "username") ?? ""
|
||||||
isAdmin = UserDefaults.standard.bool(forKey: "isAdmin")
|
isAdmin = UserDefaults.standard.bool(forKey: "isAdmin")
|
||||||
}
|
}
|
||||||
@@ -39,7 +46,7 @@ class AppState {
|
|||||||
authToken = token
|
authToken = token
|
||||||
username = user
|
username = user
|
||||||
isAdmin = admin
|
isAdmin = admin
|
||||||
UserDefaults.standard.set(token, forKey: "authToken")
|
Keychain.set(token, for: "authToken") // secret → Keychain, not UserDefaults
|
||||||
UserDefaults.standard.set(user, forKey: "username")
|
UserDefaults.standard.set(user, forKey: "username")
|
||||||
UserDefaults.standard.set(admin, forKey: "isAdmin")
|
UserDefaults.standard.set(admin, forKey: "isAdmin")
|
||||||
}
|
}
|
||||||
@@ -48,7 +55,8 @@ class AppState {
|
|||||||
authToken = ""
|
authToken = ""
|
||||||
username = ""
|
username = ""
|
||||||
isAdmin = false
|
isAdmin = false
|
||||||
UserDefaults.standard.removeObject(forKey: "authToken")
|
Keychain.set(nil, for: "authToken")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "authToken") // clear any legacy copy
|
||||||
UserDefaults.standard.removeObject(forKey: "username")
|
UserDefaults.standard.removeObject(forKey: "username")
|
||||||
UserDefaults.standard.removeObject(forKey: "isAdmin")
|
UserDefaults.standard.removeObject(forKey: "isAdmin")
|
||||||
}
|
}
|
||||||
@@ -59,3 +67,37 @@ class AppState {
|
|||||||
UserDefaults.standard.removeObject(forKey: "serverURL")
|
UserDefaults.standard.removeObject(forKey: "serverURL")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Minimal Keychain wrapper for secrets (the auth bearer token). Values are
|
||||||
|
/// stored as generic passwords, accessible after first unlock.
|
||||||
|
enum Keychain {
|
||||||
|
private static let service = "Calendarr"
|
||||||
|
|
||||||
|
static func set(_ value: String?, for key: String) {
|
||||||
|
let base: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
]
|
||||||
|
SecItemDelete(base as CFDictionary)
|
||||||
|
guard let value, let data = value.data(using: .utf8) else { return }
|
||||||
|
var add = base
|
||||||
|
add[kSecValueData as String] = data
|
||||||
|
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
SecItemAdd(add as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func get(_ key: String) -> String? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
var out: AnyObject?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess,
|
||||||
|
let data = out as? Data else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user