Skip to content

Instantly share code, notes, and snippets.

@gbalduzzi
Last active November 5, 2024 07:04
Show Gist options
  • Save gbalduzzi/0f2f14c3511da9e7811ad6f3a0175d06 to your computer and use it in GitHub Desktop.
Save gbalduzzi/0f2f14c3511da9e7811ad6f3a0175d06 to your computer and use it in GitHub Desktop.
Apple App Attest verification
<?php
const PACKAGE_NAME = 'com.your.bundle_id';
const APPLE_TEAM_ID = 'ABCDEFGHIJK'; // 11 alphanumeric team ID
const ALLOW_DEV_ENVIRONMENT = true;
use CBOR\Decoder;
use CBOR\StringStream;
/**
* @param string $attestation Base64 encoded attestation received from the client
* @param string $keyId Base64 encoded keyId received from the client
* @param string $challenge The challenge provided by your server to the client
* @return int 0 if everything is correct, ErrorCode otherwise
*/
function appleAttestVerify(string $attestation, string $challenge, string $keyId): int
{
/*
* Decode the attestation
*/
$decoder = Decoder::create();
$inputStream = new StringStream(base64_decode($attestation));
$cborData = $decoder->decode($inputStream);
$data = $cborData->normalize();
if (($data['fmt'] ?? '') !== 'apple-appattest') {
return ErrorCodes::AppleInvalidData;
}
/*
* Extract useful attestation data
*/
$attStmt = $data['attStmt'] ?? [];
$x5c = $attStmt['x5c'] ?? [];
$receipt = $attStmt['receipt'] ?? '';
$authData = $data['authData'] ?? '';
$rpId = substr($authData, 0, 32);
$counter = substr($authData, 33, 4);
$aaguid = substr($authData, 37, 16);
$credentialIdLength = substr($authData, 53, 2);
$credentialId = substr($authData, 55, unpack('n', $credentialIdLength)[1]);
if (empty($attStmt) || empty($x5c) || empty($receipt) || empty($authData)) {
return ErrorCodes::AppleInvalidData;
}
// Step 1: verify certificates
$credCert = makeCert($x5c[0]);
$certChain = array_map(fn ($c) => $this->makeCert($c), $x5c);
$certChain[] = openssl_x509_read(self::APP_ATTEST_ROOT);
foreach (range(1, count($certChain) - 1) as $i) {
if (!openssl_x509_verify($certChain[$i - 1], $certChain[$i])) {
return ErrorCodes::AppleInvalidCerts;
}
}
/*
* Step 2 + 3: create clientDataHash as the challenge hash appended to the authData
*/
$clientDataHash = hash('sha256', $authData.hash('sha256', $challenge, true));
/*
* Step 4: Obtain the value of the credCert extension with OID 1.2.840.113635.100.8.2,
*/
$parsedCert = openssl_x509_parse($credCert);
$extension = $parsedCert['extensions']['1.2.840.113635.100.8.2'] ?? '';
// The extension is a DER-encoded ASN.1 sequence, to avoid an ASN.1 decoding library I use a workaround
// The first 6 bytes that represent the apn wrapping of a string, we can ignore them
// The remaining bytes represent the actual string inside the sequence
$extension = bin2hex(substr($extension, 6));
if ($extension !== $clientDataHash) {
return ErrorCodes::AppleInvalidExtension;
}
/*
* Step 5: Create the SHA256 hash of the public key in credCert,
* and verify that it matches the key identifier from your app
*/
$pKey = openssl_pkey_get_details(openssl_pkey_get_public($credCert));
// Create the X9.62 uncompressed point format bytes as: https://security.stackexchange.com/a/185552
// Basically, get the EC x and y params, concatenate them and prepend with 0x04
if (!isset($pKey['ec'])) {
return ErrorCodes::AppleInvalidPublicKey;
}
$pBytes = "\x04".$pKey['ec']['x'].$pKey['ec']['y'];
if (hash('sha256', $pBytes, true) !== $credentialId) {
return ErrorCodes::AppleInvalidPublicKey;
}
/*
* Step 6: Calculate the App ID hash and check it is equal to RP ID
*/
$appIdHash = hash('sha256', self::APPLE_TEAM_ID.'.'.self::PACKAGE_NAME, true);
if ($appIdHash !== $rpId) {
return ErrorCodes::AppleInvalidAppId;
}
/*
* Step 7: verify the counter is 0
*/
if ($counter !== "\0\0\0\0") {
return ErrorCodes::AppleInvalidCounter;
}
/*
* Step 8: verify the aaguid field
*/
if (!in_array(
$aaguid,
ALLOW_DEV_ENVIRONMENT
? ['appattestdevelop', "appattest\0\0\0\0\0\0\0"]
: ["appattest\0\0\0\0\0\0\0"]
)
) {
return ErrorCodes::AppleInvalidAaguid;
}
/*
* Step 9: verify that credentialId is the same as the key identifier
*/
if ($credentialId !== base64_decode($keyId)) {
return ErrorCodes::AppleInvalidCredentialId;
}
return 0;
}
/**
* @param string $binary Binary representation of a certificate
* @return false|\OpenSSLCertificate
*/
function makeCert(string $binary)
{
$encoded = base64_encode($binary);
$cert = "-----BEGIN CERTIFICATE-----\n".implode("\n", str_split($encoded, 64))."\n-----END CERTIFICATE-----\n";
return openssl_x509_read($cert);
}
abstract class ErrorCodes
{
const AppleInvalidNonce = 1;
const AppleInvalidData = 2;
const AppleInvalidCerts = 3;
const AppleInvalidExtension = 4;
const AppleInvalidAppId = 5;
const AppleInvalidCounter = 6;
const AppleInvalidAaguid = 7;
const AppleInvalidCredentialId = 8;
const AppleInvalidPublicKey = 9;
}
@bfct
Copy link

bfct commented Mar 20, 2024

Absolute life saver.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment