2 * Copyright (c) 2017 Apple Inc. All Rights Reserved.
4 * @APPLE_LICENSE_HEADER_START@
6 * This file contains Original Code and/or Modifications of Original Code
7 * as defined in and that are subject to the Apple Public Source License
8 * Version 2.0 (the 'License'). You may not use this file except in
9 * compliance with the License. Please obtain a copy of the License at
10 * http://www.opensource.apple.com/apsl/ and read it before using this
13 * The Original Code and all software distributed under the License are
14 * distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, EITHER
15 * EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES,
16 * INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY,
17 * FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT.
18 * Please see the License for the specific language governing rights and
19 * limitations under the License.
21 * @APPLE_LICENSE_HEADER_END@
24 #import <Foundation/Foundation.h>
28 #import <CloudKit/CloudKit.h>
29 #import <CloudKit/CloudKit_Private.h>
31 #import "keychain/categories/NSError+UsefulConstructors.h"
32 #import "keychain/ckks/CloudKitDependencies.h"
33 #import "keychain/ckks/CloudKitCategories.h"
34 #import "keychain/ckks/CKKS.h"
35 #import "keychain/ckks/CKKSKeychainView.h"
36 #import "keychain/ckks/CKKSReachabilityTracker.h"
37 #import "keychain/ot/ObjCImprovements.h"
38 #import "keychain/analytics/SecEventMetric.h"
39 #import "keychain/analytics/SecMetrics.h"
40 #import "NSError+UsefulConstructors.h"
41 #import "CKKSPowerCollection.h"
42 #include "keychain/securityd/SecItemServer.h"
44 @implementation CKKSCloudKitFetchRequest
47 @implementation CKKSCloudKitDeletion
48 - (instancetype)initWithRecordID:(CKRecordID*)recordID recordType:(NSString*)recordType
50 if((self = [super init])) {
52 _recordType = recordType;
59 @interface CKKSFetchAllRecordZoneChangesOperation()
60 @property CKDatabaseOperation<CKKSFetchRecordZoneChangesOperation>* fetchRecordZoneChangesOperation;
61 @property NSMutableDictionary<CKRecordZoneID*, CKFetchRecordZoneChangesConfiguration*>* allClientOptions;
63 @property NSMutableDictionary<CKRecordZoneID*, id<CKKSChangeFetcherClient>>* clientMap;
65 @property CKOperationGroup* ckoperationGroup;
66 @property (assign) NSUInteger fetchedItems;
67 @property bool forceResync;
69 @property bool moreComing;
71 @property size_t totalModifications;
72 @property size_t totalDeletions;
74 // A zoneID is in this set if we're attempting to resync them
75 @property NSMutableSet<CKRecordZoneID*>* resyncingZones;
77 @property CKKSResultOperation* fetchCompletedOperation;
80 @implementation CKKSFetchAllRecordZoneChangesOperation
82 // Sets up an operation to fetch all changes from the server, and collect them until we know if the fetch completes.
83 // As a bonus, you can depend on this operation without worry about NSOperation completion block dependency issues.
85 - (instancetype)init {
89 - (instancetype)initWithContainer:(CKContainer*)container
90 fetchClass:(Class<CKKSFetchRecordZoneChangesOperation>)fetchRecordZoneChangesOperationClass
91 clients:(NSArray<id<CKKSChangeFetcherClient>>*)clients
92 fetchReasons:(NSSet<CKKSFetchBecause*>*)fetchReasons
93 apnsPushes:(NSSet<CKRecordZoneNotification*>*)apnsPushes
94 forceResync:(bool)forceResync
95 ckoperationGroup:(CKOperationGroup*)ckoperationGroup
97 if(self = [super init]) {
98 _container = container;
99 _fetchRecordZoneChangesOperationClass = fetchRecordZoneChangesOperationClass;
101 _clientMap = [NSMutableDictionary dictionary];
102 for(id<CKKSChangeFetcherClient> client in clients) {
103 _clientMap[client.zoneID] = client;
106 _ckoperationGroup = ckoperationGroup;
107 _forceResync = forceResync;
108 _fetchReasons = fetchReasons;
109 _apnsPushes = apnsPushes;
111 _modifications = [[NSMutableDictionary alloc] init];
112 _deletions = [[NSMutableDictionary alloc] init];
113 _changeTokens = [[NSMutableDictionary alloc] init];
115 _resyncingZones = [NSMutableSet set];
117 _totalModifications = 0;
120 _fetchCompletedOperation = [CKKSResultOperation named:@"record-zone-changes-completed" withBlock:^{}];
127 - (void)queryClientsForChangeTokens
129 // Ask all our clients for their change tags
131 // Unused until [<rdar://problem/38725728> Changes to discretionary-ness (explicit or derived from QoS) should be "live"] has happened and we can determine network
132 // discretionaryness.
133 //bool nilChangeTag = false;
135 for(CKRecordZoneID* clientZoneID in self.clientMap) {
136 id<CKKSChangeFetcherClient> client = self.clientMap[clientZoneID];
138 CKKSCloudKitFetchRequest* clientPreference = [client participateInFetch];
139 if(clientPreference.participateInFetch) {
140 [self.fetchedZoneIDs addObject:client.zoneID];
142 CKFetchRecordZoneChangesConfiguration* options = [[CKFetchRecordZoneChangesConfiguration alloc] init];
144 if(!self.forceResync) {
145 if (self.changeTokens[clientZoneID]) {
146 options.previousServerChangeToken = self.changeTokens[clientZoneID];
147 secnotice("ckksfetch", "Using cached change token for %@: %@", clientZoneID, self.changeTokens[clientZoneID]);
149 options.previousServerChangeToken = clientPreference.changeToken;
153 if(clientPreference.resync || self.forceResync) {
154 [self.resyncingZones addObject:clientZoneID];
157 self.allClientOptions[client.zoneID] = options;
163 self.allClientOptions = [NSMutableDictionary dictionary];
164 self.fetchedZoneIDs = [NSMutableArray array];
166 [self queryClientsForChangeTokens];
168 if(self.fetchedZoneIDs.count == 0) {
169 // No clients actually want to fetch right now, so quit
170 self.error = [NSError errorWithDomain:CKKSErrorDomain code:CKKSNoFetchesRequested description:@"No clients want a fetch right now"];
171 secnotice("ckksfetch", "Cancelling fetch: %@", self.error);
182 // Compute the network discretionary approach this fetch will take.
183 // For now, everything is nondiscretionary, because we can't afford to block a nondiscretionary request later.
184 // Once [<rdar://problem/38725728> Changes to discretionary-ness (explicit or derived from QoS) should be "live"] happens, we can come back through and make things
185 // discretionary, but boost them later.
188 // If there's a nil change tag, go to nondiscretionary. This is likely a zone bringup (which happens during iCloud sign-in) or a resync (which happens due to user input)
189 // If the fetch reasons include an API fetch, an initial start or a key hierarchy fetch, become nondiscretionary as well.
191 CKOperationDiscretionaryNetworkBehavior networkBehavior = CKOperationDiscretionaryNetworkBehaviorNonDiscretionary;
193 // [self.fetchReasons containsObject:CKKSFetchBecauseAPIFetchRequest] ||
194 // [self.fetchReasons containsObject:CKKSFetchBecauseInitialStart] ||
195 // [self.fetchReasons containsObject:CKKSFetchBecauseKeyHierarchy]) {
196 // networkBehavior = CKOperationDiscretionaryNetworkBehaviorNonDiscretionary;
199 secnotice("ckksfetch", "Beginning fetch with discretionary network (%d): %@", (int)networkBehavior, self.allClientOptions);
200 self.fetchRecordZoneChangesOperation = [[self.fetchRecordZoneChangesOperationClass alloc] initWithRecordZoneIDs:self.fetchedZoneIDs
201 configurationsByRecordZoneID:self.allClientOptions];
203 self.fetchRecordZoneChangesOperation.fetchAllChanges = NO;
204 self.fetchRecordZoneChangesOperation.configuration.discretionaryNetworkBehavior = networkBehavior;
205 self.fetchRecordZoneChangesOperation.configuration.isCloudKitSupportOperation = YES;
206 self.fetchRecordZoneChangesOperation.group = self.ckoperationGroup;
207 secnotice("ckksfetch", "Operation group is %@", self.ckoperationGroup);
209 self.fetchRecordZoneChangesOperation.recordChangedBlock = ^(CKRecord *record) {
211 secinfo("ckksfetch", "CloudKit notification: record changed(%@): %@", [record recordType], record);
213 // Add this to the modifications, and remove it from the deletions
214 self.modifications[record.recordID] = record;
215 [self.deletions removeObjectForKey:record.recordID];
219 self.fetchRecordZoneChangesOperation.recordWithIDWasDeletedBlock = ^(CKRecordID *recordID, NSString *recordType) {
221 secinfo("ckksfetch", "CloudKit notification: deleted record(%@): %@", recordType, recordID);
223 // Add to the deletions, and remove any pending modifications
224 [self.modifications removeObjectForKey: recordID];
225 self.deletions[recordID] = [[CKKSCloudKitDeletion alloc] initWithRecordID:recordID recordType:recordType];
229 self.fetchRecordZoneChangesOperation.recordZoneChangeTokensUpdatedBlock = ^(CKRecordZoneID *recordZoneID, CKServerChangeToken *serverChangeToken, NSData *clientChangeTokenData) {
232 secinfo("ckksfetch", "Received a new server change token (via block) for %@: %@ %@", recordZoneID, serverChangeToken, clientChangeTokenData);
233 self.changeTokens[recordZoneID] = serverChangeToken;
236 self.fetchRecordZoneChangesOperation.recordZoneFetchCompletionBlock = ^(CKRecordZoneID *recordZoneID, CKServerChangeToken *serverChangeToken, NSData *clientChangeTokenData, BOOL moreComing, NSError * recordZoneError) {
239 secnotice("ckksfetch", "Received a new server change token for %@: %@ %@", recordZoneID, serverChangeToken, clientChangeTokenData);
240 self.changeTokens[recordZoneID] = serverChangeToken;
241 self.allClientOptions[recordZoneID].previousServerChangeToken = serverChangeToken;
243 self.moreComing |= moreComing;
245 secnotice("ckksfetch", "more changes pending for %@, will start a new fetch at change token %@", recordZoneID, self.changeTokens[recordZoneID]);
248 ckksnotice("ckksfetch", recordZoneID, "Record zone fetch complete: changeToken=%@ clientChangeTokenData=%@ moreComing=%@ error=%@", serverChangeToken, clientChangeTokenData,
249 moreComing ? @"YES" : @"NO",
252 [self sendChangesToClient:recordZoneID moreComing:moreComing];
255 // Called with overall operation success. As I understand it, this block will be called for every operation.
256 // In the case of, e.g., network failure, the recordZoneFetchCompletionBlock will not be called, but this one will.
257 self.fetchRecordZoneChangesOperation.fetchRecordZoneChangesCompletionBlock = ^(NSError * _Nullable operationError) {
260 secerror("ckksfetch: received callback for released object");
264 // Count record changes per zone
265 NSMutableDictionary<CKRecordZoneID*,NSNumber*>* recordChangesPerZone = [NSMutableDictionary dictionary];
266 self.totalModifications += self.modifications.count;
267 self.totalDeletions += self.deletions.count;
269 // All of these should have been delivered by recordZoneFetchCompletionBlock; throw them away
270 [self.modifications removeAllObjects];
271 [self.deletions removeAllObjects];
273 // If we were told that there were moreChanges coming for any zone, we'd like to fetch again.
274 // This is true if we recieve no error or a network timeout. Any other error should cause a failure.
275 if(self.moreComing && (operationError == nil || [CKKSReachabilityTracker isNetworkFailureError:operationError])) {
276 secnotice("ckksfetch", "Must issue another fetch (with potential error %@)", operationError);
277 self.moreComing = false;
283 self.error = operationError;
286 secnotice("ckksfetch", "Record zone changes fetch complete: error=%@", operationError);
288 [CKKSPowerCollection CKKSPowerEvent:kCKKSPowerEventFetchAllChanges
289 count:self.fetchedItems];
292 for(CKRecordID* recordID in self.modifications) {
293 NSNumber* last = recordChangesPerZone[recordID.zoneID];
294 recordChangesPerZone[recordID.zoneID] = [NSNumber numberWithUnsignedLong:1+(last ? [last unsignedLongValue] : 0)];
296 for(CKRecordID* recordID in self.deletions) {
297 NSNumber* last = recordChangesPerZone[recordID.zoneID];
298 recordChangesPerZone[recordID.zoneID] = [NSNumber numberWithUnsignedLong:1+(last ? [last unsignedLongValue] : 0)];
301 for(CKRecordZoneNotification* rz in self.apnsPushes) {
302 if(rz.ckksPushTracingEnabled) {
303 secnotice("ckksfetch", "Submitting post-fetch CKEventMetric due to notification %@", rz);
305 // Schedule submitting this metric on another operation, so hopefully CK will have marked this fetch as done by the time that fires?
306 CKEventMetric *metric = [[CKEventMetric alloc] initWithEventName:@"APNSPushMetrics"];
307 metric.isPushTriggerFired = true;
308 metric[@"push_token_uuid"] = rz.ckksPushTracingUUID;
309 metric[@"push_received_date"] = rz.ckksPushReceivedDate;
310 metric[@"push_event_name"] = @"CKKS Push";
312 metric[@"fetch_error"] = operationError ? @1 : @0;
313 metric[@"fetch_error_domain"] = operationError.domain;
314 metric[@"fetch_error_code"] = [NSNumber numberWithLong:operationError.code];
316 metric[@"total_modifications"] = @(self.totalModifications);
317 metric[@"total_deletions"] = @(self.totalDeletions);
318 for(CKRecordZoneID* zoneID in recordChangesPerZone) {
319 metric[zoneID.zoneName] = recordChangesPerZone[zoneID];
322 SecEventMetric *metric2 = [[SecEventMetric alloc] initWithEventName:@"APNSPushMetrics"];
323 metric2[@"push_token_uuid"] = rz.ckksPushTracingUUID;
324 metric2[@"push_received_date"] = rz.ckksPushReceivedDate;
325 metric2[@"push_event_name"] = @"CKKS Push-webtunnel";
327 metric2[@"fetch_error"] = operationError;
329 metric2[@"total_modifications"] = @(self.totalModifications);
330 metric2[@"total_deletions"] = @(self.totalDeletions);
331 for(CKRecordZoneID* zoneID in recordChangesPerZone) {
332 metric2[zoneID.zoneName] = recordChangesPerZone[zoneID];
335 // Okay, we now have this metric. But, it's unclear if calling associateWithCompletedOperation in this block will work. So, do something silly with operation scheduling.
336 // Grab pointers to these things
337 CKContainer* container = self.container;
338 CKDatabaseOperation<CKKSFetchRecordZoneChangesOperation>* rzcOperation = self.fetchRecordZoneChangesOperation;
340 CKKSResultOperation* launchMetricOp = [CKKSResultOperation named:@"submit-metric" withBlock:^{
341 if(![metric associateWithCompletedOperation:rzcOperation]) {
342 secerror("ckksfetch: Couldn't associate metric with operation: %@ %@", metric, rzcOperation);
344 [container submitEventMetric:metric];
345 [[SecMetrics managerObject] submitEvent:metric2];
346 secnotice("ckksfetch", "Metric submitted: %@", metric);
348 [launchMetricOp addSuccessDependency:self.fetchCompletedOperation];
350 [self.operationQueue addOperation:launchMetricOp];
354 // Trigger the fake 'we're done' operation.
355 [self runBeforeGroupFinished: self.fetchCompletedOperation];
357 // Drop strong pointer to clients
358 [self.clientMap removeAllObjects];
361 [self dependOnBeforeGroupFinished:self.fetchCompletedOperation];
362 [self dependOnBeforeGroupFinished:self.fetchRecordZoneChangesOperation];
363 [self.container.privateCloudDatabase addOperation:self.fetchRecordZoneChangesOperation];
366 - (void)sendChangesToClient:(CKRecordZoneID*)recordZoneID moreComing:(BOOL)moreComing
368 id<CKKSChangeFetcherClient> client = self.clientMap[recordZoneID];
370 secerror("ckksfetch: no client registered for %@, so why did we get any data?", recordZoneID);
374 // First, filter the modifications and deletions for this zone
375 NSMutableArray<CKRecord*>* zoneModifications = [NSMutableArray array];
376 NSMutableArray<CKKSCloudKitDeletion*>* zoneDeletions = [NSMutableArray array];
378 [self.modifications enumerateKeysAndObjectsUsingBlock:^(CKRecordID* _Nonnull recordID,
379 CKRecord* _Nonnull record,
381 if([recordID.zoneID isEqual:recordZoneID]) {
382 ckksinfo("ckksfetch", recordZoneID, "Sorting record modification %@: %@", recordID, record);
383 [zoneModifications addObject:record];
387 [self.deletions enumerateKeysAndObjectsUsingBlock:^(CKRecordID* _Nonnull recordID,
388 CKKSCloudKitDeletion* _Nonnull deletion,
389 BOOL* _Nonnull stop) {
390 if([recordID.zoneID isEqual:recordZoneID]) {
391 ckksinfo("ckksfetch", recordZoneID, "Sorting record deletion %@: %@", recordID, deletion);
392 [zoneDeletions addObject:deletion];
396 BOOL resync = [self.resyncingZones containsObject:recordZoneID];
398 ckksnotice("ckksfetch", recordZoneID, "Delivering fetched changes: changed=%lu deleted=%lu moreComing=%lu resync=%u",
399 (unsigned long)zoneModifications.count, (unsigned long)zoneDeletions.count, (unsigned long)moreComing, resync);
401 // Tell the client about these changes!
402 [client changesFetched:zoneModifications
403 deletedRecordIDs:zoneDeletions
404 newChangeToken:self.changeTokens[recordZoneID]
405 moreComing:moreComing
408 if(resync && !moreComing) {
409 ckksnotice("ckksfetch", recordZoneID, "No more changes for zone; turning off resync bit");
410 [self.resyncingZones removeObject:recordZoneID];
415 [self.fetchRecordZoneChangesOperation cancel];