LCCertificateChain.m

This file can be downloaded as part of milnlicence.tbz.

//
//  LCCertificateChain.m
//  LicenceCore - https://indie.miln.eu
//
//  Copyright © Graham Miln. All rights reserved. https://miln.eu
//
//  This package is subject to the terms of the Artistic License 2.0.
//  If a copy of the Artistic-2.0 was not distributed with this file, you can
//  obtain one at https://indie.miln.eu/licence

#import "LCCertificateChain.h"

static NSString* LCCertificateChainOIDCertificateAuthorityIssuers = @"1.3.6.1.5.5.7.48.2";

@interface LCCertificateChain ()
- (SecCertificateRef)createCertificateFrom:(SecExternalFormat)inFormat data:(NSData*)inData error:(NSError**)outError;
- (NSURL*)issuingCertificateURLForCertificate:(SecCertificateRef)inCertificate error:(NSError**)outError;
@end

@implementation LCCertificateChain

- (void)certificateChainFor:(NSData*)pemData withReply:(void (^)(NSData*, NSError*))reply {
	// Extract single certificate from caller's data
	NSError* pemError = nil;
	SecCertificateRef certificate = [self createCertificateFrom:kSecFormatPEMSequence data:pemData error:&pemError];
	if (pemError != nil || certificate == nil) {
		if (certificate != nil) {
			CFRelease(certificate);
		}
		reply(nil, pemError);
		return;
	}
	
	// Create a certificate chain; fetching from the network as needed
	NSError* chainError = nil;
	CFArrayRef chain = [self createChainForCertificate:certificate error:&chainError];
	if (chainError != nil || chain == nil) {
		if (certificate != nil) {
			CFRelease(certificate);
		}
		reply(nil, chainError);
		return;
	}
	
	// Release the original certificate
	if (certificate != nil) {
		CFRelease(certificate);
		certificate = nil;
	}
	
	// Encode the chain as an aggregate PEM block for the caller
	CFDataRef aggregatePEM = nil;
	OSStatus validExport = SecItemExport(chain, kSecFormatPEMSequence, kSecItemPemArmour, nil, &aggregatePEM);
	if (validExport != errSecSuccess) {
		reply(nil, [NSError errorWithDomain:NSOSStatusErrorDomain code:validExport userInfo:nil]);
		return;
	}
	
	// Pass back the encoded chain
	reply((__bridge NSData*)aggregatePEM, nil);
	
	if (aggregatePEM != nil) {
		CFRelease(aggregatePEM);
	}
}

- (CFArrayRef)createChainForCertificate:(SecCertificateRef)inCertificate error:(NSError**)outError {
	CFMutableArrayRef chain = CFArrayCreateMutable(kCFAllocatorDefault, 0, &kCFTypeArrayCallBacks);
	if (chain != NULL) {
		// Track fetched URLs to reduce loops
		NSMutableDictionary<NSURL*,NSNumber*>* seen = [NSMutableDictionary new];
		
		CFArrayAppendValue(chain, inCertificate);
		SecCertificateRef cert = (SecCertificateRef) CFRetain(inCertificate);
		
		NSInteger maxChainLength = 10;
		BOOL done = NO;
		while(!done && maxChainLength > 0) {
			maxChainLength--;
			
			// Extract issuing certificate's URL
			NSError* urlError = nil;
			NSURL* issuingURL = [self issuingCertificateURLForCertificate:cert error:&urlError];
			if (urlError != nil) {
				// Error while extracting issuing URL
				if (outError != nil) {
					*outError = urlError;
				}
				return nil;
			}
			
			if ((issuingURL != nil) && (seen[issuingURL] == nil)) {
				seen[issuingURL] = @(YES);
				
				// Fetch certificate, assumed to follow RFC and thus be in DER format
				__block NSError* urlError = nil;
				__block NSData* certDER = nil;
				// ...sync fetch as we are not on the main thread and constraining ourselves to the function's scope
				dispatch_semaphore_t wait = dispatch_semaphore_create(0);
				NSURLSessionDataTask* task = [NSURLSession.sharedSession dataTaskWithURL:issuingURL completionHandler:^(NSData* inData, NSURLResponse* inResponse, NSError* inError) {
					urlError = inError;
					if (inError == nil) {
						certDER = inData;
					}
					dispatch_semaphore_signal(wait);
				}];
				[task resume];
				dispatch_semaphore_wait(wait, DISPATCH_TIME_FOREVER);
			
				if (urlError != nil) {
					// Error while fetching issuing certificate
					if (outError != nil) {
						*outError = urlError;
					}
					return chain; // partial chain
				}
				
				if (certDER == nil || [certDER length] == 0) {
					// Error while fetching issuing certificate
					if (outError != nil) {
						*outError = [NSError errorWithDomain:NSOSStatusErrorDomain code:404 userInfo:nil];
					}
					return chain; // partial chain
				}
				
				NSError* derError = nil;
				SecCertificateRef issuingCert = [self createCertificateFrom:kSecFormatX509Cert data:certDER error:&derError];
				if (derError != nil) {
					// Error while extracting issuing URL
					if (outError != nil) {
						*outError = derError;
					}
					return chain; // partial chain
				}
				
				if (issuingCert != nil) {
					CFArrayAppendValue(chain, issuingCert);
				} else {
					// No issuing certificate
					done = YES;
				}
				
				if (cert != nil) {
					CFRelease(cert);
					cert = nil;
				}
				cert = issuingCert;
				
			} else {
				// No issuing URL or seen URL, assume full chain fetched
				done = YES;
			}
		}
		
		if (cert != nil) {
			CFRelease(cert);
			cert = nil;
		}
	}
	
	return chain;
}

#pragma mark -

- (SecCertificateRef)createCertificateFrom:(SecExternalFormat)inFormat data:(NSData*)inData error:(NSError**)outError {
	SecCertificateRef certificate = nil;
	SecExternalFormat inputFormat = inFormat;
	SecExternalItemType itemType = kSecItemTypeCertificate;
	CFArrayRef importedItems = nil;
	OSStatus validImport = SecItemImport((CFDataRef)inData,nil, &inputFormat, &itemType, 0, nil, nil, &importedItems);
	if (validImport != errSecSuccess) {
		// Import failed
		if (*outError != nil) {
			*outError = [NSError errorWithDomain:NSOSStatusErrorDomain code:validImport userInfo:nil];
		}
		return nil;
	} else {
		// Ensure single certificate
		CFIndex i = CFArrayGetCount(importedItems);
		if (i != 1) {
			if (*outError != nil) {
				*outError = [NSError errorWithDomain:NSOSStatusErrorDomain code:validImport userInfo:nil];
			}
			return nil;
		} else {
			// Extract and retain certificate
			CFTypeRef potentialCert = CFArrayGetValueAtIndex(importedItems, 0);
			if (potentialCert != nil && CFGetTypeID(potentialCert) == SecCertificateGetTypeID()) {
				certificate = (SecCertificateRef)CFRetain(potentialCert);
			}
		}
	}
	
	if (importedItems != NULL) {
		CFRelease(importedItems);
	}
	
	return certificate;
}

- (NSURL*)issuingCertificateURLForCertificate:(SecCertificateRef)inCertificate error:(NSError**)outError {
	NSParameterAssert(inCertificate != nil);
	NSParameterAssert(outError != nil);
	NSURL* issuingURL = nil;
	
	// See https://opensource.apple.com/source/libsecurity_keychain/libsecurity_keychain-55044/lib/CertificateValues.cpp for OID to constant mapping
	CFErrorRef cfErr = NULL;
	NSDictionary* properties = CFBridgingRelease(SecCertificateCopyValues(inCertificate, (CFArrayRef)@[(NSString*)kSecOIDAuthorityInfoAccess], &cfErr));
	if (cfErr != NULL) {
		*outError = (NSError*) CFBridgingRelease(cfErr);
		return nil;
	}
	
	NSDictionary* access = properties[(NSString*)kSecOIDAuthorityInfoAccess];
	NSArray<NSDictionary*>* values = access[(NSString*)kSecPropertyKeyValue];
	// Find the first URL entry after the issuing label entry
	__block NSUInteger labelIndex = NSNotFound;
	NSUInteger urlIndex = [values indexOfObjectPassingTest:^BOOL(NSDictionary* inValue,NSUInteger inIndex,BOOL* outShouldStop) {
		if ([(NSString*)inValue[(NSString*)kSecPropertyKeyType] isEqualToString:(NSString*)kSecPropertyTypeString]) {
			if ([(NSString*)inValue[(NSString*)kSecPropertyKeyValue] isEqualToString:LCCertificateChainOIDCertificateAuthorityIssuers]) {
				// Found "Certificate Authority Issuers label" OID
				labelIndex = inIndex;
			}
		} else if (labelIndex != NSNotFound) {
			// within Certificate Authority Issuers (CAI)
			if ([(NSString*)inValue[(NSString*)kSecPropertyKeyType] isEqualToString:(NSString*)kSecPropertyTypeURL]) {
				// this is a URL within CAI
				return YES;
			} else {
				// end of the CAI section
				labelIndex = NSNotFound;
				if (outShouldStop != nil) {
					*outShouldStop = YES;
				}
			}
		}
		return NO;
	}];
	if (urlIndex != NSNotFound) {
		issuingURL = values[urlIndex][(NSString*)kSecPropertyKeyValue];
	} else {
		*outError = [NSError errorWithDomain:NSOSStatusErrorDomain code:errSecMissingValue userInfo:nil];
	}
	
	return issuingURL;
}

@end