SecExperiment.m   [plain text]

//
// SecExperiment.m
// Security
//

#include <xpc/xpc.h>
#include <os/log.h>
#include <Security/SecTrustPriv.h>
#include <uuid/uuid.h>

#define OS_OBJECT_HAVE_OBJC_SUPPORT 1

#define SEC_EXP_NULL_BAD_INPUT ((void *_Nonnull)NULL)
#define SEC_EXP_NULL_OUT_OF_MEMORY SEC_EXP_NULL_BAD_INPUT

#define SEC_EXP_NIL_BAD_INPUT ((void *_Nonnull)nil)
#define SEC_EXP_NIL_OUT_OF_MEMORY SEC_EXP_NIL_BAD_INPUT

#define SEC_EXP_CONCRETE_CLASS_NAME(external_type) SecExpConcrete_##external_type
#define SEC_EXP_CONCRETE_PREFIX_STR "SecExpConcrete_"

#define SEC_EXP_OBJECT_DECL_INTERNAL_OBJC(external_type) \
@class SEC_EXP_CONCRETE_CLASS_NAME(external_type); \
typedef SEC_EXP_CONCRETE_CLASS_NAME(external_type) *external_type##_t

#define SEC_EXP_OBJECT_IMPL_INTERNAL_OBJC_WITH_PROTOCOL_AND_VISBILITY(external_type, _protocol, visibility, ...) \
@protocol OS_OBJECT_CLASS(external_type) <_protocol> \
@end \
visibility \
@interface SEC_EXP_CONCRETE_CLASS_NAME(external_type) : NSObject<OS_OBJECT_CLASS(external_type)> \
_Pragma("clang diagnostic push") \
_Pragma("clang diagnostic ignored \"-Wobjc-interface-ivars\"") \
__VA_ARGS__ \
_Pragma("clang diagnostic pop") \
@end \
typedef int _useless_typedef_oio_##external_type

#define SEC_EXP_OBJECT_IMPL_INTERNAL_OBJC_WITH_PROTOCOL(external_type, _protocol, ...) \
SEC_EXP_OBJECT_IMPL_INTERNAL_OBJC_WITH_PROTOCOL_AND_VISBILITY(external_type, _protocol, ,__VA_ARGS__)

#define SEC_EXP_OBJECT_IMPL_INTERNAL_OBJC(external_type, ...) \
SEC_EXP_OBJECT_IMPL_INTERNAL_OBJC_WITH_PROTOCOL(external_type, NSObject, ##__VA_ARGS__)

#define SEC_EXP_OBJECT_IMPL_INTERNAL_OBJC_WITH_VISIBILITY(external_type, visibility, ...) \
SEC_EXP_OBJECT_IMPL_INTERNAL_OBJC_WITH_PROTOCOL_AND_VISBILITY(external_type, NSObject, visibility, ##__VA_ARGS__)

SEC_EXP_OBJECT_DECL_INTERNAL_OBJC(sec_experiment);

#define SEC_EXP_OBJECT_IMPL 1
#import "SecExperimentPriv.h"
#import "SecExperimentInternal.h"
#import "SecCFRelease.h"
#import <Foundation/Foundation.h>
#import <CoreFoundation/CFXPCBridge.h>
#import <System/sys/codesign.h>
#import <sys/errno.h>

#define SEC_EXPERIMENT_SAMPLING_RATE 100.0
#define HASH_INITIAL_VALUE 0
#define HASH_MULTIPLIER 31

const char *kSecExperimentDefaultsDomain = "com.apple.security.experiment";
const char *kSecExperimentDefaultsDisableSampling = "disableSampling";
const char *kSecExperimentTLSProbe = "TLSProbeExperiment";

const NSString *SecExperimentConfigurationKeyFleetSampleRate = @"FleetSampleRate";
const NSString *SecExperimentConfigurationKeyDeviceSampleRate = @"DeviceSampleRate";
const NSString *SecExperimentConfigurationKeyExperimentIdentifier = @"ExpName";
const NSString *SecExperimentConfigurationKeyConfigurationData = @"ConfigData";

static os_log_t
sec_experiment_copy_log_handle(void)
{
static dispatch_once_t onceToken = 0;
static os_log_t experiment_log = nil;
dispatch_once(&onceToken, ^{
experiment_log = os_log_create("com.apple.security", "experiment");
});
return experiment_log;
}

#define sec_experiment_log_info(fmt, ...) \
do { \
os_log_t _log_handle = sec_experiment_copy_log_handle(); \
if (_log_handle) { \
os_log_info(_log_handle, fmt, ##__VA_ARGS__); \
} \
} while (0);

#define sec_experiment_log_debug(fmt, ...) \
do { \
os_log_t _log_handle = sec_experiment_copy_log_handle(); \
if (_log_handle) { \
os_log_debug(_log_handle, fmt, ##__VA_ARGS__); \
} \
} while (0);

#define sec_experiment_log_error(fmt, ...) \
do { \
os_log_t _log_handle = sec_experiment_copy_log_handle(); \
if (_log_handle) { \
os_log_error(_log_handle, fmt, ##__VA_ARGS__); \
} \
} while (0);

// Computes hash of input and returns a value between 1-100
static uint32_t
sec_experiment_hash_multiplicative(const uint8_t *key, size_t len)
{
if (!key) {
return 0;
}

uint32_t hash = HASH_INITIAL_VALUE;
for (uint32_t i = 0; i < len; ++i) {
hash = HASH_MULTIPLIER * hash + key[i];
}

return hash % 101; // value between 0-100
}

static uint32_t
sec_experiment_host_hash(void)
{
static uuid_string_t hostuuid = {};
static uint32_t hash = 0;
static dispatch_once_t onceToken = 0;
dispatch_once(&onceToken, ^{
struct timespec timeout = {0, 0};
uuid_t uuid = {};
if (gethostuuid(uuid, &timeout) == 0) {
uuid_unparse(uuid, hostuuid);
hash = sec_experiment_hash_multiplicative((const uint8_t *)hostuuid, strlen(hostuuid));
} else {
onceToken = 0;
}
});
return hash;
}

SEC_EXP_OBJECT_IMPL_INTERNAL_OBJC(sec_experiment,
{
@public
SecExperiment *innerExperiment;
size_t numRuns;
size_t successRuns;
});

@implementation SEC_EXP_CONCRETE_CLASS_NAME(sec_experiment)

- (instancetype)initWithName:(const char *)name
{
if (name == NULL) {
return SEC_EXP_NIL_BAD_INPUT;
}

if ((self = [super init])) {
self->innerExperiment = [[SecExperiment alloc] initWithName:name];
} else {
return SEC_EXP_NIL_OUT_OF_MEMORY;
}
return self;
}

- (instancetype)initWithInnerExperiment:(SecExperiment *)experiment
{
if (experiment == NULL) {
return SEC_EXP_NIL_BAD_INPUT;
}

if ((self = [super init])) {
self->innerExperiment = experiment;
} else {
return SEC_EXP_NIL_OUT_OF_MEMORY;
}
return self;
}

- (const char *)name
{
return [innerExperiment.name UTF8String];
}

- (const char *)identifier
{
return [innerExperiment.identifier UTF8String];
}

- (BOOL)experimentIsAllowedForProcess
{
return [innerExperiment experimentIsAllowedForProcess];
}

- (BOOL)isSamplingDisabledWithDefault:(BOOL)defaultValue
{
return [innerExperiment isSamplingDisabledWithDefault:defaultValue];
}

- (BOOL)isSamplingDisabled
{
return [innerExperiment isSamplingDisabled];
}

- (SecExperimentConfig *)copyExperimentConfiguration
{
return [innerExperiment copyExperimentConfiguration];
}

@end

@interface SecExperiment()
@property NSString *name;
@property (nonatomic) BOOL samplingDisabled;
@property SecExperimentConfig *cachedConfig;
@end

@implementation SecExperiment

- (instancetype)initWithName:(const char *)name
{
if (name == NULL) {
return SEC_EXP_NIL_BAD_INPUT;
}

if ((self = [super init])) {
self.name = [NSString stringWithUTF8String:name];
} else {
return SEC_EXP_NIL_OUT_OF_MEMORY;
}
return self;
}

- (BOOL)experimentIsAllowedForProcess
{
__block NSArray<NSString *> *whitelistedProcesses = @[
@"nsurlsessiond",
@"com.apple.WebKit.Networking",
@"experimentTool",
@"network_test",
];

static BOOL isAllowed = NO;
static dispatch_once_t onceToken = 0;
dispatch_once(&onceToken, ^{
uint32_t flags = 0;
int ret = csops(getpid(), CS_OPS_STATUS, &flags, sizeof(flags));
if (ret) {
// Fail closed if we're not able to determine the type of binary.
return;
}

if (!(flags & CS_PLATFORM_BINARY)) {
// Allow SecExperiment on all non-platform binaries, e.g., third party apps.
isAllowed = YES;
return;
}

// Otherwise, this is a platform binary. Check against the set of whitelisted processes.
NSString *process = [NSString stringWithFormat:@"%s", getprogname()];
[whitelistedProcesses enumerateObjectsUsingBlock:^(NSString * _Nonnull whitelistedProcess, NSUInteger idx, BOOL * _Nonnull stop) {
if ([whitelistedProcess isEqualToString:process]) {
isAllowed = YES;
*stop = YES; // Stop searching the whitelist
}
}];
});

return isAllowed;
}

- (BOOL)isSamplingDisabledWithDefault:(BOOL)defaultValue
{
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
if (defaults != nil) {
NSMutableDictionary *experimentDefaults = [[defaults persistentDomainForName:[NSString stringWithUTF8String:kSecExperimentDefaultsDomain]] mutableCopy];
if (experimentDefaults != nil) {
NSString *key = [NSString stringWithUTF8String:kSecExperimentDefaultsDisableSampling];
if (experimentDefaults[key] != nil) {
return [experimentDefaults[key] boolValue];
}
}
}

return defaultValue;
}

- (BOOL)isSamplingDisabled
{
return [self isSamplingDisabledWithDefault:self.samplingDisabled];
}

- (NSDictionary *)copyExperimentConfigurationFromUserDefaults
{
NSDictionary *result = nil;

NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
if (defaults != nil) {
NSMutableDictionary *experimentDefaults = [[defaults persistentDomainForName:[NSString stringWithUTF8String:kSecExperimentDefaultsDomain]] mutableCopy];
if (experimentDefaults != nil) {
NSString *key = self.name;
if (experimentDefaults[key] != nil) {
result = experimentDefaults[key];
}
}
}

return result;
}

- (NSDictionary *)copyRemoteExperimentAsset
{
CFErrorRef error = NULL;
NSDictionary *config = NULL;
NSDictionary *asset = CFBridgingRelease(SecTrustOTASecExperimentCopyAsset(&error));
if (asset) {
config = [asset valueForKey:self.name];
}
CFReleaseNull(error);
return config;
}

- (NSDictionary *)copyRandomExperimentConfigurationFromAsset:(NSDictionary *)asset
{
NSArray *array = [asset valueForKey:@"ConfigArray"];
if (array == nil) {
return nil;
}
return [array objectAtIndex:(arc4random() % [array count])];
}

- (SecExperimentConfig *)copyExperimentConfiguration
{
if (self.cachedConfig) {
// If we've fetched an experiment config already, use it for the duration of this object's lifetime.
return self.cachedConfig;
}

NSDictionary *defaultsDictionary = [self copyExperimentConfigurationFromUserDefaults];
if (defaultsDictionary != nil) {
self.cachedConfig = [[SecExperimentConfig alloc] initWithConfiguration:defaultsDictionary];
return self.cachedConfig;
}

NSDictionary *remoteAsset = [self copyRemoteExperimentAsset];
if (remoteAsset != nil) {
NSDictionary *randomConfig = [self copyRandomExperimentConfigurationFromAsset:remoteAsset];
self.cachedConfig = [[SecExperimentConfig alloc] initWithConfiguration:randomConfig];
return self.cachedConfig;
}

return nil;
}

- (NSString *)identifier
{
if (self.cachedConfig != nil) {
return [self.cachedConfig identifier];
} else {
return nil;
}
}

@end

@interface SecExperimentConfig()
@property NSString *identifier;
@property NSDictionary *config;
@property uint32_t fleetSampleRate;
@property uint32_t deviceSampleRate;
@property NSDictionary *configurationData;
@end

@implementation SecExperimentConfig

- (instancetype)initWithConfiguration:(NSDictionary *)configuration
{
if (configuration == nil) {
return SEC_EXP_NIL_BAD_INPUT;
}

if ((self = [super init])) {
// Parse out experiment information from the configuration dictionary
self.config = configuration;
self.identifier = [configuration objectForKey:SecExperimentConfigurationKeyExperimentIdentifier];

NSNumber *deviceSampleRate = [configuration objectForKey:SecExperimentConfigurationKeyDeviceSampleRate];
if (deviceSampleRate != nil) {
self.deviceSampleRate = [deviceSampleRate unsignedIntValue];
}

NSNumber *fleetSampleRate = [configuration objectForKey:SecExperimentConfigurationKeyFleetSampleRate];
if (fleetSampleRate != nil) {
self.fleetSampleRate = [fleetSampleRate unsignedIntValue];
}

self.configurationData = [configuration objectForKey:SecExperimentConfigurationKeyConfigurationData];
} else {
return SEC_EXP_NIL_OUT_OF_MEMORY;
}

return self;
}

- (uint32_t)hostHash
{
return sec_experiment_host_hash();
}

- (BOOL)shouldRunWithSamplingRate:(NSNumber *)sampleRate
{
if (!sampleRate) {
return NO;
}

uint32_t sample = arc4random();
return ((float)sample < ((float)UINT32_MAX / [sampleRate unsignedIntegerValue]));
}

- (BOOL)isSampled
{
uint32_t hostIdHash = [self hostHash];
if ((hostIdHash == 0) || (self.fleetSampleRate < hostIdHash)) {
return NO;
}

return [self shouldRunWithSamplingRate:@(self.deviceSampleRate)];
}

@end

sec_experiment_t
sec_experiment_create(const char *name)
{
return [[SEC_EXP_CONCRETE_CLASS_NAME(sec_experiment) alloc] initWithName:name];
}

sec_experiment_t
sec_experiment_create_with_inner_experiment(SecExperiment *experiment)
{
return [[SEC_EXP_CONCRETE_CLASS_NAME(sec_experiment) alloc] initWithInnerExperiment:experiment];
}

void
sec_experiment_set_sampling_disabled(sec_experiment_t experiment, bool sampling_disabled)
{
experiment->innerExperiment.samplingDisabled = sampling_disabled;
}

const char *
sec_experiment_get_identifier(sec_experiment_t experiment)
{
return [experiment identifier];
}

xpc_object_t
sec_experiment_copy_configuration(sec_experiment_t experiment)
{
if (experiment == nil) {
return nil;
}

// Check first for defaults configured
SecExperimentConfig *experimentConfiguration = [experiment copyExperimentConfiguration];
if (experimentConfiguration != nil) {
NSDictionary *configurationData = [experimentConfiguration configurationData];
if (![experiment isSamplingDisabled]) {
if ([experimentConfiguration isSampled]) {
return _CFXPCCreateXPCObjectFromCFObject((__bridge CFDictionaryRef)configurationData);
} else {
sec_experiment_log_info("Configuration '%{public}s' for experiment '%{public}s' not sampled to run",
[experiment name], [[experimentConfiguration identifier] UTF8String]);
return nil;
}
} else {
return _CFXPCCreateXPCObjectFromCFObject((__bridge CFDictionaryRef)configurationData);
}
}

return nil;
}

bool
sec_experiment_run_internal(sec_experiment_t experiment, bool sampling_disabled, dispatch_queue_t queue, sec_experiment_run_block_t run_block, sec_experiment_skip_block_t skip_block, bool synchronous)
{
if (experiment == NULL || run_block == nil) {
return false;
}

if (![experiment experimentIsAllowedForProcess]) {
sec_experiment_log_info("Not running experiments for disallowed process");
return false;
}

dispatch_block_t experiment_block = ^{
bool experiment_sampling_disabled = [experiment isSamplingDisabledWithDefault:sampling_disabled];
sec_experiment_set_sampling_disabled(experiment, [experiment isSamplingDisabledWithDefault:sampling_disabled]);
xpc_object_t config = sec_experiment_copy_configuration(experiment);
const char *identifier = sec_experiment_get_identifier(experiment);
if (config != nil) {
experiment->numRuns++;
if (run_block(identifier, config)) {
experiment->successRuns++;
sec_experiment_log_info("Configuration '%s' for experiment '%s' succeeded", identifier, [experiment name]);
} else {
sec_experiment_log_info("Configuration '%s' for experiment '%s' failed", identifier, [experiment name]);
}
} else {
sec_experiment_log_info("Configuration '%s' for experiment '%s' not configured to run with sampling %s", identifier,
[experiment name], experiment_sampling_disabled ? "disabled" : "enabled");
if (skip_block) {
skip_block(sec_experiment_get_identifier(experiment));
}
}
};

if (synchronous || !queue) {
sec_experiment_log_info("Starting experiment '%s' synchronously with sampling %s", [experiment name], sampling_disabled ? "disabled" : "enabled");
experiment_block();
} else {
sec_experiment_log_info("Starting experiment '%s' asynchronously with sampling %s", [experiment name], sampling_disabled ? "disabled" : "enabled");
dispatch_async(queue, experiment_block);
}

return true;
}

bool
sec_experiment_run(const char *experiment_name, sec_experiment_run_block_t run_block, sec_experiment_skip_block_t skip_block)
{
// Sampling is always enabled for SecExperiment callers. Appliations may override this by setting the
// `disableSampling` key in the `com.apple.security.experiment` defaults domain.
sec_experiment_t experiment = sec_experiment_create(experiment_name);
if (experiment) {
return sec_experiment_run_internal(experiment, false, NULL, run_block, skip_block, true);
} else {
sec_experiment_log_info("Experiment '%s' not found", experiment_name);
return false;
}
}

bool
sec_experiment_run_async(const char *experiment_name, dispatch_queue_t queue, sec_experiment_run_block_t run_block, sec_experiment_skip_block_t skip_block)
{
sec_experiment_t experiment = sec_experiment_create(experiment_name);
if (experiment) {
return sec_experiment_run_internal(experiment, false, queue, run_block, skip_block, false);
} else {
sec_experiment_log_info("Experiment '%s' not found", experiment_name);
return false;
}
}

bool
sec_experiment_run_with_sampling_disabled(const char *experiment_name, sec_experiment_run_block_t run_block, sec_experiment_skip_block_t skip_block, bool sampling_disabled)
{
sec_experiment_t experiment = sec_experiment_create(experiment_name);
if (experiment) {
return sec_experiment_run_internal(experiment, sampling_disabled, NULL, run_block, skip_block, true);
} else {
sec_experiment_log_info("Experiment '%s' not found", experiment_name);
return false;
}
}

bool
sec_experiment_run_async_with_sampling_disabled(const char *experiment_name, dispatch_queue_t queue, sec_experiment_run_block_t run_block, sec_experiment_skip_block_t skip_block, bool sampling_disabled)
{
sec_experiment_t experiment = sec_experiment_create(experiment_name);
if (experiment) {
return sec_experiment_run_internal(experiment, sampling_disabled, queue, run_block, skip_block, false);
} else {
sec_experiment_log_info("Experiment '%s' not found", experiment_name);
return false;
}
}

size_t
sec_experiment_get_run_count(sec_experiment_t experiment)
{
return experiment->numRuns;
}

size_t
sec_experiment_get_successful_run_count(sec_experiment_t experiment)
{
return experiment->successRuns;
}