Skip to content

fix(#62): read service signers from the APK Signing Block (0.4.1) - #69

Merged
dfattal merged 1 commit into
mainfrom
fix/android-installer-v2-cert-read
Sep 30, 2026
Merged

dfattal merged 1 commit into
mainfrom
fix/android-installer-v2-cert-read

Conversation

@dfattal

@dfattal dfattal commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

What broke

On an NP02J/K68 (Android 13, API 33), installer 0.4.0 downloaded the pinned display services from updates.displayxr.org/services/cnsdk/v0.10.69/. Size and sha256 matched. It then refused device-service with "Android could not read the signing certificate of device-service.apk … Refused.", and skipped head tracking because the two install as a pair. The final status correctly said NOT READY.

Both service APKs are signed with APK Signature Scheme v2 only (apksigner: v1 false, v2 true, v3 false; 1 signer), and their certificates are the pinned ones.

Root cause: an Android 13 platform bug, not the file

android13-release PackageManager.getPackageArchiveInfo:

PackageParser.Package pkg = parser.parsePackage(apkFile, 0, false);
if ((flagsBits & GET_SIGNATURES) != 0) {
    PackageParser.collectCertificates(pkg, false /* skipVerify */);
}

The certificates are collected only for the deprecated GET_SIGNATURES flag. With GET_SIGNING_CERTIFICATES alone, which is what 0.4.0 passed, SigningDetails stays UNKNOWN and generatePackageInfo sets signingInfo = null. android13-qpr1-release onward, Android 14, and Android 12 (ApplicationPackageManager, collectCertificates = GET_SIGNATURES || GET_SIGNING_CERTIFICATES) all accept either flag. So the result depends on which framework build the tablet runs.

Fix

  • ApkSignatureReader: a pure-Kotlin verifier for APK Signature Scheme v2, v3 and v3.1. It works through EOCD → central directory offset → APK Sig Block 42 → pair 0x1b93ad61 / 0xf05368c0 / 0x7109871a, choosing the scheme the platform uses for the device's SDK (v3.1, then v3, then v2, which gives the current key after a rotation). It does not only parse. For each signer it verifies:

    • the signature over the signed data;
    • that the public key is the certificate's;
    • that the digest and signature algorithm lists are the same;
    • the whole-file content digest (1 MiB chunks over the three ZIP sections).

    It returns the SHA-256 of each signer certificate. A certificate pasted into a forged block is refused, and so is a single flipped byte anywhere in the file.

  • archiveIdentity also asks Android, now with GET_SIGNING_CERTIFICATES | GET_SIGNATURES. DisplayServices.combineSigners then combines the two answers:

    • a block that does not verify is refused, whatever Android says;
    • if the reader and Android disagree, the APK is refused;
    • if there is no v2/v3 block (v1-only), Android's answer is used;
    • if neither gives a certificate, the APK is refused.
  • archiveProblem is unchanged in substance: exactly one signer, equal to the pin (and to the manifest). Behind it, Android still refuses any update to a system app that is not signed with that app's key.

  • Installer 0.4.1 (versionCode 6). The README's trust section and layout are updated.

Tests

ApkSignatureReaderTest (19 tests) signs synthetic APKs at test time with apksig + bcpkix, both test-only dependencies. No vendor bytes and no key are committed. The APKs are larger than 2 MiB, so the digest spans several chunks. Coverage:

  • v2-only, checked at API 29–35
  • ECDSA
  • v1+v2
  • v2+v3
  • key rotation through v3 (API 28) and v3.1 (API 33): the new key at 33, the old key at 31
  • two v2 signers: both are returned, and the pin rule refuses the set
  • refused cases: v1-only (NoSigningBlock), unsigned, not a ZIP, a content byte changed, a central-directory byte changed, the certificate changed inside the block, a truncated file
  • all the combineSigners branches

Mutation-checked: removing the content-digest check, the signature check, v3.1 selection, v3 selection, the disagreement refusal or the invalid-block refusal each fails at least one test.

The real vendor APKs were run locally through DXR_SERVICE_APK_DIR (the test is skipped when that is unset; the files are never committed). Result: v2 at API 29/31/33/35, device-service dbc2792f775902a74302d500ab00c6224a4aeb89adb7d5dfdd4c3eeba763811c, head tracking 113ec0526354587c047183f7f6c1d90ee06d1286b0e848a168b38ad131c05096. archiveProblem returns null for both, with Android's empty answer simulated.

Not done

  • Not tested on the tablet yet. The hub session will run it on the NP02J.
  • v1 (JAR) signatures are not verified by the new reader. For those it falls back to Android. The vendor services are v2.

🤖 Generated with Claude Code

On the NP02J/K68 (Android 13) 0.4.0 downloaded the pinned display services
(size + sha256 OK) and then refused device-service: "Android could not read
the signing certificate". Both service APKs are signed with APK Signature
Scheme v2 only, and their certificates are the pinned ones.

Root cause is the platform: in android13-release,
PackageManager.getPackageArchiveInfo() calls collectCertificates() only when
the flags contain the deprecated GET_SIGNATURES. GET_SIGNING_CERTIFICATES alone
leaves SigningDetails.UNKNOWN and generatePackageInfo() returns
signingInfo = null. Android 12 (ApplicationPackageManager) and 13 QPR1+ test
either flag, which is why this only shows on some framework builds.

Fix:
- ApkSignatureReader: a pure-Kotlin v2/v3/v3.1 verifier. Finds the EOCD, the
  APK Signing Block, picks the scheme the platform would use for the SDK
  (v3.1 -> v3 -> v2), verifies the signer's signature over its signed data,
  that the public key is the certificate's, and the whole-file content digest;
  returns the SHA-256 of each signer certificate.
- archiveIdentity also asks Android with GET_SIGNATURES | GET_SIGNING_CERTIFICATES
  and DisplayServices.combineSigners() cross-checks the two: a block that does
  not verify, or a disagreement, is refused; v1-only falls back to Android.
  archiveProblem still requires exactly the pinned (== manifest) signer.
- Tests sign synthetic APKs at test time with apksig + bouncycastle (v1-only,
  v2-only, ECDSA, v2+v3, rotation via v3 and v3.1, two signers, tampered
  contents / central directory / certificate, truncated, unsigned). Every
  guard was mutation-checked. The real vendor APKs are checked when
  DXR_SERVICE_APK_DIR is set locally (never committed).
- installer 0.4.1 (versionCode 6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dfattal

dfattal commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Device-verified 2026-09-30 on NP02J (K68, Android 13 / API 33) from FACTORY display services (0.8.29 / 0.8.20), using a local debug build of this branch:

  • One run: device-service + headTracking downloaded from updates.displayxr.org, certificate check passed, installed as built-in-app updates; runtime v2.21.13, 5 demos and browser 1.0.10 installed → "9 installed … Then REBOOT".
  • After reboot: audit core sha == bundle 0.10.69 (05e36387…), loader == services; prove-3d PASS on all 5 demos with #206 horizon published; 0 unknown API: leia_core_* and 0 sink ABSENT (stock core) lines (both present on factory services, where content was 2D / parallax inverted / freeze).
  • Browser: after tapping through Chrome's first-run screens ("Stay signed out", "No thanks") the samples page weaves (woven AHB adopted + imported). prove-browser-resume.sh fails on this path only because the installer can't write the adb command-line file that skips FRE — harness, not product.
  • UX note: after each service update Android's "App installed" screen stayed >8 s (tapped DONE twice) — the documented fallback; the demos/runtime did not need it.
  • 0.4.0 on the same device refused device-service with "could not read the signing certificate" — this PR is the fix.

🤖 Generated with Claude Code

@dfattal
dfattal merged commit be156d3 into main Sep 30, 2026
4 checks passed
@dfattal
dfattal deleted the fix/android-installer-v2-cert-read branch September 30, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant