oire / app-attest
Verify Apple App Attest attestations and assertions in PHP: certificate chain, nonce, key id, counter. Stateless, no I/O.
Requires
- php: >=8.3
- ext-gmp: *
- ext-mbstring: *
- ext-openssl: *
- ext-sodium: *
- psr/clock: ^1.0
- spomky-labs/cbor-php: ^3.4
Requires (Dev)
- friendsofphp/php-cs-fixer: *
- oire/php-code-style: dev-master
- phpseclib/phpseclib: ^4.0
- phpunit/phpunit: *
- psalm/plugin-phpunit: *
- symfony/clock: ^7.4 || ^8.0
- vimeo/psalm: dev-master
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-10 11:20:29 UTC
README
Oire App Attest verifies Apple App Attest attestations and assertions on your PHP server: the certificate chain, the nonce, the key id, the app id and the counter. It verifies and nothing else. It keeps no state, stores no keys, issues no challenges, makes no network call and writes no log: you pass in what you stored, and you store what it returns.
What App Attest Is
App Attest lets your server confirm that a request comes from a genuine copy of your iOS app running on a genuine Apple device. The app generates a key pair in the device's Secure Enclave and asks Apple to vouch for it once: the result is an attestation, a CBOR document holding a certificate chain up to Apple's App Attest root and a nonce that binds the key to a challenge your server issued. Your server verifies the attestation and stores the public key.
From then on the app signs its requests with that key: each signature is an assertion, a CBOR document holding the signature and a counter that grows with every assertion. Your server verifies the signature with the stored public key, checks that the counter grew, and stores the new counter. Apple specifies both checks in Validating apps that connect to your server; this library performs them step by step.
Requirements
PHP 8.3 or later with the GMP, Mbstring, OpenSSL and Sodium extensions. GMP is required, not just suggested, because decoding a large number from untrusted CBOR without it takes time quadratic in the number's length, and every attestation is untrusted input.
The library depends on spomky-labs/cbor-php for CBOR and on psr/clock for the clock interface. It reads certificates with its own DER reader and checks signatures and keys with OpenSSL, so it does not depend on phpseclib and works next to any version of it, or none (see Using phpseclib in Your Application).
Installation
Install via Composer:
composer require oire/app-attest
The Flow at a Glance
- Registration. Your server issues a single-use challenge. The app generates a key with
DCAppAttestService.generateKey(), hashes the challenge into aclientDataHash, callsattestKey(_:clientDataHash:)and sends the key id and the attestation. Your server verifies the attestation withAttestationVerifierand stores the returnedAttestedKey. - Every protected request. Your server issues a single-use challenge. The app builds its client data
(for example the request body, with the challenge inside), hashes it with SHA-256, calls
generateAssertion(_:clientDataHash:)and sends the key id, the client data and the assertion. Your server verifies the assertion withAssertionVerifier, checks and consumes the challenge inside the client data, and stores the new counter.
Encoding What the Client Sends
Both verifiers take raw bytes. Send binary values as unpadded base64url and decode them with Sodium:
$keyId = sodium_base642bin($request['keyId'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
sodium_base642bin() throws a SodiumException on malformed input; treat it as a bad request.
Apple's generateKey() hands the app its key id in standard base64, not base64url, so the app converts
it before sending it. The same conversion turns the attestation and the assertion, which are Data, into
base64url:
func base64Url(_ standardBase64: String) -> String { standardBase64 .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } let keyIdToSend = base64Url(keyId) let attestationToSend = base64Url(attestation.base64EncodedString())
Registering a Key: Verifying an Attestation
In the examples, $logger stands for your PSR-3 logger and Response for your framework's response class.
use Oire\AppAttest\AttestationVerifier; use Oire\AppAttest\Exception\AttestationException; use Oire\AppAttest\Value\AppIdentity; use Oire\AppAttest\Value\BundleId; use Oire\AppAttest\Value\Environment; use Oire\AppAttest\Value\TeamId; $app = new AppIdentity(new TeamId('ABCDE12345'), new BundleId('com.example.app')); $verifier = new AttestationVerifier(); $keyId = sodium_base642bin($request['keyId'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); $attestation = sodium_base642bin($request['attestation'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); // $challenge is the challenge you issued for this registration; look it up and consume it. // Form the hash exactly as your app does: here, the SHA-256 of the challenge. $clientDataHash = hash('sha256', $challenge, true); try { $key = $verifier->verify($attestation, $clientDataHash, $keyId, $app, [Environment::Production]); } catch (AttestationException $e) { $logger->notice('App Attest attestation refused', ['reason' => $e->reason->name, 'message' => $e->getMessage()]); return new Response('', 401); } // Store these for the key, indexed by the key id: // $key->keyIdBase64Url(), $key->publicKeyPem, $key->environment->value, $key->counter (0) and $key->receipt
verify(string $attestationCbor, string $clientDataHash, string $keyId, AppIdentity $app, array $allowed, ?LaunchPolicy $launchPolicy = null): AttestedKey
performs Apple's attestation steps in order:
- The document is at most 16384 bytes, an
apple-appattestobject withattStmt.x5c,attStmt.receiptandauthData, andauthDatais long enough for its layout, with one CBOR map, the COSE key, as the credential public key after thecredentialId. x5cholds exactly two certificates, the credential certificate and an intermediate. The intermediate is a CA issued by the trust anchor, the credential certificate is issued by the intermediate, each signed by its issuer with ECDSA, as Apple's are, and all three are valid at the clock's time.- The nonce, the SHA-256 of
authDatafollowed byclientDataHash, equals the one inside the credential certificate's extension1.2.840.113635.100.8.2. - The SHA-256 of the credential certificate's public key (its raw 65-byte uncompressed point) equals
$keyId. - The
rpIdHashinauthDatais the SHA-256 of your app id,<team id>.<bundle id>. - The counter in
authDatais 0. - The
aaguidinauthDatanames an environment in$allowed. - The
credentialIdinauthDataequals the key id. - Only if you pass a
LaunchPolicy: the validation category and the bundle version inauthDatapass it (see Launch Category and Bundle Version).
$allowed lists the environments a key may come from, as Environment cases; it must not be empty.
$clientDataHash may be any length: the verifier hashes whatever bytes you pass, so it only has to match
what the app passed to attestKey(). Most apps pass the SHA-256 of the challenge; Apple's own sample in
the attestation object validation guide
passes the raw 24-byte challenge.
The returned AttestedKey is a readonly value object:
string $keyId— the key id, raw 32 bytes.string $publicKeyPem— the public key as onePUBLIC KEYPEM block. Store it unchanged and pass it toAssertionVerifier.Environment $environment—Environment::ProductionorEnvironment::Development.string $receipt— Apple's App Attest receipt, raw bytes, for your own use with Apple's fraud metric service; this library does not contact Apple.int $counter— always 0 for a freshly attested key.?int $validationCategory— the raw launch validation category inauthData, or null if the device sent none; kept as a number, so one Apple has not named yet is still reported.?string $bundleVersion— the app's bundle version inauthData, or null if the device sent none.validationCategory(): ?ValidationCategory— the category as an enum case, or null if it is absent or a number with no case.keyIdBase64Url(): string— the key id as unpadded base64url, for storing or indexing it as text.
Verifying a Request: Assertions
use Oire\AppAttest\AssertionVerifier; use Oire\AppAttest\Exception\AssertionException; $assertion = sodium_base642bin($request['assertion'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); // The exact bytes the app hashed into clientDataHash, for example the request body $clientData = sodium_base642bin($request['clientData'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); // Look up the stored key by $request['keyId']: its public key PEM and its counter $stored = $keys->find($request['keyId']); try { $result = (new AssertionVerifier())->verify($assertion, $clientData, $stored->publicKeyPem, $stored->counter, $app); } catch (AssertionException $e) { $logger->notice('App Attest assertion refused', ['reason' => $e->reason->name, 'message' => $e->getMessage()]); return new Response('', 401); } // Now check that the challenge inside $clientData is one you issued, consume it, // and store $result->counter for the key atomically (see What You Must Do Yourself)
verify(string $assertionCbor, string $clientData, string $publicKeyPem, int $previousCounter, AppIdentity $app, ?LaunchPolicy $launchPolicy = null): VerifiedAssertion
performs Apple's assertion steps 1 to 5 and returns a VerifiedAssertion:
- The document is at most 4096 bytes, an object with a
signatureand anauthenticatorDataof at least 37 bytes. - The signature is a valid ECDSA P-256 signature, with SHA-256, over the nonce: the SHA-256 of
authenticatorDatafollowed by the SHA-256 of$clientData. The device signs the nonce, so the nonce is hashed once more by ECDSA itself. - The
rpIdHashinauthenticatorDatais the SHA-256 of your app id. - The counter in
authenticatorDatais strictly greater than$previousCounter. - Only if you pass a
LaunchPolicy: the validation category and the bundle version inauthenticatorDatapass it (Apple's steps 7 and 8, see Launch Category and Bundle Version).
$clientData is raw bytes and is never parsed: what it holds is your protocol, not this library's.
$publicKeyPem must be the stored AttestedKey::$publicKeyPem, passed unchanged. Only a single
PUBLIC KEY PEM block holding an uncompressed P-256 key is accepted: never a file path, a certificate, a
private key or a key of another type. $previousCounter is the counter you stored for the key, 0 right
after its attestation.
AssertionVerifier has no constructor arguments: an assertion carries no certificate and no time.
The returned Oire\AppAttest\Value\VerifiedAssertion is a readonly value object:
int $counter— the new counter, to store for the key.?int $validationCategory— the raw launch validation category inauthenticatorData, or null if the device sent none; kept as a number, so one Apple has not named yet is still reported.?string $bundleVersion— the app's bundle version inauthenticatorData, or null if the device sent none.validationCategory(): ?ValidationCategory— the category as an enum case, or null if it is absent or a number with no case.
Without a LaunchPolicy the launch values are only reported; with one, they have passed it.
Launch Category and Bundle Version
Apple's list of attestation steps ends with "Verify the apple_validation_category_01 value" and "Verify
the apple_bundle_version_01 value", both "within the extensions CBOR dictionary in the authenticator
data", and its assertion steps 7 and 8 say the same of validationCategory and bundleVersion. Newer OS
versions append this map to authData after the credential public key, and to an assertion's
authenticatorData after its 37 bytes:
- the validation category, a
UInt32that Apple calls "the launchValidationCategoryof your app": how the executable was signed and distributed, numbered as in Apple's Defining launch environment and library constraints; - the bundle version, a string "representing the version of the distributed App".
Apple names no value you must accept and says nothing of a device that sends neither, and older devices
do send neither: none of the iOS 14 vectors this library is tested with carries them. So the library
reports the values on AttestedKey and VerifiedAssertion and enforces them only when you ask. Without a
policy, nothing about them is checked, and an extensions area that is missing or malformed is ignored.
Oire\AppAttest\Value\ValidationCategory is an int-backed enum: Platform (1, an operating system
executable), TestFlight (2), Development (3, signed by a development identity), AppStore (4),
Enterprise (5, an enterprise provisioning profile or ad hoc distribution), DeveloperId (6) and None
(10, a signing identity that matches no other category). Apple keeps 7 to 9 for binaries the system
generates in restricted situations and names none of them, so they have no case: AttestedKey and
VerifiedAssertion still report such a number in $validationCategory, and no policy allows it.
ValidationCategory::tryFromRaw(?int $value) gives the case for a reported number, or null.
Pass a LaunchPolicy as the last argument of AttestationVerifier::verify() to enforce them:
use Oire\AppAttest\Value\LaunchPolicy; use Oire\AppAttest\Value\ValidationCategory; $policy = LaunchPolicy::allowing(ValidationCategory::AppStore, ValidationCategory::TestFlight) ->withBundleVersion(static fn(string $version): bool => version_compare($version, '2.0', '>=')); $key = $verifier->verify($attestation, $clientDataHash, $keyId, $app, [Environment::Production], $policy);
AssertionVerifier::verify() takes a policy the same way, but think twice before you pass one: no genuine
assertion with these values has been published to test against (see below). If a device puts them in its
attestation but not in its assertions, or spells them otherwise, every later assertion from it fails with
ValidationCategory or BundleVersion. Enforcing the policy once, on the attestation, does not have
this risk; to see what assertions carry first, verify them without a policy and read the values on the
VerifiedAssertion.
LaunchPolicy::allowing(ValidationCategory ...$categories)allows these categories and leaves the bundle version unchecked. It needs at least one category: called with none, for example with an empty array from your configuration spread into it, it throws anInvalidArgumentExceptionrather than turn the category check off.new LaunchPolicy()checks nothing, andnew LaunchPolicy($categories, $acceptsBundleVersion)takes both parts at once. Its empty category list means that the category is not checked at all: every category passes, an absent one too. If you build that list from configuration, refuse an empty one yourself.withBundleVersion(Closure $accepts)returns a copy that also requires a bundle version for which the closure returnstrue.- With a policy that lists categories, a category that is absent, has no enum case or is not listed fails
with
ValidationCategory. With a bundle version closure, a bundle version that is absent or that the closure does not accept fails withBundleVersion. Both checks run after every other check, the category first.
The values are read leniently, because Apple documents only their names and types. The category counts
when it is four little-endian bytes (as in Apple's sample) or a CBOR unsigned integer of at most
2^32 − 1; the bundle version when it is a text string. Both verifiers accept both spellings,
apple_validation_category_01 or validationCategory and apple_bundle_version_01 or bundleVersion;
when both hold a usable value, the apple_…_01 one wins. A value of another type, or an extensions area
that is not exactly one CBOR map with distinct text-string keys and nothing after it, counts as absent.
That rule applies to the keys of the extensions map itself: a map nested in another entry, whatever its
keys, does not hide the launch values.
Apple's own attestation sample carries category 1 and bundle version "1", and the library reports both.
No genuine assertion sample with these values exists, so the assertion side is tested only with
assertions the test builders sign, and with Apple's iOS 14 assertions, which carry none. Before you
enforce a policy in production, check what your own users' devices send: verify without a policy for a
while and look at $validationCategory and $bundleVersion on AttestedKey and VerifiedAssertion. version_compare(), as in the
example, treats "1" as lower than "1.0", so compare versions the way your app numbers them.
Failures
Exception Hierarchy
Oire\AppAttest\Exception\AppAttestException— abstract, extendsRuntimeException. Catch it to handle a failure of either verifier in one place.AttestationException— thrown byAttestationVerifier::verify(), withpublic readonly AttestationFailureReason $reason.AssertionException— thrown byAssertionVerifier::verify(), withpublic readonly AssertionFailureReason $reason.
Each exception's $reason is an enum case, so you can map a failure to a response or a metric without
parsing the message. Messages name the failed check and never contain key material, so they are safe to
log. Do not send them to the client: refuse with a generic answer.
Attestation Failure Reasons
Oire\AppAttest\Exception\AttestationFailureReason:
Format— the document is longer than 16384 bytes (Apple's are about 6 KB) or not a well-formedapple-appattestobject,authDatais too short or its credential public key is not one CBOR map, or the credential certificate does not hold an uncompressed P-256 key. The document must be one CBOR map with nothing after it, the keys of every map in it distinct text strings,x5can array,fmta text string, andx5c's entries,receiptandauthDatabyte strings.CertificateChain— the chain is not exactly two certificates, does not lead to the trust anchor, has an intermediate that is not a CA, or is not valid at the clock's time; or a certificate is longer than 4096 bytes (Apple's are about 1 KiB), is not DER where the library reads it, marks critical an extension the library does not process for that certificate, is not signed by its issuer with ECDSA over SHA-256, SHA-384 or SHA-512, or its issuer lackskeyCertSign, has another subject Name or another key identifier; or the credential certificate claims to be a CA (see Trust Anchor).Nonce— the credential certificate has no nonce extension, has it twice or malformed, or its nonce does not matchauthDataand$clientDataHash. AclientDataHashformed differently from the app's ends here.KeyId— the credential certificate's key, or thecredentialIdinauthData, does not match the key id.RpIdHash— the key belongs to another team or bundle.Counter— the counter inauthDatais not 0.Environment— theaaguidnames an environment not in$allowed, or none at all.ValidationCategory— only with aLaunchPolicythat lists categories: the validation category inauthDatais absent, has noValidationCategorycase, or is not listed.BundleVersion— only with aLaunchPolicythat checks the bundle version: the bundle version inauthDatais absent or refused by the policy's closure.
Assertion Failure Reasons
Oire\AppAttest\Exception\AssertionFailureReason:
Format— the document is longer than 4096 bytes (Apple's are about 140) or not an object with asignatureand at least 37 bytes ofauthenticatorData. It must be one CBOR map with nothing after it, the keys of every map in it distinct text strings and both members byte strings.Signature— the signature does not verify with the public key over this client data.RpIdHash— the assertion was made for another team or bundle.Counter— the counter did not grow: a replayed or reordered assertion.ValidationCategoryandBundleVersion— only with aLaunchPolicy, as for attestations, about the values inauthenticatorData.
Caller Errors
A mistake in what your code passes is an InvalidArgumentException, never an AppAttestException, so it
cannot be mistaken for a forged request:
- a
TeamIdthat is not exactly 10 uppercase letters and digits; - a
BundleIdthat is empty or holds anything but ASCII letters, digits, hyphens and periods; - a
$publicKeyPemthat is not one uncompressed P-256PUBLIC KEYPEM block; - a
$previousCounteroutside 0 to 2^32 − 1; - an
$allowedthat is empty or holds anything butEnvironmentcases, such as the strings'production'or'development'; - a
LaunchPolicycategory list holding anything butValidationCategorycases, andLaunchPolicy::allowing()called with no category; - a
TrustAnchor::fromPem()argument that is not exactly one PEM certificate, whose certificate is not DER where the library reads it, or that has no EC key or no key usage that includeskeyCertSign, so that no chain could lead to it.
A broken installation is a LogicException, not a failed verification: TrustAnchor::apple(), which
AttestationVerifier calls when you pass no trust anchor, throws one if the bundled Apple root is missing or
does not match its pinned fingerprint.
What You Must Do Yourself
The library verifies; everything around verification is yours:
-
Issue single-use challenges. Generate them randomly on the server, give each a short lifetime, and accept each one once.
-
Compute
clientDataHashthe way your app does. The verifier cannot guess how the app formed it; a mismatch is aNoncefailure. -
Store the key. Keep the key id, the public key PEM, the environment and the counter, indexed by the key id. Keep the receipt if you plan to use Apple's fraud metric.
-
Check the challenge inside
clientDataand consume it. This is Apple's assertion step 6. The library never parsesclientData, so it cannot tell a fresh request from an old one replayed with its original assertion; only your challenge check can. -
Update the counter atomically. Two concurrent requests can carry two valid assertions with the same previous counter; both verify, and only one may pass. Store the new counter,
$result->counter, with a compare-and-set and refuse the request if no row changed:UPDATE app_attest_keys SET counter = :newCounter WHERE key_id = :keyId AND counter = :previousCounter
-
Decide which environments to accept, and pass them as
$allowed. -
Decide whether to enforce the launch values, and pass a
LaunchPolicyif you do (see Launch Category and Bundle Version).
Development and Production Keys
An attestation from a development build carries the appattestdevelop aaguid, one from a TestFlight or
App Store build the production aaguid. Accepting Environment::Development keys is safe in the sense
that matters: the rpIdHash check ties every key to your team id and bundle id, and only builds signed
with your team's certificates can produce keys for your app id. Another developer's app, development or
not, can never pass. Pass [Environment::Production] alone if your production server should refuse your
own development builds, and [Environment::Production, Environment::Development] while you develop.
Clocks
The certificate checks need the current time. AttestationVerifier takes any
PSR-20 ClockInterface as its second argument, Symfony's
symfony/clock included, and uses the system time through its own SystemClock when you pass none:
use Oire\AppAttest\AttestationVerifier; use Symfony\Component\Clock\MockClock; $verifier = new AttestationVerifier(clock: new MockClock('2026-10-09T12:00:00Z'));
Trust Anchor
By default the chain must lead to Apple's App Attest root, bundled in
resources/Apple_App_Attestation_Root_CA.pem and checked against its pinned SHA-256 fingerprint
(TrustAnchor::APPLE_ROOT_SHA256) every time it is loaded. Tests that sign their own chains pass their
own root:
use Oire\AppAttest\AttestationVerifier; use Oire\AppAttest\TrustAnchor; $verifier = new AttestationVerifier(TrustAnchor::fromPem($testRootPem));
A test chain must, like Apple's:
- use EC keys, not Ed25519 or Ed448;
- sign each certificate with ECDSA over SHA-256, SHA-384 or SHA-512, with the same algorithm, without
parameters, inside and outside
tbsCertificate; - give the root and the intermediate exactly one key usage extension that includes
keyCertSign; - mark the intermediate as a CA with exactly one
basicConstraints(cAtrue, an optional non-negative path length); - name each issuer with exactly the bytes of the issuer's subject Name and, if a certificate carries an authority key identifier, name in it the issuer's subject key identifier and, if it holds one, the issuer's serial number;
- mark critical, in the intermediate, only
basicConstraints, the key usage and the key identifiers, and in the credential certificate only these and the nonce extension; - give the credential certificate, if it has them, one well-formed key usage and one
basicConstraintsthat is the emptySEQUENCE(cAfalse, no path length), as Apple's does, and keep every subject and authority key identifier well-formed; - keep all three certificates valid at the clock's time;
- be v3 certificates and DER where the library reads them: lengths, integers and OIDs in their shortest
form, booleans
0x00or0xFF, a critical flag left out rather than FALSE, validity times in UTC with seconds and no fraction, nothing after the extensions, the key usage without trailing zero bits, and each of the key usage, key identifier,basicConstraintsand nonce extensions at most once.
TrustAnchor::fromPem() throws an InvalidArgumentException for a root that has no EC key, no
keyCertSign or is not DER; any other miss fails every attestation with CertificateChain, except a nonce
extension that is missing, repeated or not SEQUENCE { [1] { OCTET STRING } }, which fails with Nonce.
Using phpseclib in Your Application
The library neither requires nor calls phpseclib, so your application may use phpseclib 3, phpseclib 4 or
neither alongside it. phpseclib keeps its settings in static properties shared by the whole process, such as
ASN1::enableBlobsOnBadDecodes(), the CA store of X509::addCA(), X509::ignoreKeyUsage() or
X509::looseDNComparison(). None of them changes a verification result, and the library changes none of
them: unlike 1.x, it neither disables phpseclib's URL fetching nor registers the App Attest nonce extension
with phpseclib.
Since no phpseclib switch applies, the library makes its own checks, listed under Trust Anchor, and decodes
the nonce extension itself. Verification makes no network call: issuer certificates named in
authorityInfoAccess are never fetched. If your own code validates certificates with phpseclib and relied on
URL fetching being off, call X509::disableURLFetch() yourself.
Testing Your Own Code
To test code that calls AssertionVerifier, sign assertions yourself with a P-256 key, the way a device
does: build a 37-byte authenticatorData (your app's rpIdHash, the flags byte 0x40 genuine assertions
carry, which the verifier does not read, and the counter as a big-endian 32-bit integer), form the nonce,
the SHA-256 of authenticatorData followed by the SHA-256 of clientData, sign the nonce with ECDSA
over SHA-256, and CBOR-encode the two members:
use CBOR\ByteStringObject; use CBOR\MapObject; use CBOR\TextStringObject; use Oire\AppAttest\AssertionVerifier; use Oire\AppAttest\Value\AppIdentity; use Oire\AppAttest\Value\BundleId; use Oire\AppAttest\Value\TeamId; $app = new AppIdentity(new TeamId('ABCDE12345'), new BundleId('com.example.app')); $clientData = '{"challenge":"c2VydmVyIGNoYWxsZW5nZQ"}'; $previousCounter = 0; $privateKey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); $publicKeyPem = openssl_pkey_get_details($privateKey)['key']; $authenticatorData = $app->rpIdHash() . "\x40" . pack('N', $previousCounter + 1); $nonce = hash('sha256', $authenticatorData . hash('sha256', $clientData, true), true); openssl_sign($nonce, $signature, $privateKey, OPENSSL_ALGO_SHA256); $assertion = (string) MapObject::create() ->add(TextStringObject::create('signature'), ByteStringObject::create($signature)) ->add(TextStringObject::create('authenticatorData'), ByteStringObject::create($authenticatorData)); $result = (new AssertionVerifier())->verify($assertion, $clientData, $publicKeyPem, $previousCounter, $app); // $result->counter is 1
A signature over the bare authenticatorData followed by the SHA-256 of clientData, without hashing
them into the nonce first, is refused with Signature. To test a LaunchPolicy, append a CBOR map such as
{"validationCategory": 4, "bundleVersion": "2.1"} to the 37 bytes of authenticatorData before forming
the nonce. The CBOR classes come from spomky-labs/cbor-php, which this library already requires.
Attestations are harder to make, because they need a certificate chain with the nonce extension. The
credential certificate carries the nonce in extension 1.2.840.113635.100.8.2, whose value is the DER
SEQUENCE { [1] EXPLICIT OCTET STRING } around the 32-byte SHA-256 of authData followed by
clientDataHash. Any tool that issues EC certificates with ECDSA works, as long as the chain meets the rules
under Trust Anchor; verify it against TrustAnchor::fromPem() of its own root. This repository's test-only
AttestationBuilder
and SignedCertificate
show one way with phpseclib 4, which is only a development dependency of this repository: require it yourself
to build chains the same way, and write the nonce extension's OID yourself rather than reaching for the
library's Internal classes, which are not public API. The test code is not part of the Composer package.
API Reference
Oire\AppAttest:
AttestationVerifier::__construct(?TrustAnchor $root = null, ?ClockInterface $clock = null)AttestationVerifier::verify(string $attestationCbor, string $clientDataHash, string $keyId, AppIdentity $app, array $allowed, ?LaunchPolicy $launchPolicy = null): AttestedKey, where$allowedis a non-empty list ofEnvironmentcasesAssertionVerifier::verify(string $assertionCbor, string $clientData, string $publicKeyPem, int $previousCounter, AppIdentity $app, ?LaunchPolicy $launchPolicy = null): VerifiedAssertionTrustAnchor::apple(): TrustAnchorandTrustAnchor::fromPem(string $pem): TrustAnchorTrustAnchor::APPLE_ROOT_SHA256, the pinned fingerprint as lowercase hexadecimal, andTrustAnchor::$pem, the root certificate as PEMSystemClock, the PSR-20 clock used when none is passed
Oire\AppAttest\Value:
TeamId::__construct(string $value), withpublic readonly string $valueBundleId::__construct(string $value), withpublic readonly string $valueAppIdentity::__construct(TeamId $teamId, BundleId $bundleId), withappId(): string(<team id>.<bundle id>) andrpIdHash(): string(its SHA-256, raw bytes)Environment, a string-backed enum with the casesProduction('production') andDevelopment('development'),aaguid(): stringandEnvironment::tryFromAaguid(string $aaguid): ?EnvironmentAttestedKey::__construct(string $keyId, string $publicKeyPem, Environment $environment, string $receipt, ?int $validationCategory = null, ?string $bundleVersion = null), described under Registering a KeyVerifiedAssertion::__construct(int $counter, ?int $validationCategory = null, ?string $bundleVersion = null), described under Verifying a RequestValidationCategory, an int-backed enum:Platform(1),TestFlight(2),Development(3),AppStore(4),Enterprise(5),DeveloperId(6) andNone(10), withValidationCategory::tryFromRaw(?int $value): ?ValidationCategoryLaunchPolicy::__construct(array $validationCategories = [], ?Closure $acceptsBundleVersion = null), withpublic readonly array $validationCategories(a list ofValidationCategorycases),public readonly ?Closure $acceptsBundleVersion,LaunchPolicy::allowing(ValidationCategory ...$categories): LaunchPolicy,withBundleVersion(Closure $accepts): LaunchPolicy,allowsValidationCategory(?int $category): boolandacceptsBundleVersion(?string $bundleVersion): bool, described under Launch Category and Bundle Version
Oire\AppAttest\Exception: AppAttestException, AttestationException, AttestationFailureReason,
AssertionException and AssertionFailureReason, described under Failures.
Everything under Oire\AppAttest\Internal, and every member marked @internal, such as
TrustAnchor::fromPinnedPem(), is not part of the public API and may change in any release.
Development
composer install
composer test
composer lint
composer test runs PHPUnit; composer lint runs PHP CS Fixer with Oire's code style in dry-run mode,
then Psalm at level 1. Without a local PHP, the repository's container gives PHP 8.5 with every required
extension:
docker compose run --rm php composer test
docker compose run --rm php composer lint
The same Dockerfile builds PHP 8.3, the lowest supported version:
docker build --build-arg PHP_VERSION=8.3 -t app-attest-php83 . docker run --rm -v "$PWD":/app app-attest-php83 composer test docker run --rm -v "$PWD":/app app-attest-php83 composer lint
The tests verify genuine attestations and assertions signed by Apple, used byte for byte as the reference implementations ship them, and reach every failure through the verifiers' inputs or through test-only builders, never by editing a signed vector. Their origins are listed in the golden vectors' README.
Credits
The checks are ported from two reference implementations, whose test data are this library's golden vectors:
- veehaitch/devicecheck-appattest, in Kotlin, licensed under the Apache License, Version 2.0;
- takimoto3/app-attest, in Go, licensed under the MIT License.
Their license texts are in tests/fixtures/LICENSE-veehaitch and tests/fixtures/LICENSE-takimoto3. One
attestation vector is the sample Apple publishes in its attestation object validation guide.
License
Copyright © 2026 André Polykanine, Oire Software. Licensed under the Apache License, Version 2.0. See the LICENSE file.