CKKSAccountStateTracker.m [plain text]
/*
* Copyright (c) 2017 Apple Inc. All Rights Reserved.
*
* @APPLE_LICENSE_HEADER_START@
*
* This file contains Original Code and/or Modifications of Original Code
* as defined in and that are subject to the Apple Public Source License
* Version 2.0 (the 'License'). You may not use this file except in
* compliance with the License. Please obtain a copy of the License at
* http://www.opensource.apple.com/apsl/ and read it before using this
* file.
*
* The Original Code and all software distributed under the License are
* distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, EITHER
* EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES,
* INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT.
* Please see the License for the specific language governing rights and
* limitations under the License.
*
* @APPLE_LICENSE_HEADER_END@
*/
#if OCTAGON
#include <dispatch/dispatch.h>
#include <Security/SecureObjectSync/SOSCloudCircle.h>
#include <Security/SecureObjectSync/SOSCloudCircleInternal.h>
#include "keychain/SecureObjectSync/SOSInternal.h"
#include <notify.h>
#import "keychain/ot/OTManager.h"
#import "keychain/ot/OTConstants.h"
#import "keychain/ckks/CKKS.h"
#import "keychain/ckks/CloudKitCategories.h"
#import "keychain/ckks/CKKSAccountStateTracker.h"
#import "keychain/ckks/CKKSAnalytics.h"
#import "keychain/categories/NSError+UsefulConstructors.h"
#import "keychain/ot/ObjCImprovements.h"
NSString* CKKSAccountStatusToString(CKKSAccountStatus status)
{
switch(status) {
case CKKSAccountStatusAvailable:
return @"available";
case CKKSAccountStatusNoAccount:
return @"no account";
case CKKSAccountStatusUnknown:
return @"unknown";
}
}
@interface CKKSAccountStateTracker ()
@property (readonly) Class<CKKSNSNotificationCenter> nsnotificationCenterClass;
@property dispatch_queue_t queue;
@property NSMapTable<dispatch_queue_t, id<CKKSCloudKitAccountStateListener>>* ckChangeListeners;
@property CKContainer* container; // used only for fetching the CKAccountStatus
@property bool firstCKAccountFetch;
// make writable
@property (nullable) OTCliqueStatusWrapper* octagonStatus;
@property (nullable) NSString* octagonPeerID;
@property CKKSCondition* octagonInformationInitialized;
@property CKKSAccountStatus hsa2iCloudAccountStatus;
@property CKKSCondition* hsa2iCloudAccountInitialized;
@end
@implementation CKKSAccountStateTracker
@synthesize octagonPeerID = _octagonPeerID;
-(instancetype)init: (CKContainer*) container nsnotificationCenterClass: (Class<CKKSNSNotificationCenter>) nsnotificationCenterClass {
if((self = [super init])) {
_nsnotificationCenterClass = nsnotificationCenterClass;
// These map tables are backwards from how we'd like, but it's the best way to have weak pointers to CKKSCombinedAccountStateListener.
_ckChangeListeners = [NSMapTable strongToWeakObjectsMapTable];
_currentCKAccountInfo = nil;
_ckAccountInfoInitialized = [[CKKSCondition alloc] init];
_currentCircleStatus = [[SOSAccountStatus alloc] init:kSOSCCError error:nil];
_container = container;
_queue = dispatch_queue_create("ck-account-state", DISPATCH_QUEUE_SERIAL_WITH_AUTORELEASE_POOL);
_firstCKAccountFetch = false;
_finishedInitialDispatches = [[CKKSCondition alloc] init];
_ckdeviceIDInitialized = [[CKKSCondition alloc] init];
_octagonInformationInitialized = [[CKKSCondition alloc] init];
_hsa2iCloudAccountStatus = CKKSAccountStatusUnknown;
_hsa2iCloudAccountInitialized = [[CKKSCondition alloc] init];
id<CKKSNSNotificationCenter> notificationCenter = [self.nsnotificationCenterClass defaultCenter];
ckksinfo_global("ckksaccount", "Registering with notification center [notificationCenter addObserver:self selector:@selector(notifyCKAccountStatusChange:) name:CKAccountChangedNotification object:NULL];
WEAKIFY(self);
// If this is a live server, register with notify
if(!SecCKKSTestsEnabled()) {
int token = 0;
notify_register_dispatch(kSOSCCCircleChangedNotification, &token, dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^(int t) {
STRONGIFY(self);
[self notifyCircleChange:nil];
});
// Fire off a fetch of the account status. Do not go on our local queue, because notifyCKAccountStatusChange will attempt to go back on it for thread-safety.
// Note: if you're in the tests, you must call performInitialDispatches yourself!
dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
STRONGIFY(self);
if(!self) {
return;
}
@autoreleasepool {
[self performInitialDispatches];
}
});
}
}
return self;
}
- (void)performInitialDispatches
{
@autoreleasepool {
[self notifyCKAccountStatusChange:nil];
[self notifyCircleChange:nil];
[self.finishedInitialDispatches fulfill];
}
}
-(void)dealloc {
id<CKKSNSNotificationCenter> notificationCenter = [self.nsnotificationCenterClass defaultCenter];
[notificationCenter removeObserver:self];
}
-(NSString*)descriptionInternal: (NSString*) selfString {
return [NSString stringWithFormat:@"< selfString,
self.currentCKAccountInfo,
CKKSAccountStatusToString(self.hsa2iCloudAccountStatus)];
}
-(NSString*)description {
return [self descriptionInternal: [[self class] description]];
}
-(NSString*)debugDescription {
return [self descriptionInternal: [super description]];
}
- (dispatch_semaphore_t)registerForNotificationsOfCloudKitAccountStatusChange:(id<CKKSCloudKitAccountStateListener>)listener {
// signals when we've successfully delivered the first account status
dispatch_semaphore_t finishedSema = dispatch_semaphore_create(0);
dispatch_async(self.queue, ^{
bool alreadyRegisteredListener = false;
NSEnumerator *enumerator = [self.ckChangeListeners objectEnumerator];
id<CKKSCloudKitAccountStateListener> value;
while ((value = [enumerator nextObject])) {
// do pointer comparison
alreadyRegisteredListener |= (value == listener);
}
if(listener && !alreadyRegisteredListener) {
NSString* queueName = [NSString stringWithFormat: @"ck-account-state-
dispatch_queue_t objQueue = dispatch_queue_create([queueName UTF8String], DISPATCH_QUEUE_SERIAL_WITH_AUTORELEASE_POOL);
[self.ckChangeListeners setObject:listener forKey: objQueue];
ckksinfo_global("ckksaccount-ck", "adding a new listener:
// If we know the current account status, let this listener know
if(self.firstCKAccountFetch) {
ckksinfo_global("ckksaccount-ck", "notifying new listener
dispatch_group_t g = dispatch_group_create();
if(!g) {
ckkserror_global("ckksaccount-ck", "Unable to get dispatch group.");
dispatch_semaphore_signal(finishedSema);
return;
}
[self _onqueueDeliverCurrentCloudKitState:listener listenerQueue:objQueue oldStatus:nil group:g];
dispatch_group_notify(g, self.queue, ^{
dispatch_semaphore_signal(finishedSema);
});
} else {
dispatch_semaphore_signal(finishedSema);
}
} else {
dispatch_semaphore_signal(finishedSema);
}
});
return finishedSema;
}
- (dispatch_semaphore_t)notifyCKAccountStatusChange:(__unused id)object {
// signals when this notify is Complete, including all downcalls.
dispatch_semaphore_t finishedSema = dispatch_semaphore_create(0);
WEAKIFY(self);
[self.container accountInfoWithCompletionHandler:^(CKAccountInfo* ckAccountInfo, NSError * _Nullable error) {
STRONGIFY(self);
if(error) {
ckkserror_global("ckksaccount", "error getting account info: dispatch_semaphore_signal(finishedSema);
return;
}
dispatch_sync(self.queue, ^{
self.firstCKAccountFetch = true;
ckksnotice_global("ckksaccount", "received CK Account info: [self _onqueueUpdateAccountState:ckAccountInfo deliveredSemaphore:finishedSema];
});
}];
return finishedSema;
}
// Takes the new ckAccountInfo we're moving to
-(void)_onqueueUpdateCKDeviceID:(CKAccountInfo*)ckAccountInfo {
dispatch_assert_queue(self.queue);
WEAKIFY(self);
// If we're in an account, opportunistically fill in the device id
if(ckAccountInfo.accountStatus == CKAccountStatusAvailable) {
[self.container fetchCurrentDeviceIDWithCompletionHandler:^(NSString* deviceID, NSError* ckerror) {
STRONGIFY(self);
if(!self) {
ckkserror_global("ckksaccount", "Received fetchCurrentDeviceIDWithCompletionHandler callback with null AccountStateTracker");
return;
}
// Make sure you synchronize here; if we've logged out before the callback returns, don't record the result
dispatch_async(self.queue, ^{
STRONGIFY(self);
if(self.currentCKAccountInfo.accountStatus == CKAccountStatusAvailable) {
ckksnotice_global("ckksaccount", "CloudKit deviceID is:
self.ckdeviceID = deviceID;
self.ckdeviceIDError = ckerror;
[self.ckdeviceIDInitialized fulfill];
} else {
// Logged out! No ckdeviceid.
ckkserror_global("ckksaccount", "Logged back out but still received a fetchCurrentDeviceIDWithCompletionHandler callback");
self.ckdeviceID = nil;
self.ckdeviceIDError = nil;
// Don't touch the ckdeviceIDInitialized object; it should have been reset when the logout happened.
}
});
}];
} else {
// Logging out? no more device ID.
self.ckdeviceID = nil;
self.ckdeviceIDError = nil;
self.ckdeviceIDInitialized = [[CKKSCondition alloc] init];
}
}
- (dispatch_semaphore_t)notifyCircleChange:(__unused id)object {
dispatch_semaphore_t finishedSema = dispatch_semaphore_create(0);
SOSAccountStatus* sosstatus = [CKKSAccountStateTracker getCircleStatus];
dispatch_sync(self.queue, ^{
if(self.currentCircleStatus == nil || ![self.currentCircleStatus isEqual:sosstatus]) {
ckksnotice_global("ckksaccount", "moving to circle status: self.currentCircleStatus = sosstatus;
if (sosstatus.status == kSOSCCInCircle) {
[[CKKSAnalytics logger] setDateProperty:[NSDate date] forKey:CKKSAnalyticsLastInCircle];
}
[self _onqueueUpdateCirclePeerID:sosstatus];
}
dispatch_semaphore_signal(finishedSema);
});
return finishedSema;
}
// Pulled out for mocking purposes
+ (void)fetchCirclePeerID:(void (^)(NSString* _Nullable peerID, NSError* _Nullable error))callback {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
CFErrorRef cferror = nil;
SOSPeerInfoRef egoPeerInfo = SOSCCCopyMyPeerInfo(&cferror);
NSString* egoPeerID = egoPeerInfo ? (NSString*)CFBridgingRelease(CFRetainSafe(SOSPeerInfoGetPeerID(egoPeerInfo))) : nil;
CFReleaseNull(egoPeerInfo);
callback(egoPeerID, CFBridgingRelease(cferror));
});
}
// Takes the new ckAccountInfo we're moving to
- (void)_onqueueUpdateCirclePeerID:(SOSAccountStatus*)sosstatus {
dispatch_assert_queue(self.queue);
WEAKIFY(self);
// If we're in a circle, fetch the peer id
if(sosstatus.status == kSOSCCInCircle) {
[CKKSAccountStateTracker fetchCirclePeerID:^(NSString* peerID, NSError* error) {
STRONGIFY(self);
if(!self) {
ckkserror_global("ckksaccount", "Received fetchCirclePeerID callback with null AccountStateTracker");
return;
}
dispatch_async(self.queue, ^{
STRONGIFY(self);
if(self.currentCircleStatus && self.currentCircleStatus.status == kSOSCCInCircle) {
ckksnotice_global("ckksaccount", "Circle peerID is: // Still in circle. Proceed.
self.accountCirclePeerID = peerID;
self.accountCirclePeerIDError = error;
[self.accountCirclePeerIDInitialized fulfill];
} else {
ckkserror_global("ckksaccount", "Out of circle but still received a fetchCirclePeerID callback");
// Not in-circle. Throw away circle id.
self.accountCirclePeerID = nil;
self.accountCirclePeerIDError = nil;
// Don't touch the accountCirclePeerIDInitialized object; it should have been reset when the logout happened.
}
});
}];
} else {
// Not in-circle, reset circle ID
ckksnotice_global("ckksaccount", "out of circle( self.accountCirclePeerID = nil;
self.accountCirclePeerIDError = nil;
self.accountCirclePeerIDInitialized = [[CKKSCondition alloc] init];
}
}
- (void)_onqueueUpdateAccountState:(CKAccountInfo*)ckAccountInfo
deliveredSemaphore:(dispatch_semaphore_t)finishedSema
{
// Launder the finishedSema into a dispatch_group.
// _onqueueUpdateAccountState:circle:dispatchGroup: will then add any blocks it thinks is necessary,
// then the group will fire the semaphore.
dispatch_assert_queue(self.queue);
dispatch_group_t g = dispatch_group_create();
if(!g) {
ckksnotice_global("ckksaccount", "Unable to get dispatch group.");
dispatch_semaphore_signal(finishedSema);
return;
}
[self _onqueueUpdateAccountState:ckAccountInfo
dispatchGroup:g];
dispatch_group_notify(g, self.queue, ^{
dispatch_semaphore_signal(finishedSema);
});
}
- (void)_onqueueUpdateAccountState:(CKAccountInfo*)ckAccountInfo
dispatchGroup:(dispatch_group_t)g
{
dispatch_assert_queue(self.queue);
if([self.currentCKAccountInfo isEqual: ckAccountInfo]) {
// no-op.
ckksinfo_global("ckksaccount", "received another notification of CK Account State return;
}
if((self.currentCKAccountInfo == nil && ckAccountInfo != nil) ||
!(self.currentCKAccountInfo == ckAccountInfo || [self.currentCKAccountInfo isEqual: ckAccountInfo])) {
ckksnotice_global("ckksaccount", "moving to CK Account info: CKAccountInfo* oldAccountInfo = self.currentCKAccountInfo;
self.currentCKAccountInfo = ckAccountInfo;
[self.ckAccountInfoInitialized fulfill];
[self _onqueueUpdateCKDeviceID: ckAccountInfo];
[self _onqueueDeliverCloudKitStateChanges:oldAccountInfo dispatchGroup:g];
}
}
- (void)_onqueueDeliverCloudKitStateChanges:(CKAccountInfo*)oldStatus
dispatchGroup:(dispatch_group_t)g
{
dispatch_assert_queue(self.queue);
NSEnumerator *enumerator = [self.ckChangeListeners keyEnumerator];
dispatch_queue_t dq;
// Queue up the changes for each listener.
while ((dq = [enumerator nextObject])) {
id<CKKSCloudKitAccountStateListener> listener = [self.ckChangeListeners objectForKey: dq];
[self _onqueueDeliverCurrentCloudKitState:listener listenerQueue:dq oldStatus:oldStatus group:g];
}
}
- (void)_onqueueDeliverCurrentCloudKitState:(id<CKKSCloudKitAccountStateListener>)listener
listenerQueue:(dispatch_queue_t)listenerQueue
oldStatus:(CKAccountInfo* _Nullable)oldStatus
group:(dispatch_group_t)g
{
dispatch_assert_queue(self.queue);
__weak __typeof(listener) weakListener = listener;
if(listener) {
dispatch_group_async(g, listenerQueue, ^{
[weakListener cloudkitAccountStateChange:oldStatus to:self.currentCKAccountInfo];
});
}
}
- (BOOL)notifyCKAccountStatusChangeAndWait:(dispatch_time_t)timeout
{
return dispatch_semaphore_wait([self notifyCKAccountStatusChange:nil], dispatch_time(DISPATCH_TIME_NOW, timeout)) == 0;
}
-(void)notifyCKAccountStatusChangeAndWaitForSignal {
[self notifyCKAccountStatusChangeAndWait:DISPATCH_TIME_FOREVER];
}
-(void)notifyCircleStatusChangeAndWaitForSignal {
dispatch_semaphore_wait([self notifyCircleChange: nil], DISPATCH_TIME_FOREVER);
}
-(dispatch_group_t)checkForAllDeliveries {
dispatch_group_t g = dispatch_group_create();
if(!g) {
ckksnotice_global("ckksaccount", "Unable to get dispatch group.");
return nil;
}
dispatch_sync(self.queue, ^{
NSEnumerator *enumerator = [self.ckChangeListeners keyEnumerator];
dispatch_queue_t dq;
// Queue up the changes for each listener.
while ((dq = [enumerator nextObject])) {
id<CKKSCloudKitAccountStateListener> listener = [self.ckChangeListeners objectForKey: dq];
ckksinfo_global("ckksaccountblock", "Starting blocking for listener WEAKIFY(listener);
dispatch_group_async(g, dq, ^{
STRONGIFY(listener);
// Do nothing in particular. It's just important that this block runs.
ckksinfo_global("ckksaccountblock", "Done blocking for listener });
}
});
return g;
}
// This is its own function to allow OCMock to swoop in and replace the result during testing.
+ (SOSAccountStatus*)getCircleStatus {
CFErrorRef cferror = NULL;
SOSCCStatus status = SOSCCThisDeviceIsInCircle(&cferror);
if(cferror) {
ckkserror_global("ckksaccount", "error getting circle status: return [[SOSAccountStatus alloc] init:kSOSCCError error:CFBridgingRelease(cferror)];
}
return [[SOSAccountStatus alloc] init:status error:nil];
}
+ (NSString*)stringFromAccountStatus: (CKKSAccountStatus) status {
switch(status) {
case CKKSAccountStatusUnknown: return @"account state unknown";
case CKKSAccountStatusAvailable: return @"logged in";
case CKKSAccountStatusNoAccount: return @"no account";
}
}
- (void)triggerOctagonStatusFetch
{
WEAKIFY(self);
__block CKKSCondition* blockPointer = nil;
dispatch_sync(self.queue, ^{
self.octagonInformationInitialized = [[CKKSCondition alloc] initToChain:self.octagonInformationInitialized];
blockPointer = self.octagonInformationInitialized;
});
// Explicitly do not use the OTClique API, as that might include SOS status as well
OTOperationConfiguration* config = [[OTOperationConfiguration alloc] init];
config.timeoutWaitForCKAccount = 100*NSEC_PER_MSEC;
[[OTManager manager] fetchTrustStatus:nil
context:OTDefaultContext
configuration:config
reply:^(CliqueStatus status, NSString * _Nullable peerID, NSNumber * _Nullable numberOfPeersInOctagon, BOOL isExcluded, NSError * _Nullable error) {
STRONGIFY(self);
dispatch_sync(self.queue, ^{
if(error) {
ckkserror_global("ckksaccount", "error getting octagon status: self.octagonStatus = [[OTCliqueStatusWrapper alloc] initWithStatus:CliqueStatusError];
} else {
ckksnotice_global("ckksaccount", "Caching octagon status as ( self.octagonStatus = [[OTCliqueStatusWrapper alloc] initWithStatus:status];
}
self.octagonPeerID = peerID;
[blockPointer fulfill];
});
}];
}
- (void)setHSA2iCloudAccountStatus:(CKKSAccountStatus)status
{
self.hsa2iCloudAccountStatus = status;
if(status == CKKSAccountStatusUnknown) {
self.hsa2iCloudAccountInitialized = [[CKKSCondition alloc] initToChain:self.hsa2iCloudAccountInitialized];
} else {
[self.hsa2iCloudAccountInitialized fulfill];
}
}
@end
@implementation SOSAccountStatus
- (instancetype)init:(SOSCCStatus)status error:(NSError*)error
{
if((self = [super init])) {
_status = status;
_error = error;
}
return self;
}
- (BOOL)isEqual:(id)object
{
if(![object isKindOfClass:[SOSAccountStatus class]]) {
return NO;
}
if(object == nil) {
return NO;
}
SOSAccountStatus* obj = (SOSAccountStatus*) object;
return self.status == obj.status &&
((self.error == nil && obj.error == nil) || [self.error isEqual:obj.error]);
}
- (NSString*)description
{
return [NSString stringWithFormat:@"<SOSStatus: }
@end
@implementation OTCliqueStatusWrapper
- (instancetype)initWithStatus:(CliqueStatus)status
{
if((self = [super init])) {
_status = status;
}
return self;
}
- (BOOL)isEqual:(id)object
{
if(![object isKindOfClass:[OTCliqueStatusWrapper class]]) {
return NO;
}
OTCliqueStatusWrapper* obj = (OTCliqueStatusWrapper*)object;
return obj.status == self.status;
}
- (NSString*)description
{
return [NSString stringWithFormat:@"<CliqueStatus: }
@end
#endif // OCTAGON