OTRamping.m   [plain text]


/*
 * Copyright (c) 2018 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

#import <Foundation/Foundation.h>
#import <CoreFoundation/CoreFoundation.h>
#import <CloudKit/CloudKit.h>
#import <CloudKit/CKContainer_Private.h>
#import <utilities/debugging.h>
#import "OTRamping.h"
#import "keychain/ckks/CKKS.h"
#import "keychain/ckks/CKKSNearFutureScheduler.h"
#import "keychain/ckks/CKKSAnalytics.h"
#import "keychain/ot/OTDefines.h"

static NSString* kFeatureAllowedKey =       @"FeatureAllowed";
static NSString* kFeaturePromotedKey =      @"FeaturePromoted";
static NSString* kFeatureVisibleKey =       @"FeatureVisible";
static NSString* kRetryAfterKey =           @"RetryAfter";
static NSString* kRampPriorityKey =         @"RampPriority";

#define kCKRampManagerDefaultRetryTimeInSeconds 86400

#if OCTAGON
@interface OTRamp (lockstateTracker) <CKKSLockStateNotification>
@end
#endif

@interface OTRamp ()
@property (nonatomic, strong) CKContainer   *container;
@property (nonatomic, strong) CKDatabase    *database;

@property (nonatomic, strong) CKRecordZone      *zone;
@property (nonatomic, strong) CKRecordZoneID    *zoneID;

@property (nonatomic, strong) NSString      *recordName;
@property (nonatomic, strong) NSString      *featureName;
@property (nonatomic, strong) CKRecordID    *recordID;

@property (nonatomic, strong) CKKSCKAccountStateTracker *accountTracker;
@property (nonatomic, strong) CKKSLockStateTracker      *lockStateTracker;
@property (nonatomic, strong) CKKSReachabilityTracker   *reachabilityTracker;

@property CKKSAccountStatus accountStatus;

@property (readonly) Class<CKKSFetchRecordsOperation> fetchRecordRecordsOperationClass;

@end

@implementation OTRamp

-(instancetype) initWithRecordName:(NSString *) recordName
                       featureName:(NSString*) featureName
                         container:(CKContainer*) container
                          database:(CKDatabase*) database
                            zoneID:(CKRecordZoneID*) zoneID
                    accountTracker:(CKKSCKAccountStateTracker*) accountTracker
                  lockStateTracker:(CKKSLockStateTracker*) lockStateTracker
               reachabilityTracker:(CKKSReachabilityTracker*) reachabilityTracker
fetchRecordRecordsOperationClass:(Class<CKKSFetchRecordsOperation>) fetchRecordRecordsOperationClass

{
    self = [super init];
    if(self){
        _container = container;
        _recordName = [recordName copy];
        _featureName = [featureName copy];
        _database = database;
        _zoneID = zoneID;
        _accountTracker = accountTracker;
        _lockStateTracker = lockStateTracker;
        _reachabilityTracker = reachabilityTracker;
        _fetchRecordRecordsOperationClass = fetchRecordRecordsOperationClass;
    }
    return self;
}

-(void) fetchRampRecord:(CKOperationDiscretionaryNetworkBehavior)networkBehavior reply:(void (^)(BOOL featureAllowed, BOOL featurePromoted, BOOL featureVisible, NSInteger retryAfter, NSError *rampStateFetchError))recordRampStateFetchCompletionBlock
{
    __weak __typeof(self) weakSelf = self;

    CKOperationConfiguration *opConfig = [[CKOperationConfiguration alloc] init];
    opConfig.allowsCellularAccess = YES;
    opConfig.discretionaryNetworkBehavior = networkBehavior;

    _recordID = [[CKRecordID alloc] initWithRecordName:_recordName zoneID:_zoneID];
    CKFetchRecordsOperation *operation = [[[self.fetchRecordRecordsOperationClass class] alloc] initWithRecordIDs:@[ _recordID]];

    operation.desiredKeys = @[kFeatureAllowedKey, kFeaturePromotedKey, kFeatureVisibleKey, kRetryAfterKey];
    
    operation.configuration = opConfig;
    operation.fetchRecordsCompletionBlock = ^(NSDictionary<CKRecordID *,CKRecord *> * _Nullable recordsByRecordID, NSError * _Nullable operationError) {
        __strong __typeof(weakSelf) strongSelf = weakSelf;
        if(!strongSelf) {
            secnotice("octagon", "received callback for released object");
            operationError = [NSError errorWithDomain:octagonErrorDomain code:OTErrorCKCallback userInfo:@{NSLocalizedDescriptionKey: @"Received callback for released object"}];
            recordRampStateFetchCompletionBlock(NO, NO, NO, kCKRampManagerDefaultRetryTimeInSeconds , operationError);
            return;
        }

        BOOL featureAllowed = NO;
        BOOL featurePromoted = NO;
        BOOL featureVisible = NO;
        NSInteger retryAfter = kCKRampManagerDefaultRetryTimeInSeconds;

        secnotice("octagon", "Fetch operation records %@ fetchError %@", recordsByRecordID, operationError);
        // There should only be only one record.
        CKRecord *rampRecord = recordsByRecordID[strongSelf.recordID];

        if (rampRecord) {
            featureAllowed = [rampRecord[kFeatureAllowedKey] boolValue];
            featurePromoted = [rampRecord[kFeaturePromotedKey] boolValue];
            featureVisible = [rampRecord[kFeatureVisibleKey] boolValue];
            retryAfter = [rampRecord[kRetryAfterKey] integerValue];

            secnotice("octagon", "Fetch ramp state - featureAllowed %@, featurePromoted: %@, featureVisible: %@, retryAfter: %ld", (featureAllowed ? @YES : @NO), (featurePromoted ? @YES : @NO), (featureVisible ? @YES : @NO), (long)retryAfter);
        } else {
            secerror("octagon: Couldn't find CKRecord for ramp. Defaulting to not ramped in");
            operationError = [NSError errorWithDomain:octagonErrorDomain code:OTErrorRecordNotFound userInfo:@{NSLocalizedDescriptionKey: @" Couldn't find CKRecord for ramp. Defaulting to not ramped in"}];
        }
        recordRampStateFetchCompletionBlock(featureAllowed, featurePromoted, featureVisible, retryAfter, operationError);
    };

    [self.database addOperation: operation];
    secnotice("octagon", "Attempting to fetch ramp state from CloudKit");
}

-(BOOL) checkRampState:(NSInteger*)retryAfter networkBehavior:(CKOperationDiscretionaryNetworkBehavior)networkBehavior error:(NSError**)error
{
    __block BOOL isFeatureEnabled = NO;
    __block NSError* localError = nil;
    __block NSInteger localRetryAfter = 0;

    if(self.lockStateTracker.isLocked){
        secnotice("octagon","device is locked! can't check ramp state");
        localError = [NSError errorWithDomain:(__bridge NSString*)kSecErrorDomain
                                         code:errSecInteractionNotAllowed
                                     userInfo:@{NSLocalizedDescriptionKey: @"device is locked"}];
        if(error){
            *error = localError;
        }
        return NO;
    }
    if(self.accountTracker.currentCKAccountInfo.accountStatus != CKAccountStatusAvailable){
        secnotice("octagon","not signed in! can't check ramp state");
        localError = [NSError errorWithDomain:octagonErrorDomain
                                         code:OTErrorNotSignedIn
                                     userInfo:@{NSLocalizedDescriptionKey: @"not signed in"}];
        if(error){
            *error = localError;
        }
        return NO;
    }
    if(!self.reachabilityTracker.currentReachability){
        secnotice("octagon","no network! can't check ramp state");
        localError = [NSError errorWithDomain:octagonErrorDomain
                                         code:OTErrorNoNetwork
                                     userInfo:@{NSLocalizedDescriptionKey: @"no network"}];
        if(error){
            *error = localError;
        }
        return NO;
    }

    //defaults write to for whether or not a ramp record returns "enabled or disabled"
    CFBooleanRef enabled = (CFBooleanRef)CFPreferencesCopyValue((__bridge CFStringRef)self.recordName,
                                                                CFSTR("com.apple.security"),
                                                                kCFPreferencesAnyUser, kCFPreferencesAnyHost);
    if(enabled && CFGetTypeID(enabled) == CFBooleanGetTypeID()){
        BOOL localConfigEnable = (enabled == kCFBooleanTrue);
        secnotice("octagon", "feature is %@: %@ (local config)", localConfigEnable ? @"enabled" : @"disabled", self.recordName);
        CFReleaseNull(enabled);
        return localConfigEnable;
    }
    CFReleaseNull(enabled);
    
    CKKSAnalytics* logger = [CKKSAnalytics logger];
    SFAnalyticsActivityTracker *tracker = [logger logSystemMetricsForActivityNamed:CKKSActivityOTFetchRampState withAction:nil];

    dispatch_semaphore_t sema = dispatch_semaphore_create(0);

    [tracker start];

    [self fetchRampRecord:networkBehavior reply:^(BOOL featureAllowed, BOOL featurePromoted, BOOL featureVisible, NSInteger retryAfter, NSError *rampStateFetchError) {
        secnotice("octagon", "fetch ramp records returned with featureAllowed: %d,\n featurePromoted: %d,\n featureVisible: %d,\n", featureAllowed, featurePromoted, featureVisible);

        isFeatureEnabled = featureAllowed;
        localRetryAfter = retryAfter;
        if(rampStateFetchError){
            localError = rampStateFetchError;
        }
        dispatch_semaphore_signal(sema);
    }];

    long timeout = (SecCKKSTestsEnabled() ? 2*NSEC_PER_SEC : NSEC_PER_SEC * 65);
    if(dispatch_semaphore_wait(sema, dispatch_time(DISPATCH_TIME_NOW, timeout)) != 0) {
        secnotice("octagon", "timed out waiting for response from CloudKit\n");
        localError = [NSError errorWithDomain:octagonErrorDomain code:OTErrorCKTimeOut userInfo:@{NSLocalizedDescriptionKey: @"Failed to deserialize bottle peer"}];

        [logger logUnrecoverableError:localError forEvent:OctagonEventRamp withAttributes:@{
                                                                                            OctagonEventAttributeFailureReason : @"cloud kit timed out"}
         ];
    }

    [tracker stop];

    if(localRetryAfter > 0){
        secnotice("octagon", "cloud kit asked security to retry: %lu", (unsigned long)localRetryAfter);
        *retryAfter = localRetryAfter;
    }

    if(localError){
        secerror("octagon: had an error fetching ramp state: %@", localError);
        [logger logUnrecoverableError:localError forEvent:OctagonEventRamp withAttributes:@{
                                                                                            OctagonEventAttributeFailureReason : @"fetching ramp state"}
         ];
        if(error){
            *error = localError;
        }
    }
    if(isFeatureEnabled){
        [logger logSuccessForEventNamed:OctagonEventRamp];
    }

    return isFeatureEnabled;

}

- (void)ckAccountStatusChange:(CKKSAccountStatus)oldStatus to:(CKKSAccountStatus)currentStatus {
    secnotice("octagon", "%@ Received notification of CloudKit account status change, moving from %@ to %@",
              self.zoneID.zoneName,
              [CKKSCKAccountStateTracker stringFromAccountStatus: oldStatus],
              [CKKSCKAccountStateTracker stringFromAccountStatus: currentStatus]);

    switch(currentStatus) {
        case CKKSAccountStatusAvailable: {
            secnotice("octagon", "Logged into iCloud.");
            self.accountStatus = CKKSAccountStatusAvailable;
        }
            break;

        case CKKSAccountStatusNoAccount: {
            secnotice("octagon", "Logging out of iCloud. Shutting down.");
            self.accountStatus = CKKSAccountStatusNoAccount;
        }
            break;

        case CKKSAccountStatusUnknown: {
            // We really don't expect to receive this as a notification, but, okay!
            secnotice("octagon", "Account status has become undetermined. Pausing for %@", self.zoneID.zoneName);
            self.accountStatus = CKKSAccountStatusNoAccount;

        }
            break;
    }
}

@end
#endif