A genuine certificate chain, valid signatures, and a fresh challenge do not automatically establish that the current HTTP request came from the device that generated the key. Android Key Attestation supplies verifiable key and device properties. The application protocol must still bind them to an app, a session, and an operation.
This analysis follows a public demonstration's client, attestation service, and backend. Its evidence consists of a code snapshot and two device records. The offline checks cover encoding and policy branches; they do not rerun the handset experiment or establish a universal result for commercial applications.
Genuine evidence can fail policy
The local-key record received HTTP 400 because deviceLocked=false. Its attestation level was still StrongBox, with an Unverified boot state. Hardware can truthfully report a state that the backend does not accept. That is different from forging a certificate.
| Field | Local generation | Relayed record |
|---|---|---|
| HTTP status | 400 | 200 |
| attestation_version | 100 | 3 |
| keymaster_version | 100 | 4 |
| Attestation security level | StrongBox | StrongBox |
| device_locked | false | true |
| verified_boot_state | Unverified | Verified |
| Certificate count | 4 | 4 |
The changes are consistent with a new attestation generated on another device. However, the records use different challenges; they are not a paired experiment with a fixed input. Establishing challenge preservation requires correlating the bytes and parsed DER across every boundary.
StrongBox, bootloader locking, Verified Boot, and runtime process integrity are separate dimensions. No single field establishes an unmodified application process or the state of every device with root access.
Three objects and two encoding channels
Keep the application key, the key signing its attestation, and the business session distinct. Copying a certificate and public key does not export the hardware private key. Provisioning and rotating attestation credentials do not themselves bind an HTTP session to the device holding the application key.
| Object | Demonstration representation | Verification focus |
|---|---|---|
| Challenge | Unpadded base64url → byte[] | Byte equality with an issued challenge |
| Certificates | Array of ordinary Base64-encoded DER | Trusted path, signatures, validity, revocation |
| Requested level | requested_level text | Client-side label only |
| Actual level | Security levels in a verified extension | Apply policy to attestation and key implementation fields |
| Session | Current backend interaction | Separately bind account, key, and action |
The attestation extension OID is 1.3.6.1.4.1.11129.2.1.17. Select the occurrence nearest the root on the constructed and validated path, rather than assuming a leaf location. Newer provisioning extensions add an adjacency requirement. Maintain trust anchors using official guidance, including the 2026 root transition. Android verification procedure
One code-review detail deserves separate attention: after path validation, the snapshot extracts the extension using reversed(chain) on the submitted array. That is not the same operation as traversing the validator's returned path. Their consistency needs an explicit check. This is a static review observation, not a demonstrated certificate-chain exploit.
The return-value seam changes evidence origin
The client requests a challenge, calls its own generateAttestedKey(challenge) wrapper, and submits {nonce, chain}. This method belongs to the sample; it is not a universal Android interface.
Its result contains certificates, a requested level, and a fallback explanation. Replacing that object lets subsequent network code continue. The intervention changes application behavior without modifying secure hardware, certificate signatures, or signed boot-state fields.
sequenceDiagram
participant C as Client
participant B as Backend
C->>B: POST /nonce
B-->>C: Challenge
C->>C: Obtain evidence
C->>B: POST /verify
B-->>C: Decision
sequenceDiagram
participant C as Client
participant H as Host
participant D as Device
C->>H: Challenge bytes
H->>D: POST /attest
D-->>H: New chain
H-->>C: Result object
The controller connects to the client process over USB. Its host-side execution is distinct from the in-process agent; the diagram does not place both on the handset. Challenges and certificates cross this boundary, not private keys.
The requested_level label can contain arbitrary display text. The backend must use verified extension fields instead. The missing binding concerns the submitting endpoint, the attested key, and subsequent operations—not a failure of the hardware signature.
The service and the lifetime of its key
The second device accepts a challenge, generates an EC P-256 key, and returns certificates. Its service attempts StrongBox based on the Android API version, then falls back to a non-StrongBox path. The client implementation instead checks the advertised StrongBox feature first. These are different conditions; neither replaces examination of the actual evidence.

Interface illustration: listening port, running state, and two endpoints—not a verification result.
- 1
Decode the challenge
Recover bytes from unpadded base64url and pass them to setAttestationChallenge.
- 2
Generate and read
Create the key, obtain its certificate chain from Keystore, and encode each DER certificate with ordinary Base64.
- 3
Attempt cleanup
The finally block calls deleteEntry(alias) inside runCatching. Deletion errors are caught; reaching finally alone does not establish successful deletion.
- 4
Separate certificate validity from key access
After successful deletion, the certificate remains verifiable, but the service loses its normal path to signing with that key through the alias.
The sample has no workflow that retains this key and signs subsequent business operations. It therefore exposes a useful distinction between submitting certificates once and continuously possessing the corresponding private key.
Freshness prevents replay, not origin substitution
The backend issues a random 32-byte challenge with a 300-second lifetime. It validates certificates and revocation status, compares the challenge, consumes it once, and then applies device policy. A request that reaches device policy and fails has already consumed its nonce.
Relaying a fresh challenge and replaying evidence for a consumed challenge take different branches. This offline model assumes cryptographic validation, preserves consumption order, and compares policy with and without application binding.
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Evidence:
challenge: bytes
chain_valid: bool
hardware_backed: bool
device_locked: bool
boot_state: str
package: str
signer: bytes
EXPECTED_PACKAGE = "example.demo.client"
EXPECTED_SIGNER = bytes.fromhex("11" * 32)
def decide(e, pending, bind_app=False):
if not e.chain_valid:
return "chain rejected"
if e.challenge not in pending:
return "challenge rejected"
pending.remove(e.challenge)
if not (e.hardware_backed and e.device_locked and e.boot_state == "Verified"):
return "device rejected"
if bind_app and (e.package != EXPECTED_PACKAGE or e.signer != EXPECTED_SIGNER):
return "application rejected"
return "accepted"
challenge = bytes(range(32))
local = Evidence(challenge, True, True, False, "Unverified",
EXPECTED_PACKAGE, EXPECTED_SIGNER)
relay = replace(local, device_locked=True, boot_state="Verified",
package="example.demo.oracle", signer=bytes.fromhex("22" * 32))
assert decide(local, {challenge}) == "device rejected"
pending = {challenge}
assert decide(relay, pending) == "accepted"
assert decide(relay, pending) == "challenge rejected" # old evidence replay
assert decide(relay, {challenge}, bind_app=True) == "application rejected"
same_app_relay = replace(relay, package=EXPECTED_PACKAGE, signer=EXPECTED_SIGNER)
assert decide(same_app_relay, {challenge}, bind_app=True) == "accepted"
print("five policy branches: PASS")The actual output is five policy branches: PASS. The set does not model the 300-second clock, cross-process atomic consumption, account binding, or certificate validation. Resetting it creates independent test cases; it does not suggest re-enabling a consumed production challenge.
The snapshot checks attestation level, locking, and Verified boot state. It neither parses and matches attestationApplicationId nor requires later requests to be signed by the attested key. Those concrete premises matter more than a blanket claim about defeating attestation.
App identity and key possession cover different gaps
attestationApplicationId expresses the platform's view of which apps may use the key. Its digests identify app signing certificates, not APK file hashes. A shared UID may produce multiple packages. Once chain, challenge, and device checks succeed, validate the package and signing-certificate sets with an explicit rotation policy. AOSP field definitions
| Check | Constraint added in this example | Separate question that remains |
|---|---|---|
| App identity | Excludes evidence generated by a different app | Remote driving of the same trusted app |
| Subsequent request signatures | Operations continue to depend on the attested key | An online proxy with continuing key access |
| Session and action binding | A signature belongs to a specific context | Account switches, retries, and concurrent state |
App identity addresses this cross-app path; it does not settle every relay scenario. The model deliberately retains a branch where matching application and signer identities still pass.
A later signature can cover a purpose label, session identifier, fresh challenge, action, body digest, and expiry. Use unambiguous lengths or canonical serialization rather than concatenating variable-length strings. This is a protocol-design recommendation, not a feature implemented in the sample.
Start verification with byte equality
The challenge uses base64url; certificates use ordinary Base64. Confusing them can turn an encoding mistake into an apparent attestation failure. This test includes inputs producing - and _, and rejects a padded representation outside its canonical format.
import base64
import re
def encode_challenge(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def decode_challenge(text):
if not isinstance(text, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", text):
raise ValueError("not unpadded base64url")
padded = text + "=" * (-len(text) % 4)
raw = base64.b64decode(padded, altchars=b"-_", validate=True)
if encode_challenge(raw) != text:
raise ValueError("non-canonical encoding")
return raw
fixtures = [bytes(range(32)), b"\xfb\xff\xff" * 10 + b"\x00\x01"]
for raw in fixtures:
encoded = encode_challenge(raw)
assert len(raw) == 32
assert "=" not in encoded and "\n" not in encoded
assert decode_challenge(encoded) == raw
assert "-" in encode_challenge(fixtures[1])
assert "_" in encode_challenge(fixtures[1])
try:
decode_challenge("AA==")
except ValueError:
pass
else:
raise AssertionError("padded input unexpectedly accepted")
print("challenge codec fixtures: PASS")The actual output is challenge codec fixtures: PASS. The corresponding Android no-padding, no-wrap, and URL-safe flags are 1 | 2 | 8 = 11. They change the textual representation, not the random bytes.
A full protocol check should retain device and APK versions, signing-certificate digests, hashes of every DER certificate, the validated path, challenge issuance and consumption, and alias creation/deletion outcomes. Concurrent relay tooling additionally needs correlation IDs, timeouts, and cancellation so a late response does not satisfy another invocation.
The conclusion belongs at the protocol boundary: valid evidence may originate in another execution environment. Binding it to an expected app, a continuously held key, and a particular action supplies conditions that certificate acceptance alone leaves open. Evaluate Android Key Attestation and Play Integrity according to their separate protocols.