OTPairingService.m   [plain text]

#import <TargetConditionals.h>
#import <Foundation/Foundation.h>
#import <IDS/IDS.h>
#import <KeychainCircle/KeychainCircle.h>
#import <os/assumes.h>
#import <xpc/private.h>

#if TARGET_OS_WATCH
#import <Security/OTControl.h>
#endif /* TARGET_OS_WATCH */

#if !TARGET_OS_SIMULATOR
#import <MobileKeyBag/MobileKeyBag.h>
#endif /* !TARGET_OS_SIMULATOR */

#import "OTPairingService.h"
#import "OTPairingPacketContext.h"
#import "OTPairingSession.h"
#import "OTPairingConstants.h"

#import "keychain/categories/NSError+UsefulConstructors.h"
#import "keychain/ot/OTDeviceInformationAdapter.h"

#define WAIT_FOR_UNLOCK_DURATION (120ull)

@interface OTPairingService ()
@property dispatch_queue_t queue;
@property IDSService *service;
@property dispatch_source_t unlockTimer;
@property int notifyToken;
@property (nonatomic, strong) OTDeviceInformationActualAdapter *deviceInfo;
@property OTPairingSession *session;
@end

@implementation OTPairingService

+ (instancetype)sharedService
{
static dispatch_once_t once;
static OTPairingService *service;

dispatch_once(&once, ^{
service = [[OTPairingService alloc] init];
});

return service;
}

- (instancetype)init
{
if ((self = [super init])) {
self.queue = dispatch_queue_create("com.apple.security.otpaird", DISPATCH_QUEUE_SERIAL);
self.service = [[IDSService alloc] initWithService:OTPairingIDSServiceName];
[self.service addDelegate:self queue:self.queue];
self.notifyToken = NOTIFY_TOKEN_INVALID;
self.deviceInfo = [[OTDeviceInformationActualAdapter alloc] init];
}
return self;
}

- (NSString *)pairedDeviceNotificationName
{
NSString *result = nil;
for (IDSDevice *device in self.service.devices) {
if (device.isDefaultPairedDevice) {
result = [NSString stringWithFormat:@"ids-device-state-%@", device.uniqueIDOverride];
break;
}
}
return result;
}

#if TARGET_OS_WATCH
- (void)initiatePairingWithCompletion:(OTPairingCompletionHandler)completionHandler
{
dispatch_assert_queue_not(self.queue);

if ([self _octagonInClique]) {
os_log(OS_LOG_DEFAULT, "already in octagon, bailing");
completionHandler(false, [NSError errorWithDomain:OTPairingErrorDomain code:OTPairingErrorTypeAlreadyIn description:@"already in octagon"]);
return;
}

dispatch_async(self.queue, ^{
if (self.session != nil) {
completionHandler(false, [NSError errorWithDomain:OTPairingErrorDomain code:OTPairingErrorTypeBusy description:@"pairing in progress"]);
return;
}

self.session = [[OTPairingSession alloc] initWithDeviceInfo:self.deviceInfo];
self.session.completionHandler = completionHandler;
[self sendReplyToPacket];
});
}
#endif /* TARGET_OS_WATCH */

// Should be a delegate method - future refactor
- (void)session:(__unused OTPairingSession *)session didCompleteWithSuccess:(bool)success error:(NSError *)error
{
os_assert(self.session == session);

#if TARGET_OS_WATCH
self.session.completionHandler(success, error);
#endif /* TARGET_OS_WATCH */

self.session = nil;
self.unlockTimer = nil;
}

#pragma mark -

- (void)sendReplyToPacket
{
dispatch_assert_queue(self.queue);

#if !TARGET_OS_SIMULATOR
NSDictionary *lockOptions;
CFErrorRef lockError = NULL;

lockOptions = @{
(__bridge NSString *)kMKBAssertionTypeKey : (__bridge NSString *)kMKBAssertionTypeOther,
(__bridge NSString *)kMKBAssertionTimeoutKey : @(60),
};
self.session.lockAssertion = MKBDeviceLockAssertion((__bridge CFDictionaryRef)lockOptions, &lockError);

if (self.session.lockAssertion) {
[self stopWaitingForDeviceUnlock];
[self exchangePacketAndReply];
} else {
os_log(OS_LOG_DEFAULT, "Failed to obtain lock assertion: %@", lockError);
if (lockError) {
CFRelease(lockError);
}
[self waitForDeviceUnlock];
}
#else /* TARGET_OS_SIMULATOR */
[self exchangePacketAndReply];
#endif /* !TARGET_OS_SIMULATOR */
}

- (void)deviceUnlockTimedOut
{
dispatch_assert_queue(self.queue);

/* Should not happen; be safe. */
if (self.session == nil) {
return;
}

#if TARGET_OS_IOS
OTPairingPacketContext *packet = self.session.packet;
self.session.packet = nil;

os_assert(packet != nil); // the acceptor always responds to a request packet, it's never initiating

NSError *unlockError = [NSError errorWithDomain:OTPairingErrorDomain code:OTPairingErrorTypeLock description:@"timed out waiting for companion unlock"];
NSMutableDictionary *message = [[NSMutableDictionary alloc] init];
message[OTPairingIDSKeyMessageType] = @(OTPairingIDSMessageTypeError);
message[OTPairingIDSKeySession] = self.session.identifier;
message[OTPairingIDSKeyErrorDescription] = unlockError.description;
NSString *toID = packet.fromID;
NSString *responseIdentifier = packet.outgoingResponseIdentifier;
[self _sendMessage:message to:toID identifier:responseIdentifier];

[self scheduleGizmoPoke];
#endif /* TARGET_OS_IOS */

[self session:self.session didCompleteWithSuccess:false error:[NSError errorWithDomain:OTPairingErrorDomain code:OTPairingErrorTypeLock description:@"timed out waiting for unlock"]];
}

- (void)exchangePacketAndReply
{
dispatch_assert_queue(self.queue);

OTPairingPacketContext *packet = self.session.packet;
self.session.packet = nil;

[self.session.channel exchangePacket:packet.packetData complete:^(BOOL complete, NSData *responsePacket, NSError *channelError) {
/* this runs on a variety of different queues depending on the step (caller's queue, or an NSXPC queue) */

dispatch_async(self.queue, ^{
NSString *toID;
NSString *responseIdentifier;

os_log(OS_LOG_DEFAULT, "exchangePacket: complete=%d responsePacket=%@ channelError=%@", complete, responsePacket, channelError);

if (self.session == nil) {
os_log(OS_LOG_DEFAULT, "pairing session went away, dropping exchangePacket response");
return;
}

if (channelError != nil) {
#if TARGET_OS_IOS
NSMutableDictionary *message = [[NSMutableDictionary alloc] init];
message[OTPairingIDSKeyMessageType] = @(OTPairingIDSMessageTypeError);
message[OTPairingIDSKeySession] = self.session.identifier;
message[OTPairingIDSKeyErrorDescription] = channelError.description;
os_assert(packet != nil); // the acceptor always responds to a request packet, it's never initiating
toID = packet.fromID;
responseIdentifier = packet.outgoingResponseIdentifier;
[self _sendMessage:message to:toID identifier:responseIdentifier];
#endif

[self session:self.session didCompleteWithSuccess:false error:[NSError errorWithDomain:OTPairingErrorDomain code:OTPairingErrorTypeKCPairing description:@"exchangePacket" underlying:channelError]];

return;
}

if (responsePacket != nil) {
NSDictionary *message = @{
OTPairingIDSKeyMessageType : @(OTPairingIDSMessageTypePacket),
OTPairingIDSKeySession : self.session.identifier,
OTPairingIDSKeyPacket : responsePacket,
};
toID = packet ? packet.fromID : IDSDefaultPairedDevice;
responseIdentifier = packet ? packet.outgoingResponseIdentifier : nil;
[self _sendMessage:message to:toID identifier:responseIdentifier];
}

if (complete) {
[self session:self.session didCompleteWithSuccess:true error:nil];
}
});
}];
}

- (void)_sendMessage:(NSDictionary *)message to:(NSString *)toID identifier:(NSString *)responseIdentifier
{
[self _sendMessage:message to:toID identifier:responseIdentifier expectReply:YES];
}

- (void)_sendMessage:(NSDictionary *)message to:(NSString *)toID identifier:(NSString *)responseIdentifier expectReply:(BOOL)expectReply
{
dispatch_assert_queue(self.queue);

NSSet *destinations;
NSMutableDictionary *options;
NSString *identifier = nil;
NSError *error = nil;
BOOL sendResult = NO;

destinations = [NSSet setWithObject:toID];

options = [NSMutableDictionary new];
options[IDSSendMessageOptionForceLocalDeliveryKey] = @YES;
options[IDSSendMessageOptionExpectsPeerResponseKey] = @(expectReply); // TODO: when are we complete??
if (responseIdentifier != nil) {
options[IDSSendMessageOptionPeerResponseIdentifierKey] = responseIdentifier;
}

sendResult = [self.service sendMessage:message
toDestinations:destinations
priority:IDSMessagePriorityDefault
options:options
identifier:&identifier
error:&error];
if (sendResult) {
/* sentMessageIdentifier is used to validate the next reply; do not set if no reply is expected. */
if (expectReply) {
self.session.sentMessageIdentifier = identifier;
}
} else {
os_log(OS_LOG_DEFAULT, "send message failed (%@): %@", identifier, error);
// On iOS, do nothing; watch will time out waiting for response.
[self session:self.session didCompleteWithSuccess:false error:[NSError errorWithDomain:OTPairingErrorDomain code:OTPairingErrorTypeIDS description:@"IDS message send failure" underlying:error]];
}
}

#pragma mark IDSServiceDelegate methods

- (void)service:(IDSService *)service account:(__unused IDSAccount *)account incomingMessage:(NSDictionary *)message fromID:(NSString *)fromID context:(IDSMessageContext *)context
{
dispatch_assert_queue(self.queue);

OTPairingPacketContext *packet = nil;
bool validateIdentifier = true;

os_log(OS_LOG_DEFAULT, "IDS message from %@: %@", fromID, message);

packet = [[OTPairingPacketContext alloc] initWithMessage:message fromID:fromID context:context];

if (packet.messageType == OTPairingIDSMessageTypePoke) {
#if TARGET_OS_WATCH
// on self.queue now, but initiatePairingWithCompletion: must _not_ be on self.queue
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
os_log(OS_LOG_DEFAULT, "companion claims to be unlocked, retrying");
[self initiatePairingWithCompletion:^(bool success, NSError *error) {
if (success) {
os_log(OS_LOG_DEFAULT, "companion-unlocked retry succeeded");
} else {
os_log(OS_LOG_DEFAULT, "companion-unlocked retry failed: %@", error);
}
}];
});
#endif /* TARGET_OS_WATCH */
return;
}

/*
* Check for missing/invalid session identifier. Since the watch is the initiator,
* iOS responds to an invalid session identifier by resetting state and starting over.
*/
if (packet.sessionIdentifier == nil) {
os_log(OS_LOG_DEFAULT, "ignoring message with no session identifier (old build?)");
return;
} else if (![packet.sessionIdentifier isEqualToString:self.session.identifier]) {
#if TARGET_OS_WATCH
os_log(OS_LOG_DEFAULT, "unknown session identifier, dropping message");
return;
#elif TARGET_OS_IOS
os_log(OS_LOG_DEFAULT, "unknown session identifier %@, creating new session object", packet.sessionIdentifier);
self.session = [[OTPairingSession alloc] initWithDeviceInfo:self.deviceInfo identifier:packet.sessionIdentifier];
validateIdentifier = false;
#endif /* TARGET_OS_IOS */
}

if (validateIdentifier && ![self.session.sentMessageIdentifier isEqualToString:packet.incomingResponseIdentifier]) {
os_log(OS_LOG_DEFAULT, "ignoring message with unrecognized incomingResponseIdentifier");
return;
}

switch (packet.messageType) {
case OTPairingIDSMessageTypeError:
[self session:self.session didCompleteWithSuccess:false error:[NSError errorWithDomain:OTPairingErrorDomain code:OTPairingErrorTypeRemote description:@"companion error" underlying:packet.error]];
break;
case OTPairingIDSMessageTypePacket:
self.session.packet = packet;
[self sendReplyToPacket];
break;
case OTPairingIDSMessageTypePoke:
/*impossible*/
break;
}
}

- (void)service:(__unused IDSService *)service account:(__unused IDSAccount *)account identifier:(NSString *)identifier didSendWithSuccess:(BOOL)success error:(NSError *)error context:(__unused IDSMessageContext *)context
{
dispatch_assert_queue(self.queue);

/* Only accept callback if it is for the recently-sent message. */
if (![self.session.sentMessageIdentifier isEqualToString:identifier]) {
os_log(OS_LOG_DEFAULT, "ignoring didSendWithSuccess callback for unexpected identifier: %@", identifier);
return;
}

if (!success) {
os_log(OS_LOG_DEFAULT, "unsuccessfully sent message (%@): %@", identifier, error);
// On iOS, do nothing; watch will time out waiting for response.
[self session:self.session didCompleteWithSuccess:false error:[NSError errorWithDomain:OTPairingErrorDomain code:OTPairingErrorTypeIDS description:@"IDS message failed to send" underlying:error]];
}
}

#pragma mark lock state handling

#if !TARGET_OS_SIMULATOR
- (void)waitForDeviceUnlock
{
dispatch_assert_queue(self.queue);

static dispatch_once_t once;
static dispatch_source_t lockStateCoalescingSource;
uint32_t notify_status;
int token;

dispatch_once(&once, ^{
// Everything here is on one queue, so we sometimes get several notifications while busy doing something else.
lockStateCoalescingSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_OR, 0, 0, self.queue);
dispatch_source_set_event_handler(lockStateCoalescingSource, ^{
[self sendReplyToPacket];
});
dispatch_activate(lockStateCoalescingSource);
});

if (self.notifyToken == NOTIFY_TOKEN_INVALID) {
notify_status = notify_register_dispatch(kMobileKeyBagLockStatusNotificationID, &token, self.queue, ^(__unused int t) {
dispatch_source_merge_data(lockStateCoalescingSource, 1);
});
if (os_assumes_zero(notify_status) == NOTIFY_STATUS_OK) {
self.notifyToken = token;
}
}

self.unlockTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, self.queue);
dispatch_source_set_timer(self.unlockTimer, dispatch_time(DISPATCH_TIME_NOW, WAIT_FOR_UNLOCK_DURATION * NSEC_PER_SEC), DISPATCH_TIME_FOREVER, 0);
dispatch_source_set_event_handler(self.unlockTimer, ^{
[self stopWaitingForDeviceUnlock];
[self deviceUnlockTimedOut];
});
dispatch_activate(self.unlockTimer);

// double-check to prevent race condition, try again soon
if (MKBGetDeviceLockState(NULL) == kMobileKeyBagDeviceIsUnlocked) {
[self stopWaitingForDeviceUnlock];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 5ull * NSEC_PER_SEC), self.queue, ^{
[self sendReplyToPacket];
});
}
}

- (void)stopWaitingForDeviceUnlock
{
dispatch_assert_queue(self.queue);

uint32_t notify_status;

if (self.notifyToken != NOTIFY_TOKEN_INVALID) {
notify_status = notify_cancel(self.notifyToken);
os_assumes_zero(notify_status);
self.notifyToken = NOTIFY_TOKEN_INVALID;
}

if (self.unlockTimer != nil) {
dispatch_source_cancel(self.unlockTimer);
self.unlockTimer = nil;
}
}
#endif /* !TARGET_OS_SIMULATOR */

#if TARGET_OS_IOS
- (void)scheduleGizmoPoke
{
xpc_object_t criteria;

criteria = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_string(criteria, XPC_ACTIVITY_PRIORITY, XPC_ACTIVITY_PRIORITY_MAINTENANCE);
xpc_dictionary_set_int64(criteria, XPC_ACTIVITY_DELAY, 0ll);
xpc_dictionary_set_int64(criteria, XPC_ACTIVITY_GRACE_PERIOD, 0ll);
xpc_dictionary_set_bool(criteria, XPC_ACTIVITY_REPEATING, false);
xpc_dictionary_set_bool(criteria, XPC_ACTIVITY_ALLOW_BATTERY, true);
xpc_dictionary_set_bool(criteria, XPC_ACTIVITY_REQUIRES_CLASS_A, true);
xpc_dictionary_set_bool(criteria, XPC_ACTIVITY_COMMUNICATES_WITH_PAIRED_DEVICE, true);

os_log(OS_LOG_DEFAULT, "scheduling XPC Activity to inform gizmo of companion unlock");
xpc_activity_register(OTPairingXPCActivityPoke, criteria, ^(xpc_activity_t activity) {
xpc_activity_state_t state = xpc_activity_get_state(activity);
if (state == XPC_ACTIVITY_STATE_RUN) {
dispatch_sync(self.queue, ^{
os_log(OS_LOG_DEFAULT, "poking gizmo now");
NSDictionary *message = @{
OTPairingIDSKeyMessageType : @(OTPairingIDSMessageTypePoke),
};
[self _sendMessage:message to:IDSDefaultPairedDevice identifier:nil expectReply:NO];
});
}
});
}
#endif /* TARGET_OS_IOS */

#pragma mark Octagon Clique Status

#if TARGET_OS_WATCH
- (bool)_octagonInClique
{
__block bool result = false;
NSError *ctlError = nil;

OTControl *ctl = [OTControl controlObject:true error:&ctlError];
if (ctl != nil) {
OTOperationConfiguration *config = [OTOperationConfiguration new];
config.useCachedAccountStatus = true;
[ctl fetchCliqueStatus:nil context:OTDefaultContext configuration:config reply:^(CliqueStatus cliqueStatus, NSError * _Nullable error) {
result = (cliqueStatus == CliqueStatusIn);
}];
} else {
os_log(OS_LOG_DEFAULT, "failed to acquire OTControl: %@", ctlError);
}

return result;
}
#endif /* TARGET_OS_WATCH */

@end