DEV Community

EME GUG
EME GUG

Posted on

Beyond Play Integrity: Verifying Android Devices with Hardware Key Attestation (Without Locking Out GrapheneOS Users)

Tuần này trên Hacker News, tin khoảng một chục app ngân hàng bắt đầu chính thức cho phép GrapheneOS lên tới hơn 500 điểm. Nghe thì giống chuyện của cộng đồng privacy, nhưng với dev làm fintech, ví điện tử hay app có eKYC ở Việt Nam thì đây là câu hỏi kỹ thuật rất thật: làm sao biết thiết bị đáng tin mà không cần phụ thuộc hoàn toàn vào Google Play Integrity? Mình từng làm backend cho một app thanh toán. Team dùng MEETS_DEVICE_INTEGRITY làm điều kiện cứng, và kết quả là chặn nhầm cả người dùng ROM sạch, có verified boot đầy đủ, trong khi máy root có cài module bypass vẫn lọt qua. Bài này chia sẻ cách tiếp cận mà các ngân hàng kia đang dùng: Android Hardware Key Attestation, kiểm tra phía server, với policy rõ ràng.

Play Integrity kiểm tra gì, và vì sao chưa đủ

Play Integrity API trả về một verdict có các nhãn như MEETS_BASIC_INTEGRITY, MEETS_DEVICE_INTEGRITY, MEETS_STRONG_INTEGRITY. Vấn đề nằm ở chỗ:

  • MEETS_DEVICE_INTEGRITY về bản chất nghĩa là máy đã được Google chứng nhận (Play Protect certified). Một OS thay thế, dù an toàn hơn stock, vẫn không bao giờ đạt được nhãn này.
  • Verdict là một hộp đen. Bạn không biết bootloader có bị lock không, OS nào đang chạy, patch level là bao nhiêu.
  • Cả hệ thống phụ thuộc vào Google Play Services, nên máy không có GMS thì coi như hết đường.

Key Attestation thì đi xuống tầng thấp hơn. Bạn yêu cầu chip bảo mật (TEE hoặc StrongBox) sinh một cặp khóa, rồi chip đó ký một certificate chain chứng minh trạng thái thiết bị: bootloader đã lock chưa, verifiedBootKey là gì, osPatchLevel bao nhiêu, app nào đã yêu cầu tạo khóa. Chain này đi ngược về Google Hardware Attestation Root. Khóa nằm trong phần cứng nên OS bị root cũng không giả mạo được.

sequenceDiagram
    participant App
    participant TEE as TEE/StrongBox
    participant API as Backend
    App->>API: GET /attest/challenge
    API-->>App: nonce (32 bytes, TTL 60s)
    App->>TEE: generateKey(challenge = nonce)
    TEE-->>App: Certificate chain
    App->>API: POST /attest (chain)
    API->>API: Verify chain + parse extension
    API->>API: Apply policy (boot state, key, patch)
    API-->>App: device_token / reject

Phía client: sinh khóa có attestation

Trên Android (API 24+), chỉ cần thêm setAttestationChallenge khi tạo khóa trong Android Keystore. Challenge bắt buộc phải lấy từ server để chống replay.

fun createAttestedKey(challenge: ByteArray): List<String> {
    val alias = "device_binding_key"
    val kpg = KeyPairGenerator.getInstance(
        KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore"
    )
    val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
        .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
        .setDigests(KeyProperties.DIGEST_SHA256)
        .setAttestationChallenge(challenge)
        .apply {
            // Pixel 3+ có StrongBox; fallback về TEE nếu không có
            if (Build.VERSION.SDK_INT >= 28 && hasStrongBox()) setIsStrongBoxBacked(true)
        }
        .build()
    kpg.initialize(spec)
    kpg.generateKeyPair()

    val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    return ks.getCertificateChain(alias).map {
        Base64.encodeToString(it.encoded, Base64.NO_WRAP)
    }
}
Enter fullscreen mode Exit fullscreen mode

Điểm hay là khóa này về sau dùng luôn để ký request (device binding). Một token đánh cắp sẽ vô dụng khi đem sang máy khác.

Phía server: verify chain và đọc extension

Đây là phần hay bị làm sai nhất. Có 4 bước không được bỏ qua:

  1. Verify từng cert được ký bởi cert kế tiếp trong chain.
  2. Public key của root phải khớp với Google Hardware Attestation Root (lấy từ tài liệu chính thức của Android, nhớ hiện tại có cả root RSA cũ lẫn root ECDSA mới).
  3. Kiểm tra serial của các cert trong chain với revocation list tại https://android.googleapis.com/attestation/status.
  4. Parse extension OID 1.3.6.1.4.1.11129.2.1.17 (KeyDescription) trên leaf cert, rồi so attestationChallenge với nonce đã phát.

Ví dụ Python với cryptography 43.x:

import base64, requests
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
from cryptography.x509.oid import ObjectIdentifier

KEY_DESC_OID = ObjectIdentifier("1.3.6.1.4.1.11129.2.1.17")
GOOGLE_ROOT_PUBKEYS = load_google_roots()  # DER public keys, pin cứng trong config

def verify_sig(child: x509.Certificate, parent: x509.Certificate):
    pub = parent.public_key()
    if isinstance(pub, ec.EllipticCurvePublicKey):
        pub.verify(child.signature, child.tbs_certificate_bytes,
                   ec.ECDSA(child.signature_hash_algorithm))
    elif isinstance(pub, rsa.RSAPublicKey):
        pub.verify(child.signature, child.tbs_certificate_bytes,
                   padding.PKCS1v15(), child.signature_hash_algorithm)

def verify_chain(chain_b64: list[str]) -> x509.Certificate:
    certs = [x509.load_der_x509_certificate(base64.b64decode(c)) for c in chain_b64]
    for child, parent in zip(certs, certs[1:]):
        verify_sig(child, parent)

    root_der = certs[-1].public_key().public_bytes(
        encoding=Encoding.DER, format=PublicFormat.SubjectPublicKeyInfo)
    if root_der not in GOOGLE_ROOT_PUBKEYS:
        raise ValueError("root không phải Google attestation root")

    revoked = requests.get("https://android.googleapis.com/attestation/status",
                           timeout=5).json()["entries"]  # nên cache 1 giờ
    for c in certs:
        if format(c.serial_number, "x") in revoked:
            raise ValueError("cert đã bị revoke")
    return certs[0]

def get_key_description(leaf: x509.Certificate) -> bytes:
    ext = leaf.extensions.get_extension_for_oid(KEY_DESC_OID)
    return ext.value.value  # DER của KeyDescription, parse bằng pyasn1
Enter fullscreen mode Exit fullscreen mode

Phần parse ASN.1 của KeyDescription (gồm attestationSecurityLevel, softwareEnforced, hardwareEnforced, RootOfTrust...) khá dài. Nếu backend chạy JVM thì dùng thẳng thư viện google/android-key-attestation trên GitHub. Với Python, bạn định nghĩa schema bằng pyasn1 theo đúng spec trong tài liệu Android, mất tầm 100 dòng. Chi tiết quan trọng: chỉ tin các field trong hardwareEnforced. Field nằm trong softwareEnforced thì OS có thể khai gì cũng được.

Policy: allowlist theo verifiedBootKey thay vì "chỉ Google"

Đây là cách các ngân hàng kia mở cửa cho GrapheneOS. Trong RootOfTrust có verifiedBootState và verifiedBootKey. Với stock ROM, trạng thái là Verified (máy boot bằng key của OEM). Với OS thay thế cài đúng cách, bootloader được re-lock bằng key riêng của OS đó, nên trạng thái là SelfSigned, còn verifiedBootKey là hash của key đó. GrapheneOS công bố công khai các hash này cho từng dòng Pixel trong tài liệu attestation của họ.

flowchart TD
    A[KeyDescription parsed] --> B{securityLevel >= TEE?}
    B -- No --> X[Reject]
    B -- Yes --> C{deviceLocked?}
    C -- No --> X
    C -- Yes --> D{verifiedBootState}
    D -- Verified --> E[OEM ROM: accept]
    D -- SelfSigned --> F{bootKey in allowlist?}
    F -- Yes --> G[Alt OS: accept]
    F -- No --> X
    D -- Unverified/Failed --> X
from datetime import date

# Hash lấy từ tài liệu chính thức của từng OS, lưu trong config/DB, không hardcode
ALLOWED_SELF_SIGNED = load_allowlist("attestation_boot_keys.json")
MAX_PATCH_AGE_DAYS = 180

def evaluate(kd: dict, expected_nonce: bytes, package: str) -> str:
    if kd["attestation_challenge"] != expected_nonce:
        return "reject:nonce"
    if kd["security_level"] not in ("TrustedEnvironment", "StrongBox"):
        return "reject:software_keystore"
    if kd["package_name"] != package:
        return "reject:wrong_app"

    rot = kd["hw"]["root_of_trust"]
    if not rot["device_locked"]:
        return "reject:unlocked_bootloader"

    state = rot["verified_boot_state"]
    if state == "SelfSigned" and rot["verified_boot_key"].hex() not in ALLOWED_SELF_SIGNED:
        return "reject:unknown_os"
    if state not in ("Verified", "SelfSigned"):
        return "reject:boot_state"

    patch = kd["hw"].get("os_patch_level")  # dạng YYYYMM
    if patch and (date.today() - date(patch // 100, patch % 100, 1)).days > MAX_PATCH_AGE_DAYS:
        return "step_up:outdated_patch"  # không chặn hẳn, yêu cầu OTP
    return "accept"
Enter fullscreen mode Exit fullscreen mode

Để ý dòng step_up: không phải lỗi nào cũng nên chặn cứng. Máy lâu chưa update thì bắt thêm OTP hoặc giới hạn hạn mức chuyển tiền. Làm vậy UX tốt hơn nhiều so với popup "Thiết bị không an toàn" rồi kill app.

Kết luận

Những việc có thể làm ngay trong sprint tới:

  • Đừng dùng Play Integrity làm cổng duy nhất. Dùng nó như một tín hiệu, còn quyết định cuối cùng nên dựa trên Key Attestation verify phía server.
  • Challenge phải đến từ server, dùng một lần, TTL ngắn. Thiếu bước này thì attestation chẳng có ý nghĩa gì.
  • Pin root public key của Google và check revocation list. Cache danh sách revoke khoảng 1 giờ, đừng gọi mỗi request.
  • Chỉ tin hardwareEnforced. Từ chối security_level = Software.
  • Allowlist verifiedBootKey để trong config, không hardcode, vì hash có thể thay đổi theo dòng máy mới.
  • Thiết kế policy nhiều mức (accept / step-up / reject) và log lý do reject. Có số liệu thật rồi bạn mới biết mình đang chặn nhầm bao nhiêu người.
  • Gắn khóa attestation vào session để ký request, có thêm device binding mà không tốn gì.

Bảo mật thiết bị không có nghĩa là "chỉ chấp nhận máy Google đóng dấu". Nó có nghĩa là kiểm chứng được trạng thái máy bằng mật mã. Làm đúng cách thì app của bạn vừa an toàn hơn, vừa không đẩy đi nhóm người dùng quan tâm đến bảo mật nhất.

Top comments (0)