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_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
@@ -543,7 +543,7 @@
|
||||
INFOPLIST_KEY_CFBundleName = Calendarr;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import Security
|
||||
|
||||
@main
|
||||
struct CalendarrApp: App {
|
||||
@@ -24,7 +25,13 @@ class AppState {
|
||||
|
||||
init() {
|
||||
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") ?? ""
|
||||
isAdmin = UserDefaults.standard.bool(forKey: "isAdmin")
|
||||
}
|
||||
@@ -39,7 +46,7 @@ class AppState {
|
||||
authToken = token
|
||||
username = user
|
||||
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(admin, forKey: "isAdmin")
|
||||
}
|
||||
@@ -48,7 +55,8 @@ class AppState {
|
||||
authToken = ""
|
||||
username = ""
|
||||
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: "isAdmin")
|
||||
}
|
||||
@@ -59,3 +67,37 @@ class AppState {
|
||||
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