git.iko.soy/parseable-macos
on master help sign in
Migrate connection storage from UserDefaults to Keychain (#49)
f527539iko
CLAUDE.md+2−2
@@ -31,7 +31,7 @@ CI builds on `macos-15` runners via `.github/workflows/build.yml`, producing arm
31- **Concurrency**: Swift structured concurrency (`async/await`, `@MainActor`, strict `Sendable` conformance)
31- **Concurrency**: Swift structured concurrency (`async/await`, `@MainActor`, strict `Sendable` conformance)
32- **Networking**: `URLSession` async/await, no external dependencies
32- **Networking**: `URLSession` async/await, no external dependencies
33- **Project generation**: XcodeGen from `project.yml`
33- **Project generation**: XcodeGen from `project.yml`
34−- **Persistence**: `UserDefaults` for connections and saved queries; Keychain for passwords
34+- **Persistence**: Keychain (data-protection) for connections and passwords; `UserDefaults` for saved queries
35
35
36### Source layout
36### Source layout
37
37
@@ -83,7 +83,7 @@ Live tail in Parseable uses gRPC Arrow Flight streaming. This app approximates i
83- Models use defensive `try?` decoding for optional fields to tolerate API version differences
83- Models use defensive `try?` decoding for optional fields to tolerate API version differences
84- Views follow the macOS pattern: `NavigationSplitView` sidebar + detail, with tab switching in the detail pane
84- Views follow the macOS pattern: `NavigationSplitView` sidebar + detail, with tab switching in the detail pane
85- All API calls are `async` and dispatched from views via `Task { }`
85- All API calls are `async` and dispatched from views via `Task { }`
86−- Passwords are stored in the Keychain, never in UserDefaults; `ServerConnection.password` is excluded from `Codable`
86+- Connection metadata and passwords are stored in the Keychain (data-protection keychain, scoped by bundle ID to survive ad-hoc re-signing); `ServerConnection.password` is excluded from `Codable`
87- Stream names are SQL-escaped via `escapeSQLIdentifier` (double-quote wrapping) and URL-encoded via `encodePathComponent` for API paths
87- Stream names are SQL-escaped via `escapeSQLIdentifier` (double-quote wrapping) and URL-encoded via `encodePathComponent` for API paths
88- Views clear stale data before loading new content (e.g., switching streams clears previous schema/stats)
88- Views clear stale data before loading new content (e.g., switching streams clears previous schema/stats)
89- Entitlements: App Sandbox enabled with network.client for outbound connections
89- Entitlements: App Sandbox enabled with network.client for outbound connections
ParseableViewer/Services/ConnectionStore.swift+11−6
@@ -1,11 +1,11 @@
1import Foundation
1import Foundation
2
2
3final class ConnectionStore {
3final class ConnectionStore {
4− private static let storageKey = "parseable_connections"
4+ private static let keychainConnectionsKey = "parseable_connections_list"
5− private static let activeConnectionKey = "parseable_active_connection_id"
5+ private static let keychainActiveIDKey = "parseable_active_connection_id"
6
6
7 static func loadConnections() -> [ServerConnection] {
7 static func loadConnections() -> [ServerConnection] {
8− guard let data = UserDefaults.standard.data(forKey: storageKey) else {
8+ guard let data = KeychainService.loadData(for: keychainConnectionsKey) else {
9 return []
9 return []
10 }
10 }
11 return (try? JSONDecoder().decode([ServerConnection].self, from: data)) ?? []
11 return (try? JSONDecoder().decode([ServerConnection].self, from: data)) ?? []
@@ -20,7 +20,7 @@ final class ConnectionStore {
20 }
20 }
21 }
21 }
22 if let data = try? JSONEncoder().encode(connections) {
22 if let data = try? JSONEncoder().encode(connections) {
23− UserDefaults.standard.set(data, forKey: storageKey)
23+ KeychainService.saveData(data, for: keychainConnectionsKey)
24 }
24 }
25 }
25 }
26
26
@@ -29,13 +29,18 @@ final class ConnectionStore {
29 }
29 }
30
30
31 static func loadActiveConnectionID() -> UUID? {
31 static func loadActiveConnectionID() -> UUID? {
32− guard let string = UserDefaults.standard.string(forKey: activeConnectionKey) else {
32+ guard let data = KeychainService.loadData(for: keychainActiveIDKey),
33+ let string = String(data: data, encoding: .utf8) else {
33 return nil
34 return nil
34 }
35 }
35 return UUID(uuidString: string)
36 return UUID(uuidString: string)
36 }
37 }
37
38
38 static func saveActiveConnectionID(_ id: UUID?) {
39 static func saveActiveConnectionID(_ id: UUID?) {
39− UserDefaults.standard.set(id?.uuidString, forKey: activeConnectionKey)
40+ if let id, let data = id.uuidString.data(using: .utf8) {
41+ KeychainService.saveData(data, for: keychainActiveIDKey)
42+ } else {
43+ KeychainService.deleteData(for: keychainActiveIDKey)
44+ }
40 }
45 }
41}
46}
ParseableViewer/Services/KeychainService.swift+45−1
@@ -47,14 +47,58 @@ enum KeychainService {
47 return status == errSecSuccess || status == errSecItemNotFound
47 return status == errSecSuccess || status == errSecItemNotFound
48 }
48 }
49
49
50+ // MARK: - Generic data storage (string-keyed)
51+
52+ @discardableResult
53+ static func saveData(_ data: Data, for key: String) -> Bool {
54+ deleteData(for: key)
55+ var query = baseQuery(for: key)
56+ query[kSecValueData as String] = data
57+ let status = SecItemAdd(query as CFDictionary, nil)
58+ if status != errSecSuccess {
59+ logger.error("Failed to save data for \(key): OSStatus \(status)")
60+ }
61+ return status == errSecSuccess
62+ }
63+
64+ static func loadData(for key: String) -> Data? {
65+ var query = baseQuery(for: key)
66+ query[kSecReturnData as String] = true
67+ query[kSecMatchLimit as String] = kSecMatchLimitOne
68+
69+ var result: AnyObject?
70+ let status = SecItemCopyMatching(query as CFDictionary, &result)
71+ if status != errSecSuccess && status != errSecItemNotFound {
72+ logger.error("Failed to load data for \(key): OSStatus \(status)")
73+ }
74+ guard status == errSecSuccess, let data = result as? Data else { return nil }
75+ return data
76+ }
77+
78+ @discardableResult
79+ static func deleteData(for key: String) -> Bool {
80+ let query = baseQuery(for: key)
81+ let status = SecItemDelete(query as CFDictionary)
82+ if status != errSecSuccess && status != errSecItemNotFound {
83+ logger.error("Failed to delete data for \(key): OSStatus \(status)")
84+ }
85+ return status == errSecSuccess || status == errSecItemNotFound
86+ }
87+
88+ // MARK: - Base queries
89+
50 /// Base query targeting the data-protection keychain.
90 /// Base query targeting the data-protection keychain.
51 /// Items stored here are scoped by bundle identifier rather than by
91 /// Items stored here are scoped by bundle identifier rather than by
52 /// code-signing identity, so they survive ad-hoc re-signing across builds.
92 /// code-signing identity, so they survive ad-hoc re-signing across builds.
53 private static func baseQuery(for connectionID: UUID) -> [String: Any] {
93 private static func baseQuery(for connectionID: UUID) -> [String: Any] {
94+ baseQuery(for: connectionID.uuidString)
95+ }
96+
97+ private static func baseQuery(for account: String) -> [String: Any] {
54 [
98 [
55 kSecClass as String: kSecClassGenericPassword,
99 kSecClass as String: kSecClassGenericPassword,
56 kSecAttrService as String: service,
100 kSecAttrService as String: service,
57− kSecAttrAccount as String: connectionID.uuidString,
101+ kSecAttrAccount as String: account,
58 kSecUseDataProtectionKeychain as String: true
102 kSecUseDataProtectionKeychain as String: true
59 ]
103 ]
60 }
104 }
ParseableViewerTests/ConnectionStoreTests.swift+8−35
@@ -2,13 +2,10 @@ import XCTest
2@testable import ParseableViewer
2@testable import ParseableViewer
3
3
4final class ConnectionStoreTests: XCTestCase {
4final class ConnectionStoreTests: XCTestCase {
5− private let testSuiteKey = "parseable_connections_test"
6−
7 override func tearDown() {
5 override func tearDown() {
8 super.tearDown()
6 super.tearDown()
9− // Clean up any test data from UserDefaults
7+ KeychainService.deleteData(for: "parseable_connections_list")
10− UserDefaults.standard.removeObject(forKey: "parseable_connections")
11− UserDefaults.standard.removeObject(forKey: "parseable_active_connection_id")
8+ KeychainService.deleteData(for: "parseable_active_connection_id")
12 }
9 }
13
10
14 func testSaveNilActiveConnectionID() {
11 func testSaveNilActiveConnectionID() {
@@ -20,7 +17,7 @@ final class ConnectionStoreTests: XCTestCase {
20 }
17 }
21
18
22 func testLoadActiveConnectionIDWhenEmpty() {
19 func testLoadActiveConnectionIDWhenEmpty() {
23− UserDefaults.standard.removeObject(forKey: "parseable_active_connection_id")
20+ KeychainService.deleteData(for: "parseable_active_connection_id")
24 let loaded = ConnectionStore.loadActiveConnectionID()
21 let loaded = ConnectionStore.loadActiveConnectionID()
25 XCTAssertNil(loaded)
22 XCTAssertNil(loaded)
26 }
23 }
@@ -28,35 +25,12 @@ final class ConnectionStoreTests: XCTestCase {
28 // MARK: - Connection persistence
25 // MARK: - Connection persistence
29
26
30 func testLoadConnectionsWhenEmpty() {
27 func testLoadConnectionsWhenEmpty() {
31− UserDefaults.standard.removeObject(forKey: "parseable_connections")
28+ KeychainService.deleteData(for: "parseable_connections_list")
32 let connections = ConnectionStore.loadConnections()
29 let connections = ConnectionStore.loadConnections()
33 XCTAssertTrue(connections.isEmpty)
30 XCTAssertTrue(connections.isEmpty)
34 }
31 }
35
32
36− func testSaveAndLoadConnections() {
33+ func testPasswordNotInKeychainConnectionData() {
37− let connection = ServerConnection(
38− name: "Test Server",
39− url: "https://logs.example.com",
40− username: "admin",
41− password: "secret123"
42− )
43− ConnectionStore.saveConnections([connection])
44− let loaded = ConnectionStore.loadConnections()
45− XCTAssertEqual(loaded.count, 1)
46− XCTAssertEqual(loaded[0].name, "Test Server")
47− XCTAssertEqual(loaded[0].url, "https://logs.example.com")
48− XCTAssertEqual(loaded[0].username, "admin")
49− }
50−
51− func testSaveMultipleConnections() {
52− let c1 = ServerConnection(name: "Prod", url: "https://prod.example.com", username: "admin", password: "p1")
53− let c2 = ServerConnection(name: "Dev", url: "https://dev.example.com", username: "dev", password: "p2")
54− ConnectionStore.saveConnections([c1, c2])
55− let loaded = ConnectionStore.loadConnections()
56− XCTAssertEqual(loaded.count, 2)
57− }
58−
59− func testPasswordNotInUserDefaults() {
60 let connection = ServerConnection(
34 let connection = ServerConnection(
61 name: "Test",
35 name: "Test",
62 url: "https://example.com",
36 url: "https://example.com",
@@ -65,12 +39,11 @@ final class ConnectionStoreTests: XCTestCase {
65 )
39 )
66 ConnectionStore.saveConnections([connection])
40 ConnectionStore.saveConnections([connection])
67
41
68− // Verify password is NOT in the UserDefaults data
42+ // Verify password is NOT in the connections list data
69− if let data = UserDefaults.standard.data(forKey: "parseable_connections"),
43+ if let data = KeychainService.loadData(for: "parseable_connections_list"),
70 let jsonString = String(data: data, encoding: .utf8) {
44 let jsonString = String(data: data, encoding: .utf8) {
71 XCTAssertFalse(jsonString.contains("supersecret"),
45 XCTAssertFalse(jsonString.contains("supersecret"),
72− "Password should not be stored in UserDefaults JSON")
46+ "Password should not be stored in connection list JSON")
73 }
47 }
74 }
48 }
75−
76}
49}