~/posts/mobile/android-key-attestation-relay-session-binding.md

Android key attestation: relayed evidence and session binding

Follow challenges, certificate paths, app identity, and key lifetimes to separate genuine hardware evidence from request origin, with offline checks of the policy boundaries.

date[31:24]
read[23:16]
7 min
cat[15:8]
Mobile
Contents
  1. 0x00Genuine evidence can fail policy
  2. 0x01Three objects and two encoding channels
  3. 0x02The return-value seam changes evidence origin
  4. 0x03The service and the lifetime of its key
  5. 0x04Freshness prevents replay, not origin substitution
  6. 0x05App identity and key possession cover different gaps
  7. 0x06Start verification with byte equality
  8. 0x07References

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.

Fields from two separate records7 rows
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.

Representations and their verification boundaries5 rows
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.

Hardware attestation service interface illustration

Interface illustration: listening port, running state, and two endpoints—not a verification result.

Key lifetime within one request4 steps
  1. 1

    Decode the challenge

    Recover bytes from unpadded base64url and pass them to setAttestationChallenge.

  2. 2

    Generate and read

    Create the key, obtain its certificate chain from Keystore, and encode each DER certificate with ordinary Base64.

  3. 3

    Attempt cleanup

    The finally block calls deleteEntry(alias) inside runCatching. Deletion errors are caught; reaching finally alone does not establish successful deletion.

  4. 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.

policy_model.pypython
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

What each additional requirement establishes3 rows
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.

challenge_codec.pypython
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.

References

NORMAL~/posts/mobile/android-key-attestation-relay-session-binding.md§--
0%en