Reverse Engineering Apple's DeviceCheck Token Generation
This document presents an end-to-end reverse engineering of Apple's DeviceCheck token generation flow on iOS, starting from the public DCDevice API and following execution through devicecheckd, DeviceCheckInternal.framework, and the underlying cryptographic primitives.
The goal is to provide a reproducible, engineering-grade description useful for security engineering, red teaming, and low-level reverse engineering work.
Chapter 1 — DeviceCheck.framework entry point
When creating a token from DCDevice, the method -[DCDevice generateTokenWithCompletionHandler:] is used — inside it calls DCDeviceMetadataDaemonConnection. This method creates a connection to the iPhone's devicecheckd daemon:
NSXPCConnection *xpc_connection = (NSXPCConnection *)objc_msgSend(
objc_alloc((Class)&OBJC_CLASS___NSXPCConnection),
"initWithMachServiceName:options:",
CFSTR("com.apple.devicecheckd"),
0LL);
devicecheckd
After connecting to the devicecheckd daemon via XPC, the following chain of calls starts. When a connection is received, the daemon invokes -[DCXPCListener listener:shouldAcceptNewConnection:] → -[DCClientHandler initWithConnection:], and after the client makes an RPC call → -[DCClientHandler fetchOpaqueBlobWithCompletion].
First of all, -[DCClientHandler fetchOpaqueBlobWithCompletion] calls:
if ( -[DCClientHandler _isSupported](self, "_isSupported") )
The value of this variable is hard-coded in DeviceIdentityIsSupported from DeviceIdentity.framework:
__int64 DeviceIdentityIsSupported_1()
{
return 1LL;
}
I assume that if DCDevice is unavailable on the device, then there will be a different build of the framework where it is hard-coded to 0.
After checking support, it calls -[DCClientHandler _generateAppIDFromCurrentConnection] — this method obtains <TeamID>.<BundleIdentifier> (format: ABCDE12345.com.example.myApp) using entitlements (-[NSXPCConnection valueForEntitlement:]), and if that fails, it uses SecTaskCopyTeamIdentifier and SecTaskCopySigningIdentifier.
If team_id is valid and not "0000000000", the method combines the team_id and bundle_id with a dot; otherwise it uses only the bundle_id. It returns an appID:
return [appID length] ? appID : nil;
Returning to -[DCClientHandler fetchOpaqueBlobWithCompletion]:
if ( app_id )
{
DCContext_class = objc_alloc_init((Class)&OBJC_CLASS___DCContext);
objc_msgSend(DCContext_class, "setClientAppID:", app_id);
// allocate DCDDeviceMetadata and initialize DCCryptoProxyImpl
DCDDeviceMetadata = objc_alloc((Class)&OBJC_CLASS___DCDDeviceMetadata);
DCCryptoProxyImpl = objc_alloc_init((Class)&OBJC_CLASS___DCCryptoProxyImpl);
init_DCDDeviceMetadata = objc_msgSend(
DCDDeviceMetadata,
"initWithContext:cryptoProxy:",
DCContext_class,
DCCryptoProxyImpl);
objc_msgSend(init_DCDDeviceMetadata, "generateEncryptedBlobWithCompletion:", v4);
}
In summary, the devicecheckd daemon returns an encrypted token (opaque blob) to DeviceCheck.framework via XPC in the completion handler passed to -[DCDDeviceMetadata generateEncryptedBlobWithCompletion:]. This blob is later exposed to callers as the token returned by -[DCDevice generateTokenWithCompletionHandler:].
DCContext, DCDDeviceMetadata, and DCCryptoProxyImpl
The behavior described below is implemented in DeviceCheckInternal.framework.
The first thing that happens in -[DCClientHandler fetchOpaqueBlobWithCompletion] is the initialization of DCContext and assignment of our <TeamID>.<BundleIdentifier> to it:
DCContext_class = objc_alloc_init((Class)&OBJC_CLASS___DCContext);
objc_msgSend(DCContext_class, "setClientAppID:", app_id);
Since DCContext has no -init method of its own, it simply inherits the implementation from NSObject. The method -[DCContext setClientAppID:] sets self->_clientAppID:
id __cdecl __noreturn -[DCContext clientAppID](DCContext *self, SEL a2)
{
return objc_getProperty_33(self, a2, 8LL, 1);
}
Now our DCDDeviceMetadata is initialized:
init_DCDDeviceMetadata = objc_msgSend(
DCDDeviceMetadata,
"initWithContext:cryptoProxy:",
DCContext_class,
DCCryptoProxyImpl_class_arg);
What happens during the initialization of DCDDeviceMetadata:
// instance memory layout
struct DCDDeviceMetadata // sizeof=0x18
{
unsigned __int8 superclass_opaque[8];
DCCryptoProxy *_cryptoProxy;
DCContext *_context;
};
id -[DCDDeviceMetadata initWithContext:cryptoProxy:](DCDDeviceMetadata *self, SEL a2,
id DCContext_class_arg, id DCCryptoProxyImpl_class_arg)
{
DCDDeviceMetadata *dc_device_metadata = -[DCDDeviceMetadata init](self, "init");
if ( dc_device_metadata )
{
j__objc_storeStrong((id *)&dc_device_metadata->_cryptoProxy, DCCryptoProxyImpl_class_arg);
j__objc_storeStrong((id *)&dc_device_metadata->_context, DCContext_class_arg);
}
return (id *)dc_device_metadata;
}
All classes have been initialized and now the devicecheckd daemon calls generateEncryptedBlobWithCompletion: — a method that will begin generating the token.
Chapter 2 — DeviceCheckInternal.framework
Almost all methods are rewritten by me to make them easier to read and understand.
Our daemon invoked the method generateEncryptedBlobWithCompletion: — this is the start of token creation.
void __cdecl -[DCDDeviceMetadata generateEncryptedBlobWithCompletion:](
DCDDeviceMetadata *self, SEL a2, id completion_arg)
{
id v4 = objc_retain(completion_arg);
DCCryptoProxy *cryptoProxy = self->_cryptoProxy;
DCContext *context = self->_context;
[cryptoProxy fetchOpaqueBlobWithContext:context
completion:^(NSData *blob, NSError *error) {
// if data is non-zero, call completion(data, nil)
// if data is zero, create NSError with code 0 and call completion(nil, error)
}];
}
At this point, -[DCCryptoProxy fetchOpaqueBlobWithContext:completion:] is called with our context and the completion handler:
void __cdecl -[DCCryptoProxyImpl fetchOpaqueBlobWithContext:completion:](
DCCryptoProxyImpl *self, SEL a2,
id DCContext_arg, id completion_arg)
{
id retainedContext = [DCContext_arg retain];
void (^copiedCompletion)(NSData *, NSError *) = [completion_arg copy];
if (os_log_type_enabled(self.logger, OS_LOG_TYPE_DEFAULT))
{
os_log(self.logger, "Generating certificate...");
}
__block id blockContext = retainedContext;
__block void (^blockCompletion)(NSData *, NSError *) = copiedCompletion;
[self _fetchPublicKey:^(NSData *publicKey) {
DCCertificateGenerator *generator = [[DCCertificateGenerator alloc]
initWithContext:blockContext
publicKey:publicKey];
[generator generateEncryptedCertificateChainWithCompletion:
^(NSData *encryptedChain, NSError *error) {
blockCompletion(encryptedChain, error);
}
];
}
];
}
Getting the publicKey
First, the publicKey is obtained, and then it is passed on:
void __cdecl -[DCCryptoProxyImpl _fetchPublicKey:](DCCryptoProxyImpl *self, SEL a2, id completion)
{
DCAssetFetcher *fetcher = [DCAssetFetcher sharedFetcher];
[fetcher fetchPublicKeyAssetWithCompletion:^(NSData *publicKey) {
completion(publicKey);
}];
}
This method calls -[DCAssetFetcher fetchPublicKeyAssetWithCompletion:]:
void __cdecl -[DCAssetFetcher fetchPublicKeyAssetWithCompletion:](
DCAssetFetcher *self, SEL a2, id publicKeyCompletion)
{
DCAssetFetcherContext *context = [[DCAssetFetcherContext alloc] init];
void (^completionBlock)(NSData *) = [publicKeyCompletion retain];
[context setAllowCatalogRefresh:NO];
[self _fetchAssetWithContext:context
completionHandler:completionBlock];
}
We continue following the call chain into _queryMetadataWithContext::
void __cdecl -[DCAssetFetcher _queryMetadataWithContext:completion:](
DCAssetFetcher *self, SEL a2,
DCAssetFetcherContext *context, id completion)
{
id assetQuery = [[self _assetQuery] retain];
NSUInteger resultCode = [assetQuery queryMetaDataSync];
if ([retainedContext ignoreCachedMetadata] || resultCode == 2)
{
[self _handleMissingMetadataWithContext:retainedContext
completion:completionBlock];
} else {
if (resultCode != 0)
{
NSError *error = [NSError errorWithDomain:@"com.apple.twobit.fetcherror"
code:0xFFFF_FFFF_FFFF_F448
userInfo:nil];
completionBlock(nil, error);
return;
}
[self _handleSuccessForQuery:assetQuery
completion:completionBlock];
}
}
The asset validation chain resolves through _handleSuccessForQuery: → _validateAsset: → +[DCAsset assetWithMobileAsset:], which extracts the public key from com.apple.devicecheck.pubvalue:
+ (DCAsset *)assetWithMobileAsset:(NSDictionary *)mobileAsset {
NSNumber *version = mobileAsset[@"com.apple.MobileAsset.AssetVersion"];
if (![version isKindOfClass:[NSNumber class]] || version.integerValue != 1) {
return nil;
}
NSData *pubKeyData = mobileAsset[@"com.apple.devicecheck.pubvalue"];
if (![pubKeyData isKindOfClass:[NSData class]] || pubKeyData.length == 0) {
return nil;
}
DCAsset *asset = [[DCAsset alloc] init];
asset.version = 1;
asset.publicKey = pubKeyData;
NSNumber *refreshInterval = mobileAsset[@"com.apple.devicecheck.refreshtimer"];
if ([refreshInterval isKindOfClass:[NSNumber class]]) {
asset.publicKeyRefreshInterval = refreshInterval.doubleValue;
}
return asset;
}
Interestingly, none of this metadata fetching is required for the key material itself: the effective public key is embedded in __37__DCCryptoProxyImpl__fetchPublicKey___block_invoke:
+[NSData dataWithBytes:length:](&OBJC_CLASS___NSData, "dataWithBytes:length:",
&fallback_server_pubkey, 65LL);
It is hard-coded and the same in all versions of macOS, iOS, and iPadOS:
0450d934fa67bcf6f2dfbf96629e0a7238e9205d75f28cfcd84f35a6
592bbe058a9c0f8edbca2acb67efb774971ca45f7d856a694fb1b9c40
b94fb2e7a5a9498b0
This is 130 hex characters — the key itself is 65 bytes. This value is the public key used by DeviceCheck.
Chapter 3 — Token encryption
Almost all methods are rewritten by me to make them easier to read and understand.
Returning to fetchOpaqueBlobWithContext: — after the public key is fetched, a DCCertificateGenerator is initialized:
struct DCCertificateGenerator // sizeof=0x18
{
unsigned __int8 superclass_opaque[8];
NSData *_publicKey;
DCContext *_context;
};
id -[DCCertificateGenerator initWithContext:publicKey:](
DCCertificateGenerator *self, SEL a2, id context_arg, id publicKey_arg)
{
DCCertificateGenerator *v9 = -[DCCertificateGenerator init](self, "init");
if ( v9 )
{
j__objc_storeStrong((id *)&v9->_publicKey, publicKey_arg);
j__objc_storeStrong((id *)&v9->_context, context_arg);
}
return (id *)v9;
}
After successful initialization, generateEncryptedCertificateChainWithCompletion is called. Conceptually, _generateCertificateChainWithCompletion retrieves a certificate chain from the keychain and passes it into _encryptData, where the final token is constructed.
Two root certificates are fetched from the keychain in __DeviceIdentityIssueClientCertificateWithCompletion_block_invoke; representative examples:
The core of the token logic lives in _encryptData, which builds and encrypts the final payload:
- (NSData *)_encryptData:(NSData *)data // certificates
serverSyncedDate:(NSDate *)serverDate
error:(NSError **)error {
// Get client App ID as UTF-8 data
NSString *clientAppID = [self.context clientAppID];
NSData *clientAppData = [clientAppID dataUsingEncoding:NSUTF8StringEncoding];
// Current timestamp
NSTimeInterval timestamp = [serverDate timeIntervalSince1970];
// AES-GCM mode from CommonCrypto
const struct ccmode_gcm *gcm = ccaes_gcm_encrypt_mode();
// Allocate buffers
size_t payloadLen = inputLen + clientAppLen + 81;
size_t outputLen = inputLen + clientAppLen + 235;
uint8_t *outBuf = calloc(1, outputLen);
uint8_t *payloadBuf = calloc(1, payloadLen);
// Write header (type = 2) and payload length
*((uint32_t *)outBuf) = 2;
*((uint32_t *)(outBuf + 150)) = (uint32_t)payloadLen;
// Copy device's static public key into outBuf at offset 5
memcpy(outBuf + 5, devicePubKeyData.bytes, devicePubKeyData.length);
// Assemble payload:
// [timestamp(8B), inputLen(4B), clientAppLen(4B), inputBytes, clientAppBytes]
*((uint64_t *)(payloadBuf + 65)) = (uint64_t)timestamp;
*((uint32_t *)(payloadBuf + 73)) = (uint32_t)inputLen;
*((uint32_t *)(payloadBuf + 77)) = (uint32_t)clientAppLen;
memcpy(payloadBuf + 81, inputBytes, inputLen);
memcpy(payloadBuf + 81 + inputLen, clientAppBytes, clientAppLen);
// Create ephemeral ECDH key via keybag
id keybag = [self keybagHandle];
uint64_t refKey = 0;
int aksErr = aks_ref_key_create((__int64)keybag, 11, 4, 0, 0, &refKey);
// Get ephemeral public key (65 bytes)
size_t ecdhPubLen = 0;
const uint8_t *ecdhPub = aks_ref_key_get_public_key(refKey, &ecdhPubLen);
memcpy(outBuf + 85, ecdhPub, ecdhPubLen);
// ECDH shared secret with device's static public key
aks_ref_key_compute_key(refKey, 0, 0, devicePubBytes, devicePubLen);
// Derive key material: HKDF-SHA256 -> 44 bytes (32B key + 12B IV)
uint8_t hkdfOut[44];
cchkdf(ccsha256_di(), sharedLen, sharedSecret, 0, NULL, 0x2C, hkdfOut);
uint8_t *aesKey = hkdfOut;
uint8_t *aesIV = hkdfOut + 32;
// Encrypt with AES-GCM
ccgcm_one_shot(gcm,
32, aesKey,
12, aesIV,
0, NULL,
payloadLen, payloadBuf,
outBuf + 154, // ciphertext
16, outBuf + 1); // GCM tag
NSData *encryptedData = [NSData dataWithBytes:outBuf length:outputLen];
free(outBuf);
free(payloadBuf);
return encryptedData;
}
Chapter 4 — Conclusion and security implications
This write-up traces the full DeviceCheck token generation path: from calling DCDevice in an app, through XPC into the devicecheckd daemon, into DeviceCheckInternal.framework, and finally to _encryptData, where the payload is constructed and encrypted.
Key stages are covered — initializing the context with TeamID.BundleID, resolving the public key (including the hard-coded fallback), assembling the certificate chain, and forming the opaque blob returned to the caller.
From a security and red-team perspective, this analysis provides the primitives needed to reason about DeviceCheck's trust model, to emulate or instrument the client in controlled environments, and to assess how robustly backends validate and interpret DeviceCheck tokens.