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:
Scarriffle
2026-07-06 14:32:01 +02:00
parent 9db04361cc
commit 3f5de5cdfa
2 changed files with 47 additions and 5 deletions

View File

@@ -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;

View File

@@ -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)
}
}