Skip to content

Commit 3b0d695

Browse files
committed
fix: access of keychain items and introduce chunked storage of large items/blobs
1 parent d728e57 commit 3b0d695

1 file changed

Lines changed: 160 additions & 27 deletions

File tree

Lines changed: 160 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
11
import Foundation
22

33
public class SessionCookieStore {
4-
4+
55
// MARK: - Private properties
66
private static let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.mendix.app"
77
private static let storageKey = bundleIdentifier + "sessionCookies"
88
private static let queue = DispatchQueue(label: bundleIdentifier + ".session-cookie-store", qos: .utility)
9-
9+
private static let chunkSize: Int = 64 * 1024
10+
private static let maxChunks: Int = 1000
11+
1012
// MARK: - Public API
1113
public static func restore() {
12-
14+
1315
guard let cookies = get(key: storageKey) else {
1416
NSLog("SessionCookieStore: No cookies to restore")
1517
return
1618
}
17-
19+
1820
let storage = HTTPCookieStorage.shared
1921
let existing = Set(storage.cookies ?? [])
2022
cookies.filter { !existing.contains($0) }.forEach { storage.setCookie($0) }
21-
22-
clear() // Clear stored cookies after restoration to avoid any side effects
23+
24+
clear()
2325
}
24-
26+
2527
public static func persist() {
2628
queue.async {
2729
let sessionCookies = HTTPCookieStorage.shared.cookies?.filter { isSessionCookie($0) } ?? []
@@ -33,53 +35,184 @@ public class SessionCookieStore {
3335
set(key: storageKey, cookies: sessionCookies)
3436
}
3537
}
36-
38+
39+
#if DEBUG
40+
/// Persist variant that calls `completion` once the keychain write finishes.
41+
/// Only compiled in DEBUG builds; used by the harness test bridge to await completion.
42+
public static func persist(completion: @escaping () -> Void) {
43+
queue.async {
44+
let sessionCookies = HTTPCookieStorage.shared.cookies?.filter { isSessionCookie($0) } ?? []
45+
guard !sessionCookies.isEmpty else {
46+
clear()
47+
NSLog("SessionCookieStore: Clear existing session cookies from storage")
48+
completion()
49+
return
50+
}
51+
set(key: storageKey, cookies: sessionCookies)
52+
completion()
53+
}
54+
}
55+
#endif
56+
3757
public static func clear() {
58+
clearAllChunks(key: storageKey)
3859
clear(key: storageKey)
3960
}
40-
61+
4162
// MARK: - Private API
4263
private static func isSessionCookie(_ cookie: HTTPCookie) -> Bool {
4364
return cookie.expiresDate == nil
4465
}
45-
66+
4667
private static func set(key: String, cookies: [HTTPCookie]) {
4768
do {
4869
let data = try NSKeyedArchiver.archivedData(withRootObject: cookies, requiringSecureCoding: false)
49-
let storeQuery = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecValueData: data] as CFDictionary
50-
SecItemDelete(storeQuery)
51-
let status = SecItemAdd(storeQuery, nil)
52-
if status != noErr {
53-
NSLog("SessionCookieStore: Failed to persist session cookies with status: \(status)")
70+
71+
clear(key: key)
72+
clearAllChunks(key: key)
73+
74+
if data.count <= chunkSize {
75+
let storeQuery = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecValueData: data] as CFDictionary
76+
let status = SecItemAdd(storeQuery, nil)
77+
if status != noErr {
78+
NSLog("SessionCookieStore: Failed to persist session cookies with status: \(status)")
79+
}
80+
} else {
81+
let chunkCount = min((data.count + chunkSize - 1) / chunkSize, maxChunks)
82+
83+
var writtenChunkKeys: [String] = []
84+
var failed = false
85+
86+
for i in 0..<chunkCount {
87+
let start = i * chunkSize
88+
let end = min(start + chunkSize, data.count)
89+
let chunkKey = key + "_chunk_\(i)"
90+
let chunkQuery = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: chunkKey, kSecValueData: Data(data[start..<end])] as CFDictionary
91+
let status = SecItemAdd(chunkQuery, nil)
92+
if status != noErr {
93+
NSLog("SessionCookieStore: Failed to write chunk \(i) with status: \(status)")
94+
failed = true
95+
break
96+
}
97+
writtenChunkKeys.append(chunkKey)
98+
}
99+
100+
guard !failed else {
101+
for chunkKey in writtenChunkKeys {
102+
SecItemDelete([kSecClass: kSecClassGenericPassword, kSecAttrAccount: chunkKey] as CFDictionary)
103+
}
104+
return
105+
}
106+
107+
// Commit marker written last — its presence guarantees all chunks are present
108+
let countKey = key + "_chunkcount"
109+
let countData = "\(chunkCount)".data(using: .utf8)!
110+
let countQuery = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: countKey, kSecValueData: countData] as CFDictionary
111+
let countStatus = SecItemAdd(countQuery, nil)
112+
if countStatus != noErr {
113+
// Rollback
114+
for chunkKey in writtenChunkKeys {
115+
SecItemDelete([kSecClass: kSecClassGenericPassword, kSecAttrAccount: chunkKey] as CFDictionary)
116+
}
117+
NSLog("SessionCookieStore: Failed to write chunk count marker (status: \(countStatus)), rolled back")
118+
}
54119
}
55120
} catch {
56121
NSLog("SessionCookieStore: Failed to persist session cookies: \(error.localizedDescription)")
57122
}
58123
}
59-
124+
60125
private static func get(key: String) -> [HTTPCookie]? {
61-
do {
62-
let query: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecReturnData: true]
63-
var item: CFTypeRef?
64-
let status = SecItemCopyMatching(query as CFDictionary, &item)
65-
if status == errSecSuccess, let data = item as? Data {
126+
let countKey = key + "_chunkcount"
127+
let countQuery: [CFString: Any] = [
128+
kSecClass: kSecClassGenericPassword,
129+
kSecAttrAccount: countKey,
130+
kSecReturnData: true,
131+
kSecUseAuthenticationUI: kSecUseAuthenticationUIFail
132+
]
133+
var countRef: CFTypeRef?
134+
let countStatus = SecItemCopyMatching(countQuery as CFDictionary, &countRef)
135+
136+
if countStatus == errSecSuccess,
137+
let countData = countRef as? Data,
138+
let countStr = String(data: countData, encoding: .utf8),
139+
let chunkCount = Int(countStr) {
140+
141+
var assembled = Data()
142+
for i in 0..<chunkCount {
143+
let chunkKey = key + "_chunk_\(i)"
144+
let chunkQuery: [CFString: Any] = [
145+
kSecClass: kSecClassGenericPassword,
146+
kSecAttrAccount: chunkKey,
147+
kSecReturnData: true,
148+
kSecUseAuthenticationUI: kSecUseAuthenticationUIFail
149+
]
150+
var chunkRef: CFTypeRef?
151+
let chunkStatus = SecItemCopyMatching(chunkQuery as CFDictionary, &chunkRef)
152+
guard chunkStatus == errSecSuccess, let chunkData = chunkRef as? Data else {
153+
NSLog("SessionCookieStore: Failed to read chunk \(i) (status: \(chunkStatus)), discarding chunked set")
154+
clearAllChunks(key: key)
155+
return nil
156+
}
157+
assembled.append(chunkData)
158+
}
159+
do {
160+
let cookies = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, HTTPCookie.self], from: assembled) as? [HTTPCookie]
161+
return cookies
162+
} catch {
163+
NSLog("SessionCookieStore: Failed to deserialize chunked cookies, clearing: \(error.localizedDescription)")
164+
clearAllChunks(key: key)
165+
return nil
166+
}
167+
}
168+
169+
// --- Legacy single-item format (backward compat on read) ---
170+
let query: [CFString: Any] = [
171+
kSecClass: kSecClassGenericPassword,
172+
kSecAttrAccount: key,
173+
kSecReturnData: true,
174+
kSecUseAuthenticationUI: kSecUseAuthenticationUIFail
175+
]
176+
var item: CFTypeRef?
177+
let status = SecItemCopyMatching(query as CFDictionary, &item)
178+
179+
if status == errSecInteractionNotAllowed {
180+
// Oversized/blocked item — remove so it never prompts again
181+
NSLog("SessionCookieStore: Blocked legacy item detected, clearing")
182+
clear(key: key)
183+
return nil
184+
} else if status == errSecSuccess, let data = item as? Data {
185+
do {
66186
let cookies = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, HTTPCookie.self], from: data) as? [HTTPCookie]
67187
return cookies
68-
} else {
69-
NSLog("SessionCookieStore: No session cookies found with status: \(status)")
188+
} catch {
189+
NSLog("SessionCookieStore: Failed to deserialize legacy cookies, clearing: \(error.localizedDescription)")
190+
clear(key: key)
70191
return nil
71192
}
72-
} catch {
73-
NSLog("SessionCookieStore: Failed to retrieve session cookies: \(error.localizedDescription)")
193+
} else if status != errSecItemNotFound {
194+
NSLog("SessionCookieStore: Unreadable legacy item (status: \(status)), clearing")
195+
clear(key: key)
196+
return nil
197+
} else {
198+
NSLog("SessionCookieStore: No session cookies found")
74199
return nil
75200
}
76201
}
77-
202+
78203
private static func clear(key: String) {
79204
let query = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecReturnData: true] as CFDictionary
80205
let status = SecItemDelete(query)
81-
if status != errSecSuccess {
206+
if status != errSecSuccess && status != errSecItemNotFound {
82207
NSLog("SessionCookieStore: Failed to clear cookies with status: \(status)")
83208
}
84209
}
210+
211+
private static func clearAllChunks(key: String) {
212+
SecItemDelete([kSecClass: kSecClassGenericPassword, kSecAttrAccount: key + "_chunkcount"] as CFDictionary)
213+
for i in 0..<maxChunks {
214+
let st = SecItemDelete([kSecClass: kSecClassGenericPassword, kSecAttrAccount: key + "_chunk_\(i)"] as CFDictionary)
215+
if st == errSecItemNotFound { break }
216+
}
217+
}
85218
}

0 commit comments

Comments
 (0)