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

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