diff --git a/uraniborg/AndroidStudioProject/Hubble/app/build.gradle b/uraniborg/AndroidStudioProject/Hubble/app/build.gradle index 1622f96..00b61dc 100644 --- a/uraniborg/AndroidStudioProject/Hubble/app/build.gradle +++ b/uraniborg/AndroidStudioProject/Hubble/app/build.gradle @@ -6,8 +6,8 @@ android { applicationId "com.uraniborg.hubble" minSdkVersion 23 targetSdkVersion 35 - versionCode 11 - versionName "2.1.0" + versionCode 12 + versionName "2.2.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { diff --git a/uraniborg/AndroidStudioProject/Hubble/app/src/main/java/com/uraniborg/hubble/MainActivity.java b/uraniborg/AndroidStudioProject/Hubble/app/src/main/java/com/uraniborg/hubble/MainActivity.java index 7da29ce..7acce67 100644 --- a/uraniborg/AndroidStudioProject/Hubble/app/src/main/java/com/uraniborg/hubble/MainActivity.java +++ b/uraniborg/AndroidStudioProject/Hubble/app/src/main/java/com/uraniborg/hubble/MainActivity.java @@ -49,7 +49,7 @@ public class MainActivity extends AppCompatActivity { // semantically tie the notion of app version to versionName, which we will update for every // major and minor release. Unfortunately, for now, we have to independently and separately // update these values everytime we do any revisions because BuildConfig is phased out. - private final String VERSION = "2.1.0"; + private final String VERSION = "2.2.0"; // We're changing to TreeMap so that package names are sorted. This would ease output comparison. private TreeMap mAllPackages; @@ -128,6 +128,106 @@ private void getInstalledPackagesInformation() { Log.d(tag, String.format("There are %d packages (including APEX)", mAllPackages.size())); } + // The Android framework ("platform") package. Its signing identity is what defines a + // "platform-signed" package and gates access to the system shared UIDs. + static final String PLATFORM_PACKAGE_NAME = "android"; + + /** + * Returns {@link PackageManager#checkSignatures(String, String)}'s verdict comparing + * {@code pkgName} against the platform ({@code android}) package. + * + *

Recorded verbatim as an observation. Per AOSP + * {@code ComputerEngine#checkSignaturesInternal} and + * {@code PackageManagerServiceUtils#compareSignatures}, the algorithm is: + * + *

    + *
  1. Compare the two packages' current signer sets for exact set equality.
  2. + *
  3. If that fails and either side has a signing lineage, retry using only the + * oldest ancestor of each ({@code getPastSigningCertificates()[0]}) - an + * explicit backwards-compatibility path for callers predating key rotation.
  4. + *
+ * + *

IMPORTANT: this is not a capability-aware trust decision, and it is not a + * sound oracle for "is this platform-signed?". It never consults the per-ancestor + * {@code SigningDetails.CertCapabilities} flags ({@code PERMISSION}, + * {@code SHARED_USER_ID}); those are evaluated elsewhere in the framework (shared-UID join + * logic and the permission subsystem) and are not reachable from any public API. Two known + * divergences follow directly from the algorithm above: + * + *

+ * + *

Consumers should therefore treat this as descriptive metadata (what + * {@code PackageManager} itself would report to an app), not as the basis for platform + * trust. See {@code HubbleParser.is_platform_signed()}. + * + * @param pkgName the package to compare against the platform package. + * @return one of {@code MATCH}, {@code NO_MATCH}, {@code NEITHER_SIGNED}, + * {@code FIRST_NOT_SIGNED}, {@code SECOND_NOT_SIGNED}, {@code UNKNOWN_PACKAGE}, or + * {@code UNKNOWN} if the query itself failed. + */ + @NotNull + private String getPlatformSignatureMatch(@NotNull String pkgName) { + final String tag = TAG + "-CERT"; + try { + int result = mPackageManager.checkSignatures(pkgName, PLATFORM_PACKAGE_NAME); + switch (result) { + case PackageManager.SIGNATURE_MATCH: + return "MATCH"; + case PackageManager.SIGNATURE_NO_MATCH: + return "NO_MATCH"; + case PackageManager.SIGNATURE_NEITHER_SIGNED: + return "NEITHER_SIGNED"; + case PackageManager.SIGNATURE_FIRST_NOT_SIGNED: + return "FIRST_NOT_SIGNED"; + case PackageManager.SIGNATURE_SECOND_NOT_SIGNED: + return "SECOND_NOT_SIGNED"; + case PackageManager.SIGNATURE_UNKNOWN_PACKAGE: + // Expected for entries (e.g. some APEXes) that PackageManager does not track as a + // signature-comparable package. Consumers should fall back to digest comparison. + return "UNKNOWN_PACKAGE"; + default: + Log.e(tag, String.format("Unexpected checkSignatures result %d for package: %s", result, + pkgName)); + return "UNKNOWN"; + } + } catch (RuntimeException e) { + Log.e(tag, String.format("Failed to checkSignatures against platform for package %s: %s", + pkgName, e.getMessage())); + return "UNKNOWN"; + } + } + + @NotNull + private JSONArray extractAndRegisterCertificates(@NotNull String pkgName, + @Nullable Signature[] signatures) { + final String tag = TAG + "-CERT"; + JSONArray digests = new JSONArray(); + if (signatures == null) { + return digests; + } + for (Signature signature : signatures) { + if (signature == null) { + continue; + } + String encodedSignatureDigest = Utilities.computeSHA256DigestOfCertificate(signature); + if (encodedSignatureDigest == null) { + Log.e(tag, String.format("Failed to compute hash for cert of package: %s", pkgName)); + continue; + } + if (!mAllCertificates.containsKey(encodedSignatureDigest)) { + mAllCertificates.put(encodedSignatureDigest, signature.toByteArray()); + } + digests.put(encodedSignatureDigest); + } + return digests; + } + @SuppressWarnings("deprecation") private void getAllCertificates() { final String tag = TAG + "-CERT"; @@ -138,35 +238,82 @@ private void getAllCertificates() { continue; } PackageInfo pkgInfo = pkgMetadata.ref; - Signature[] signatures; + JSONObject signingInfoJson = new JSONObject(); + // Recorded verbatim as an observation; see getPlatformSignatureMatch(). + String platformSignatureMatch = getPlatformSignatureMatch(pkgName); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - signatures = pkgInfo.signatures; + Signature[] signatures = pkgInfo.signatures; + if (signatures == null) { + Log.e(tag, String.format("Failed to grab signature for package: %s", pkgName)); + continue; + } + // Count the declared signers BEFORE computing digests: a single + // computeSHA256DigestOfCertificate() failure drops an entry, and must not silently + // demote a co-signed APK to a single-signer one. + int declaredSignerCount = 0; + for (Signature signature : signatures) { + if (signature != null) { + declaredSignerCount++; + } + } + JSONArray activeSigners = extractAndRegisterCertificates(pkgName, signatures); + pkgMetadata.certIds = activeSigners; + try { + signingInfoJson.put("hasMultipleSigners", declaredSignerCount > 1); + // NOTE: pre-P PackageManager exposes no v3 lineage API at all, so rotation is + // UNOBSERVABLE here rather than known to be absent. Emit null (not false) and no + // lineage, so consumers classify these as UNKNOWN instead of asserting "never + // rotated". See docs/hubble_results.md. + signingInfoJson.put("hasPastSigningCertificates", JSONObject.NULL); + signingInfoJson.put("apkContentsSigners", activeSigners); + signingInfoJson.put("signingCertificateLineage", new JSONArray()); + signingInfoJson.put("platformSignatureMatch", platformSignatureMatch); + pkgMetadata.signingInfo = signingInfoJson; + } catch (JSONException e) { + Log.e(tag, String.format("Failed to build signingInfo JSON for package %s: %s", + pkgName, e.getMessage())); + } } else { SigningInfo signingInfo = pkgInfo.signingInfo; - if (signingInfo.hasMultipleSigners()) { - signatures = signingInfo.getApkContentsSigners(); - } else { - signatures = signingInfo.getSigningCertificateHistory(); + if (signingInfo == null) { + Log.e(tag, String.format("Failed to grab signingInfo for package: %s", pkgName)); + continue; } - } - if (signatures == null) { - Log.e(tag, String.format("Failed to grab signature for package: %s", pkgName)); - continue; - } - - JSONArray signaturesJSONArray = new JSONArray(); - for (Signature signature : signatures) { - String encodedSignatureDigest = Utilities.computeSHA256DigestOfCertificate(signature); - if (encodedSignatureDigest == null) { - Log.e(tag, String.format("Failed to compute hash for cert of package: %s", pkgName)); + boolean hasMultipleSigners = signingInfo.hasMultipleSigners(); + boolean hasPastSigningCertificates = signingInfo.hasPastSigningCertificates(); + Signature[] activeSignatures = signingInfo.getApkContentsSigners(); + Signature[] lineageSignatures = + hasMultipleSigners ? null : signingInfo.getSigningCertificateHistory(); + + if (activeSignatures == null && lineageSignatures == null) { + Log.e(tag, String.format("Failed to grab signature for package: %s", pkgName)); continue; } - if (!mAllCertificates.containsKey(encodedSignatureDigest)) { - mAllCertificates.put(encodedSignatureDigest, signature.toByteArray()); + + JSONArray apkContentsSigners = extractAndRegisterCertificates(pkgName, activeSignatures); + JSONArray signingCertificateLineage = + extractAndRegisterCertificates(pkgName, lineageSignatures); + + if (hasMultipleSigners) { + pkgMetadata.certIds = apkContentsSigners; + } else { + pkgMetadata.certIds = (signingCertificateLineage.length() > 0) + ? signingCertificateLineage : apkContentsSigners; + } + + try { + signingInfoJson.put("hasMultipleSigners", hasMultipleSigners); + signingInfoJson.put("hasPastSigningCertificates", hasPastSigningCertificates); + signingInfoJson.put("apkContentsSigners", apkContentsSigners); + signingInfoJson.put("signingCertificateLineage", signingCertificateLineage); + signingInfoJson.put("platformSignatureMatch", platformSignatureMatch); + pkgMetadata.signingInfo = signingInfoJson; + } catch (JSONException e) { + Log.e(tag, String.format("Failed to build signingInfo JSON for package %s: %s", + pkgName, e.getMessage())); } - signaturesJSONArray.put(encodedSignatureDigest); } - pkgMetadata.certIds = signaturesJSONArray; } } diff --git a/uraniborg/AndroidStudioProject/Hubble/app/src/main/java/com/uraniborg/hubble/PackageMetadata.java b/uraniborg/AndroidStudioProject/Hubble/app/src/main/java/com/uraniborg/hubble/PackageMetadata.java index eb80c6e..da552dc 100644 --- a/uraniborg/AndroidStudioProject/Hubble/app/src/main/java/com/uraniborg/hubble/PackageMetadata.java +++ b/uraniborg/AndroidStudioProject/Hubble/app/src/main/java/com/uraniborg/hubble/PackageMetadata.java @@ -49,7 +49,8 @@ public class PackageMetadata extends BaseInfo { protected CharSequence description = null; protected int versionCode; protected String versionName; - protected JSONArray certIds; + protected JSONArray certIds = new JSONArray(); + protected JSONObject signingInfo = null; protected boolean isEnabled = false; protected boolean isTestOnly = false; protected boolean isFactoryTest = false; diff --git a/uraniborg/README.md b/uraniborg/README.md index eaba3d9..bb32deb 100644 --- a/uraniborg/README.md +++ b/uraniborg/README.md @@ -37,7 +37,9 @@ Unit tests for the Python automation and verification scripts are located in parsing `preinstalled_packages.txt`, 6-state package classification (`FACTORY_PREINSTALLED_APK`, `FACTORY_PREINSTALLED_APEX`, `UPDATED_SYSTEM_APP`, `UPDATED_MAINLINE_MODULE`, `USER_INSTALLED`, - `UNKNOWN`), and package query/filtering methods. + `UNKNOWN`), signing certificate lineage vs. co-signing classification + (`SINGLE_SIGNER`, `KEY_ROTATION_LINEAGE`, `MULTIPLE_SIGNERS`), and package + query/filtering methods. - `test_inclusion_proof_check.py`: Tests pre-fetching transparency log entries (`--cache_prefetch_concurrency`, `--cache_prefetch_timeout`, `--cache_dir`), opt-out (`--no_prefetch`), fail-open fallback on pre-fetch errors/timeouts, @@ -70,6 +72,11 @@ build.gradle file of the Hubble app. > intentionally break compatibility; output files produced by Hubble `1.0.0` > (or any version `< 2.1.0`) and higher major versions (`>= 3.0.0`) are **not > supported**. +> +> Minor versions within `2.x` are **additive** and are read on a best-effort +> basis, so previously collected corpora stay readable. For example `2.2.0` +> adds the `signingInfo` object; when reading `2.1.0` output the signing-mode +> helpers report `UNKNOWN` instead of rejecting the observation. diff --git a/uraniborg/VERSION b/uraniborg/VERSION index 7ec1d6d..ccbccc3 100644 --- a/uraniborg/VERSION +++ b/uraniborg/VERSION @@ -1 +1 @@ -2.1.0 +2.2.0 diff --git a/uraniborg/docs/hubble_results.md b/uraniborg/docs/hubble_results.md index 7304a90..e518a2e 100644 --- a/uraniborg/docs/hubble_results.md +++ b/uraniborg/docs/hubble_results.md @@ -16,6 +16,13 @@ errors that leads to truncation of file, for example. > `preinstalled_packages.txt`, `isUpdatedSystemApp`, and `isApex`). Major > version bumps break compatibility; output from Hubble `1.0.0` (or any version > `< 2.1.0`) and higher major versions (`>= 3.0.0`) is **not supported**. +> +> Minor versions within `2.x` are **additive** and never raise the supported +> floor, so previously collected corpora remain readable and comparable over +> time. Schema `2.2.0` adds the structured `signingInfo` object (lineage vs. +> co-signer metadata) while leaving `certIds` unchanged; when reading `2.1.0` +> output, signing-mode helpers report `UNKNOWN` rather than rejecting the +> observation. @@ -94,7 +101,10 @@ This file enumerates all installed packages on the system at the time of observation. - activities: A list of `activity`s that the package contains (can be empty). -- certIds: SHA256 digest of certificate(s) used to sign this package. +- certIds: SHA256 digest(s) of certificate(s) associated with this package (for + co-signed packages, the active co-signers; for single-signer packages, the + signing certificate lineage or single signer). Use `signingInfo` below to + disambiguate active signers from key rotation lineage. - description: The description of the application (if available). - firstInstallTime: The recorded time (in ms) of the first install time of this package. @@ -137,6 +147,12 @@ package at the time of observation. - sharedUserId: a string representing the [shared user ID](https://developer.android.com/reference/android/content/pm/PackageInfo.html#sharedUserId) of this package. - sharedUserLabel: an integer representing the [shared user ID label](https://developer.android.com/reference/android/content/pm/PackageInfo.html#sharedUserLabel) of this package. +- signingInfo: *(added in schema `2.2.0`; absent in `2.1.0` output)* A nested JSON object capturing structured [`SigningInfo`](https://developer.android.com/reference/android/content/pm/SigningInfo) metadata so that APK Signature Scheme v3 key rotation lineages are cleanly distinguished from multi-signer (co-signed) APKs: + - `hasMultipleSigners`: A boolean indicating whether the package is simultaneously co-signed by multiple active signers (`SigningInfo.hasMultipleSigners()`). On API < 28 this is derived from the length of the deprecated `PackageInfo.signatures` array, counted *before* digest computation so that a failed digest cannot silently demote a co-signed APK. + - `hasPastSigningCertificates`: A boolean indicating whether the package has rotated its signing key and includes past ancestor signing certificates in its v3 lineage (`SigningInfo.hasPastSigningCertificates()`). **Tri-state:** on API < 28 the platform exposes no v3 lineage API at all, so this is `null` rather than `false` — rotation is *unobservable* there, not known to be absent. Consumers must not read `null` as "never rotated"; `HubbleParser.classify_package_signing()` reports `UNKNOWN` for it. + - `apkContentsSigners`: A list of SHA256 certificate digests for the **currently active** signer(s) (`SigningInfo.getApkContentsSigners()`). For single-signer packages (with or without key rotation), this contains exactly 1 element (the current active signer). For co-signed packages (`hasMultipleSigners == true`), this contains all active co-signers. + - `signingCertificateLineage`: A list of SHA256 certificate digests representing the ordered signing certificate lineage (`SigningInfo.getSigningCertificateHistory()`) from the **oldest (original) ancestor signing certificate at index `0`** to the **current active signing certificate at index `-1`**. When `hasMultipleSigners == true`, this is an empty array `[]` (as Android does not support v3 key rotation for multi-signer APKs). It is also `[]` on API < 28, where no lineage is observable — Hubble deliberately does not fabricate one from the active signers. + - `platformSignatureMatch`: The verdict from [`PackageManager.checkSignatures(pkgName, "android")`](https://developer.android.com/reference/android/content/pm/PackageManager#checkSignatures(java.lang.String,%20java.lang.String)) — one of `MATCH`, `NO_MATCH`, `NEITHER_SIGNED`, `FIRST_NOT_SIGNED`, `SECOND_NOT_SIGNED`, `UNKNOWN_PACKAGE`, or `UNKNOWN`. Recorded verbatim as an **observation of what `PackageManager` reports to apps**. Per AOSP `ComputerEngine.checkSignaturesInternal()`, it (1) compares the two packages' **current** signer sets for exact set equality, then (2) on failure, if either side has a lineage, retries using only the **oldest** ancestor of each — an explicit backwards-compatibility path for callers predating key rotation. It does **not** consult `CertCapabilities`. See the caution below for why this is *not* used to determine platform signing. `UNKNOWN_PACKAGE` is expected for entries (e.g. `com.android.privatespace`) that `PackageManager` does not resolve as a signature-comparable package. - splitNames: any names of installed [split APKs](https://developer.android.com/reference/android/content/pm/PackageInfo#splitNames) of this package. - usesCleartextTraffic: a boolean [flag](https://developer.android.com/reference/android/content/pm/ApplicationInfo.html#FLAG_USES_CLEARTEXT_TRAFFIC)indicating whether or not this @@ -255,3 +271,83 @@ inspection of `installLocation` as described below: > | `/data/apex/active/*@*.decompressed.apex`, `/data/apex/decompressed/*@*.decompressed.apex` | **Factory Pre-installed** (compressed CAPEX decompressed at boot, not an update) | > | `/data/apex/active/*@*.apex` (ending in `.apex`, **not** `.decompressed.apex`) | **Updated Mainline Module** (post-setup OTA update via Play / Mainline; hash reflects updated binary) | > | Any other path (or `isPreinstalled == false`) | **Unknown (`UNKNOWN`)** (unrecognized OEM APEX layout; emits a warning instead of assuming updated) | + +### Determining Package Signing Certificate Lineage vs. Co-Signing +In Android (API 28+ / APK Signature Scheme v3), an APK with multiple associated +certificates in `certIds` can represent two completely different cryptographic +configurations: + +1. **Key Rotation Lineage (`KEY_ROTATION_LINEAGE`):** The package has a single + active signer, but has rotated its signing key from one or more historical + ancestor certificates (`signingInfo.hasMultipleSigners == false`, + `signingInfo.hasPastSigningCertificates == true`). +2. **Co-Signed by Multiple Active Signers (`MULTIPLE_SIGNERS`):** The package + is simultaneously co-signed by two or more active certificates + (`signingInfo.hasMultipleSigners == true`, + `signingInfo.hasPastSigningCertificates == false`). Android does not support + v3 key rotation for multi-signer APKs. + +Use the nested `signingInfo` object (or `HubbleParser.classify_package_signing()`, +`get_active_signers()`, `get_signing_lineage()`, and +`get_past_signing_certificates()`) to distinguish these cases: + +| Signing Mode (`HubbleParser`) | `signingInfo.hasMultipleSigners` | `signingInfo.hasPastSigningCertificates` | `signingInfo.apkContentsSigners` | `signingInfo.signingCertificateLineage` | Meaning | +| :--- | :---: | :---: | :--- | :--- | :--- | +| **`SINGLE_SIGNER`** | `false` | `false` | `[cert_current]` (len `1`) | `[cert_current]` (len `1`) | Signed by a single certificate with no key rotation history | +| **`KEY_ROTATION_LINEAGE`** | `false` | `true` | `[cert_current]` (len `1`) | `[cert_oldest, ..., cert_current]` (len `>= 2`) | Single active signer (`cert_current`) with ordered v3 key rotation history from `cert_oldest` | +| **`MULTIPLE_SIGNERS`** | `true` | `false` or `null` | `[cert_1, cert_2, ...]` (len `>= 2`) | `[]` (empty) | Simultaneously co-signed by all listed certificates in `apkContentsSigners`. Reported affirmatively even on API < 28, where the signer count is observable. | +| **`UNKNOWN`** (legacy schema) | — | — | — | — | No `signingInfo` object at all (legacy schema `2.1.0` output), or malformed metadata. The flat `certIds` list cannot distinguish a lineage from co-signers, so the mode is never guessed. Use `HubbleParser.has_structured_signing_info()` to detect this. | +| **`UNKNOWN`** (API < 28) | `false` | `null` | `[cert_current]` (len `1`) | `[]` (empty) | Collected on a pre-P device, where PackageManager exposes no v3 lineage API. The package may or may not have rotated its key; Hubble reports `null` instead of asserting `SINGLE_SIGNER`. | + +> [!IMPORTANT] +> **Lineage Ordering (`certIds[0]` vs. `apkContentsSigners[0]`):** +> When `signingInfo.hasPastSigningCertificates == true`, Android's +> `getSigningCertificateHistory()` orders certificates from the **oldest +> (retired) ancestor at index `0`** to the **current active signer at the last +> index (`[-1]`)**. Always read `signingInfo.apkContentsSigners` (or call +> `HubbleParser.get_active_signers(pkg)`) to obtain the currently active +> signing certificate(s) rather than indexing `certIds[0]`. + +> [!CAUTION] +> **Platform-Package Matching is Directional.** +> Platform-package and shared-UID matching in `HubbleParser` +> (`get_platform_packages()`, `print_platform_packages()`, and +> `get_shared_uid_packages()`) all funnel through +> `HubbleParser.is_platform_signed(pkg)`, which compares the package's +> **active** signer(s) against the platform's **full** lineage +> (`get_platform_signatures()`). +> +> **Never intersect two full certificate sets.** A package that has rotated +> *away* from the platform key still carries that key in its own lineage, so a +> symmetric intersection would keep treating it as platform-signed forever. Only +> the platform side may use the full lineage (as the trust anchor); the candidate +> side must use active signers only. +> +> **`platformSignatureMatch` is deliberately NOT used for this decision.** +> `PackageManager.checkSignatures()` is a legacy, pre-rotation-compatible API, +> not a capability-aware trust check. It compares current signer sets for exact +> equality, then retries with only the oldest ancestor of each lineage, and never +> calls `SigningDetails.checkCapability()`. That makes it unsound in both +> directions here: +> +> | Scenario | `checkSignatures` | `is_platform_signed()` | Why they differ | +> | :--- | :---: | :---: | :--- | +> | Package rotated **away** from the platform key | `MATCH` | `false` | Oldest-ancestor retry compares the *retired* platform cert | +> | Package co-signed by platform key **+** another key | `NO_MATCH` | `true` | Exact set equality fails, but the platform key is an active signer | +> +> Use `HubbleParser.get_platform_signature_match(pkg)` when you specifically want +> the `PackageManager` verdict (e.g. to reason about legacy callers of that API). + +> [!WARNING] +> **Signing-lineage capability flags are not observable at all.** +> When a key is rotated, each ancestor node carries +> `SigningDetails.CertCapabilities` flags (`PERMISSION`, `SHARED_USER_ID`, +> `INSTALLED_DATA`, `ROLLBACK`, `AUTH`) which the rotation may **revoke**. The +> framework honours these in its shared-UID join logic and permission subsystem, +> but they are not reachable from any public API — not via `SigningInfo`, and not +> via `checkSignatures()`. Hubble therefore cannot record them. +> +> Consequently a retired platform certificate whose capabilities were revoked is +> indistinguishable from one that retains them, and `is_platform_signed()` may +> **over-approximate** platform trust for un-rotated system packages still signed +> with a retired platform certificate. diff --git a/uraniborg/scripts/python/hubble_parser.py b/uraniborg/scripts/python/hubble_parser.py index a8b5ae5..a084b17 100644 --- a/uraniborg/scripts/python/hubble_parser.py +++ b/uraniborg/scripts/python/hubble_parser.py @@ -22,6 +22,12 @@ IMPORTANT: Backwards compatibility with Hubble 1.0.0 (or any version earlier than 2.1.0) is intentionally NOT supported. Major version bumps break backwards compatibility by design. + +Within the supported 2.x range, minor versions are additive and are read on a +best-effort basis. In particular, the `signingInfo` object introduced in 2.2.0 +is absent from 2.1.0 output; helpers that depend on it degrade gracefully (see +`has_structured_signing_info`) rather than rejecting the observation, so that +previously collected 2.1.0 corpora remain readable and comparable over time. """ import base64 @@ -40,11 +46,22 @@ class HubbleParser: NOTE: Requires Hubble output schema 2.x (>= 2.1.0). Output from Hubble 1.0.0 (or any version < 2.1.0) and higher major versions (>= 3.0.0) is NOT supported. + Schema 2.2.0 adds the `signingInfo` object; 2.1.0 output remains supported and + is handled via documented legacy fallbacks. """ # Encodes the supported major version and minimum minor version (schema 2.x, >= 2.1.0). # Compatibility with < 2.1.0 (including 1.0.0) and >= 3.0.0 is NOT supported. + # + # NOTE: Deliberately NOT bumped to 2.2.0. The 2.2.0 `signingInfo` object is purely + # additive and leaves `certIds` byte-for-byte unchanged, so raising this floor would + # invalidate every previously collected 2.1.0 observation for no correctness benefit. + # Only raise this for a genuinely breaking schema change. EXPECTED_VERSION = "2.1.0" + # The minor version that introduced the structured `signingInfo` object. Used only to + # document/describe capability, never to reject an observation. + SIGNING_INFO_MIN_VERSION = "2.2.0" + # These are core files and their corresponding filenames that Hubble outputs, # which may expand in the future. # NOTE: It is imperative that for maintenance purposes, the key name does @@ -65,6 +82,14 @@ class HubbleParser: PACKAGE_STATE_USER_INSTALLED = "USER_INSTALLED" PACKAGE_STATE_UNKNOWN = "UNKNOWN" + SIGNING_MODE_SINGLE_SIGNER = "SINGLE_SIGNER" + SIGNING_MODE_KEY_ROTATION_LINEAGE = "KEY_ROTATION_LINEAGE" + SIGNING_MODE_MULTIPLE_SIGNERS = "MULTIPLE_SIGNERS" + SIGNING_MODE_UNKNOWN = "UNKNOWN" + + # The Android framework package, whose signing identity defines "platform-signed". + PLATFORM_PACKAGE_NAME = "android" + # TODO: Remove SYSTEM_SHARED_UID_SET along with legacy baseline/risk-scoring # categorization helpers. SYSTEM_SHARED_UID_SET = set([ @@ -95,6 +120,199 @@ def check_version(version_str): except (ValueError, AttributeError): return False + @staticmethod + def has_structured_signing_info(package): + """Returns True if the package carries the structured `signingInfo` object. + + The `signingInfo` object was introduced in Hubble schema 2.2.0. Observations + collected with 2.1.0 do not have it, and for those the flat `certIds` list is + inherently ambiguous: it holds either a key rotation lineage OR a set of + active co-signers, with no way to tell which. Callers that need to reason + about signing semantics should check this first and treat a False result as + "unknown" rather than assuming a single signer. + + Args: + package: A dict representing a package entry from packages.txt. + + Returns: + True if structured signing metadata is present, False for legacy output. + """ + return (isinstance(package, dict) and + isinstance(package.get("signingInfo"), dict)) + + @staticmethod + def get_active_signers(package): + """Returns the currently active signing certificate digest(s) for a package. + + For single-signer packages (with or without a key rotation lineage), this + returns a 1-element list containing the current active signer. For co-signed + packages (hasMultipleSigners=True), this returns all active co-signers. + + LEGACY (schema < 2.2.0): when `signingInfo` is absent, this falls back to the + flat `certIds` list. That list is ambiguous - for a rotated package it is the + full lineage (oldest first), not just the active signer - so the result is an + over-approximation. Use `has_structured_signing_info()` to detect this case. + + Args: + package: A dict representing a package entry from packages.txt. + + Returns: + A list of SHA-256 hex digest strings for the active signer(s). + """ + if not isinstance(package, dict): + return [] + signing_info = package.get("signingInfo") + if isinstance(signing_info, dict): + active = signing_info.get("apkContentsSigners") + if isinstance(active, list) and active: + return list(active) + # apkContentsSigners should always be populated in 2.2.0+, but if every + # digest failed to compute on device, recover the active signer from the + # tail of the lineage (Android orders it oldest -> current). + if not signing_info.get("hasMultipleSigners", False): + lineage = signing_info.get("signingCertificateLineage") + if isinstance(lineage, list) and lineage: + return [lineage[-1]] + return [] + cert_ids = package.get("certIds") + if isinstance(cert_ids, list): + return list(cert_ids) + return [] + + @staticmethod + def get_signing_lineage(package): + """Returns the ordered signing certificate lineage for a package. + + When a package is not co-signed (hasMultipleSigners=False), returns the + certificate digests ordered from oldest ancestor at index 0 to the current + active signer at index -1. When a package is co-signed by multiple signers + (hasMultipleSigners=True), returns an empty list because multi-signer APKs + do not have a signing certificate rotation lineage. + + LEGACY (schema < 2.2.0): returns [] because pre-2.2.0 output cannot express a + lineage unambiguously. This is deliberately NOT backfilled from `certIds`, so + that "no lineage" is never confused with "lineage unknown". + + API < 28: also returns [], because the platform exposes no v3 lineage API. + Hubble deliberately does not fabricate one from the active signers; use + `classify_package_signing()`, which reports UNKNOWN for that case. + + Args: + package: A dict representing a package entry from packages.txt. + + Returns: + A list of SHA-256 hex digest strings ordered [oldest_ancestor, ..., current_signer], + or [] if co-signed or unavailable. + """ + if not HubbleParser.has_structured_signing_info(package): + return [] + signing_info = package["signingInfo"] + if signing_info.get("hasMultipleSigners", False): + return [] + lineage = signing_info.get("signingCertificateLineage") + if isinstance(lineage, list): + return list(lineage) + return [] + + @staticmethod + def get_past_signing_certificates(package): + """Returns historical ancestor signing certificates excluding the active signer. + + Args: + package: A dict representing a package entry from packages.txt. + + Returns: + A list of retired/past ancestor SHA-256 certificate digests ordered from + oldest ancestor to most recent predecessor, or [] if the package has not + undergone key rotation or is co-signed. + """ + lineage = HubbleParser.get_signing_lineage(package) + if len(lineage) > 1: + return lineage[:-1] + return [] + + @staticmethod + def classify_package_signing(package, logger=None): + """Classifies a package's signing configuration (lineage vs. co-signing). + + Distinguishes between: + 1. SINGLE_SIGNER: Signed by a single certificate with no key rotation history + (hasMultipleSigners=False, hasPastSigningCertificates=False). + 2. KEY_ROTATION_LINEAGE: Signed by a single active certificate with one or + more past ancestor certificates in its v3 signing lineage + (hasMultipleSigners=False, hasPastSigningCertificates=True). + 3. MULTIPLE_SIGNERS: Co-signed simultaneously by multiple active signers + (hasMultipleSigners=True). + 4. UNKNOWN: The signing configuration cannot be determined. This covers: + (a) every package in a legacy (schema < 2.2.0) observation, where + `signingInfo` does not exist and `certIds` alone cannot distinguish a + rotation lineage from a set of co-signers; (b) a package collected on + API < 28, where PackageManager exposes no v3 lineage API and Hubble + therefore emits `hasPastSigningCertificates: null` - "not rotated" is + unobservable there, not false; (c) missing or malformed metadata. + Co-signing is still reported affirmatively on API < 28, since the + signer count itself is observable. + + Args: + package: A dict representing a package entry from packages.txt. + logger: Optional logging.Logger to emit warnings on missing signingInfo. + + Returns: + One of the SIGNING_MODE_* string constants. + """ + if not isinstance(package, dict): + return HubbleParser.SIGNING_MODE_UNKNOWN + + if not HubbleParser.has_structured_signing_info(package): + if logger: + logger.debug( + "Package %s has no structured signingInfo (legacy Hubble output " + "< %s); classifying signing mode as UNKNOWN", + package.get("name", ""), + HubbleParser.SIGNING_INFO_MIN_VERSION) + return HubbleParser.SIGNING_MODE_UNKNOWN + signing_info = package["signingInfo"] + + has_multiple = bool(signing_info.get("hasMultipleSigners", False)) + # Tri-state: Hubble emits null on API < 28, where PackageManager exposes no + # v3 lineage API and "not rotated" is therefore unobservable, not false. + has_past_raw = signing_info.get("hasPastSigningCertificates") + rotation_state_known = isinstance(has_past_raw, bool) + has_past = has_past_raw is True + active_signers = signing_info.get("apkContentsSigners") + if not isinstance(active_signers, list): + active_signers = [] + lineage = HubbleParser.get_signing_lineage(package) + + if not active_signers and not lineage: + if logger: + logger.warning( + "Package %s has empty signingInfo certificates; " + "classifying signing mode as UNKNOWN", + package.get("name", "")) + return HubbleParser.SIGNING_MODE_UNKNOWN + + # Checked first: a signer count > 1 is directly observable on every API + # level, so co-signing is affirmative even when rotation state is not. + if has_multiple or len(active_signers) > 1: + return HubbleParser.SIGNING_MODE_MULTIPLE_SIGNERS + + if has_past or len(lineage) > 1: + return HubbleParser.SIGNING_MODE_KEY_ROTATION_LINEAGE + + if not rotation_state_known: + if logger: + logger.debug( + "Package %s has an unobservable key rotation state (API < 28); " + "classifying signing mode as UNKNOWN", + package.get("name", "")) + return HubbleParser.SIGNING_MODE_UNKNOWN + + if len(active_signers) == 1 or len(lineage) == 1: + return HubbleParser.SIGNING_MODE_SINGLE_SIGNER + + return HubbleParser.SIGNING_MODE_UNKNOWN + @staticmethod def classify_package(package, logger=None): """Classifies an installed package into a distinct installation state. @@ -167,26 +385,64 @@ def classify_package(package, logger=None): else: return HubbleParser.PACKAGE_STATE_USER_INSTALLED + @staticmethod + def get_package_certificate_set(package): + """Returns every signing certificate ever associated with a package. + + This is the union of the package's rotation lineage, its active signer(s), + and the flat `certIds` list, i.e. both current AND retired certificates. + + WARNING: This set is only meaningful as the *trust anchor* side of a + comparison (e.g. "the set of certificates that identify the platform"). Do + NOT use it for the candidate package side: a package that has rotated AWAY + from a trusted key still contains that key in its lineage, so intersecting + two full certificate sets would treat it as still trusted. For the candidate + side use `get_active_signers()`; see `is_platform_signed()`. + """ + if not isinstance(package, dict): + return set() + certs = ( + set(HubbleParser.get_signing_lineage(package)) + | set(HubbleParser.get_active_signers(package)) + ) + cert_ids = package.get("certIds") + if isinstance(cert_ids, list): + certs.update(cert_ids) + return certs + def __init__(self, logger, normalize=False): - self.packages = [] + self._packages = [] self.preinstalled_packages = [] self.certificates = "" self.device_properties = "" self.build = "" self.hardware = "" # TODO: Remove legacy risk-scoring and baseline-categorization attributes - # (scorer, normalize, _platform_signature, _shared_uid_packages) as legacy - # categorization of Uraniborg results is no longer supported. + # (scorer, normalize, _platform_signature, _platform_signatures, + # _shared_uid_packages) as legacy categorization of Uraniborg results is no + # longer supported. self.scorer = None self.normalize = normalize self.logger = logger self._platform_signature = "" + self._platform_signatures = None self._shared_uid_packages = None self._output_version = None # do a bit of sanity check logger.debug("normalize: %s", self.normalize) + @property + def packages(self): + return self._packages + + @packages.setter + def packages(self, value): + self._packages = value + self._platform_signature = "" + self._platform_signatures = None + self._shared_uid_packages = None + def parse_hubble_json(self, packages, build, hardware, preinstalled_packages=None): """Consumes hubble output as json format. @@ -227,6 +483,9 @@ def parse_hubble_output(self, directory): """ logger = self.logger self._output_version = None + self._platform_signature = "" + self._platform_signatures = None + self._shared_uid_packages = None output_files = os.listdir(directory) if not output_files: logger.error("%s is empty!", directory) @@ -270,15 +529,15 @@ def get_api_level(self): return self.build["apiLevel"] # TODO: Remove legacy baseline/whitelist/scoring package categorization - # methods (get_shared_uid_packages, get_platform_signature, - # get_platform_packages, print_platform_packages, print_nocode_packages) as - # legacy categorization of Uraniborg results is no longer supported. + # methods (get_package_certificate_set, get_shared_uid_packages, + # get_platform_signatures, get_platform_signature, get_platform_packages, + # print_platform_packages, print_nocode_packages) as legacy categorization of + # Uraniborg results is no longer supported. def get_shared_uid_packages(self): if not self._shared_uid_packages: - platform_signature = self.get_platform_signature() self._shared_uid_packages = dict() for package in self.packages: - if platform_signature in package["certIds"]: + if self.is_platform_signed(package): package_shared_uid = package["sharedUserId"] if package_shared_uid is not None: other_packages = self._shared_uid_packages.get(package_shared_uid) @@ -290,12 +549,110 @@ def get_shared_uid_packages(self): return self._shared_uid_packages + def get_platform_signatures(self): + """Returns the platform's full signing identity (lineage union active signers). + + This is the trust-anchor set: every certificate the `android` framework + package has ever been signed by, so that a platform key rotation does not + orphan system packages still signed with a retired platform certificate. + """ + if self._platform_signatures is None: + self._platform_signatures = set() + for package in self.packages: + if package["name"] == HubbleParser.PLATFORM_PACKAGE_NAME: + self._platform_signatures = self.get_package_certificate_set(package) + break + + return self._platform_signatures + + @staticmethod + def get_platform_signature_match(package): + """Returns the recorded `PackageManager.checkSignatures(pkg, "android")` verdict. + + Present only in Hubble >= 2.2.0 output. This is descriptive metadata: it is + what `PackageManager` itself would report to an app, which is useful when + reasoning about legacy callers of that API. It is NOT used to decide platform + signing - see `is_platform_signed()` for why. + + Args: + package: A dict representing a package entry from packages.txt. + + Returns: + One of "MATCH", "NO_MATCH", "NEITHER_SIGNED", "FIRST_NOT_SIGNED", + "SECOND_NOT_SIGNED", "UNKNOWN_PACKAGE", "UNKNOWN", or None if unavailable. + """ + if not HubbleParser.has_structured_signing_info(package): + return None + return package["signingInfo"].get("platformSignatureMatch") + + def is_platform_signed(self, package): + """Returns True if `package` shares a signing identity with the platform. + + Uses a directional comparison: the package's *active* signer(s) against the + platform's *full* lineage (`get_platform_signatures()`). It deliberately does + NOT consider the package's own retired certificates, so a package that has + rotated away from the platform key is correctly no longer treated as + platform-signed. + + Why `signingInfo.platformSignatureMatch` is deliberately NOT used here: + + `PackageManager.checkSignatures()` is a legacy, pre-rotation-compatible API, + not a capability-aware trust decision. Per AOSP + `ComputerEngine.checkSignaturesInternal()` it (1) compares the two packages' + *current* signer sets for exact set equality, then (2) on failure, if either + side has a lineage, retries using only the *oldest* ancestor of each. It + never calls `SigningDetails.checkCapability()`. That makes it unsound in both + directions for this question: + + - False positive: a package that rotated AWAY from the platform key still + reports MATCH, because step (2) compares the retired platform cert. + Trusting it would reintroduce exactly the bug this method avoids. + - False negative: a package co-signed by the platform key PLUS another key + reports NO_MATCH, because step (1) demands exact set equality. + + Use `get_platform_signature_match()` if you specifically want that verdict. + + KNOWN LIMITATION: neither approach can see the per-ancestor + `SigningDetails.CertCapabilities` flags (`PERMISSION`, `SHARED_USER_ID`) that + a key rotation may revoke. The framework evaluates those in its shared-UID + join logic and permission subsystem, and they are not reachable from any + public API, so Hubble cannot observe them. A retired platform certificate + whose capabilities were revoked is therefore indistinguishable here from one + that retains them, and this method may over-approximate platform trust + accordingly. + + Args: + package: A dict representing a package entry from packages.txt. + + Returns: + True if the package is platform-signed, False otherwise. + """ + if not isinstance(package, dict): + return False + + platform_signatures = self.get_platform_signatures() + if not platform_signatures: + return False + return bool(platform_signatures & set(self.get_active_signers(package))) def get_platform_signature(self): + """Returns the active platform signing certificate digest. + + Deprecated: Prefer `is_platform_signed()` for platform matching, or + `get_platform_signatures()` for the full lineage-aware trust-anchor set when + the `android` framework signing key has rotated. + + NOTE: For Hubble >= 2.2.0 this is the *currently active* platform signer. For + legacy (< 2.2.0) output it degrades to `certIds[0]`, which for a rotated key + is the OLDEST ancestor - matching the historical behaviour of this method. + """ if not self._platform_signature: for package in self.packages: - if package["name"] == "android": - self._platform_signature = package["certIds"][0] + if package["name"] == HubbleParser.PLATFORM_PACKAGE_NAME: + active_signers = self.get_active_signers(package) + if active_signers: + self._platform_signature = active_signers[0] + break return self._platform_signature @@ -308,9 +665,8 @@ def get_all_packages(self, get_codes_only): def get_platform_packages(self, get_codes_only): result = [] - platform_signature = self.get_platform_signature() for package in self.packages: - if platform_signature in package["certIds"]: + if self.is_platform_signed(package): if not get_codes_only or package["hasCode"]: result.append(package["name"]) return result @@ -321,9 +677,8 @@ def print_all_packages(self, print_codes_only): print(" \"{}\",".format(package["name"])) def print_platform_packages(self, print_codes_only): - platform_signature = self.get_platform_signature() for package in self.packages: - if platform_signature in package["certIds"]: + if self.is_platform_signed(package): if not print_codes_only or package["hasCode"]: print(" \"{}\",".format(package["name"])) @@ -348,6 +703,27 @@ def _get_package_list(self, allow_preinstalled_fallback=True): "cannot query user-installed packages.") return [] + def get_packages_by_signing_mode(self, signing_mode, get_codes_only=False): + """Returns a list of package names matching a specific signing mode.""" + result = [] + for package in self._get_package_list(allow_preinstalled_fallback=True): + if self.classify_package_signing(package, self.logger) == signing_mode: + if not get_codes_only or package.get("hasCode", True): + result.append(package["name"]) + return result + + def get_key_rotated_packages(self, get_codes_only=False): + """Returns a list of packages with a v3 signing certificate rotation lineage.""" + return self.get_packages_by_signing_mode( + HubbleParser.SIGNING_MODE_KEY_ROTATION_LINEAGE, + get_codes_only=get_codes_only) + + def get_cosigned_packages(self, get_codes_only=False): + """Returns a list of packages co-signed by multiple active signers.""" + return self.get_packages_by_signing_mode( + HubbleParser.SIGNING_MODE_MULTIPLE_SIGNERS, + get_codes_only=get_codes_only) + def get_preinstalled_packages(self, get_codes_only=False): """Returns a list of preinstalled package names.""" has_preinstalled = ( diff --git a/uraniborg/scripts/python/tests/test_automate_observation.py b/uraniborg/scripts/python/tests/test_automate_observation.py index cf75a51..5d8f6a8 100644 --- a/uraniborg/scripts/python/tests/test_automate_observation.py +++ b/uraniborg/scripts/python/tests/test_automate_observation.py @@ -340,7 +340,7 @@ def test_extract_apks_from_preinstalled_packages(tmp_path: Path): """Verifies extract_apks_from_device uses explicit preinstalled_only intent.""" preinstall_file = tmp_path / "preinstalled_packages.txt" preinstall_file.write_text(json.dumps({ - "version": "2.1.0", + "version": "2.2.0", "totalPreinstalledPackages": 1, "preinstalledPackages": [{ "name": "com.android.settings", @@ -366,7 +366,7 @@ def test_extract_apks_from_preinstalled_packages(tmp_path: Path): # Verify an empty "packages": [] does NOT fall through to "preinstalledPackages" empty_packages_file = tmp_path / "packages.txt" empty_packages_file.write_text(json.dumps({ - "version": "2.1.0", + "version": "2.2.0", "packages": [], "preinstalledPackages": [{ "name": "com.android.settings", @@ -903,7 +903,7 @@ def test_extract_apks_from_device_retry_via_tmp_and_failure_cleanup( packages_file = tmp_path / "packages.txt" packages_file.write_text( json.dumps({ - "version": "2.1.0", + "version": "2.2.0", "packages": [ { "name": "com.app.direct_ok", @@ -1035,7 +1035,7 @@ def test_extract_apks_from_device_input_validation_edge_cases(tmp_path: Path): # 6. JSON with non-list expected key ("packages": None) bad_json_file = tmp_path / "bad_packages.txt" - bad_json_file.write_text(json.dumps({"version": "2.1.0", "packages": None})) + bad_json_file.write_text(json.dumps({"version": "2.2.0", "packages": None})) assert ( automate_observation.extract_apks_from_device( mock_adb, str(bad_json_file), apks_dir, logger @@ -1048,7 +1048,7 @@ def test_extract_apks_from_device_input_validation_edge_cases(tmp_path: Path): partial_entries_file = tmp_path / "partial_entries.txt" partial_entries_file.write_text( json.dumps({ - "version": "2.1.0", + "version": "2.2.0", "packages": [ {"name": "com.missing.location"}, {"installLocation": "/data/app/missing_name.apk"}, @@ -1092,11 +1092,11 @@ def test_classify_dir_sequential_numbering_and_failed_extraction_file( """Verifies 000/001 sequential numbering, apks/ creation, and failed_extraction.txt output.""" fingerprint = "google/lynx/lynx:17/CP2A.260705.006/123456:user/release-keys" build_json_content = json.dumps({ - "version": "2.1.0", + "version": "2.2.0", "buildInfo": [{"fingerprint": fingerprint}], }) packages_json_content = json.dumps({ - "version": "2.1.0", + "version": "2.2.0", "packages": [{ "name": "com.example.unpullable", "installLocation": "/system/priv-app/Unpullable/Unpullable.apk", @@ -1177,11 +1177,11 @@ def test_classify_dir_adb_backup_fallback(tmp_path: Path): """Verifies adb backup decompression, tar extraction, path-traversal rejection, and source_dir move.""" fingerprint = "google/lynx/lynx:17/CP2A.260705.006/999:user/release-keys" build_bytes = json.dumps({ - "version": "2.1.0", + "version": "2.2.0", "buildInfo": [{"fingerprint": fingerprint}], }).encode("utf-8") packages_bytes = json.dumps({ - "version": "2.1.0", + "version": "2.2.0", "packages": [{ "name": "com.example.backup_app", "installLocation": "/data/app/backup_app/base.apk", diff --git a/uraniborg/scripts/python/tests/test_hubble_parser.py b/uraniborg/scripts/python/tests/test_hubble_parser.py index da168e5..1a7af73 100644 --- a/uraniborg/scripts/python/tests/test_hubble_parser.py +++ b/uraniborg/scripts/python/tests/test_hubble_parser.py @@ -17,6 +17,7 @@ """Unit tests for hubble_parser.py covering versioning, core files, and package classification.""" import base64 +import copy import json import logging import os @@ -44,7 +45,10 @@ def parser(logger: logging.Logger) -> HubbleParser: def test_expected_version_constant(): + # The floor stays at 2.1.0: the 2.2.0 `signingInfo` object is purely additive, + # so bumping this would needlessly invalidate every 2.1.0 corpus. assert HubbleParser.EXPECTED_VERSION == "2.1.0" + assert HubbleParser.SIGNING_INFO_MIN_VERSION == "2.2.0" def test_check_version(): @@ -52,6 +56,7 @@ def test_check_version(): assert HubbleParser.check_version("2.1.0") is True assert HubbleParser.check_version("2.1.1") is True assert HubbleParser.check_version("2.2.0") is True + assert HubbleParser.check_version("2.3.0") is True # Higher major versions (3.0.0+) and versions below 2.1.0 are NOT supported assert HubbleParser.check_version("3.0.0") is False @@ -269,7 +274,7 @@ def test_package_queries_and_filters(parser: HubbleParser): def test_parse_preinstalled_packages(parser: HubbleParser, tmp_path: Path): preinstalled_content = { - "version": "2.1.0", + "version": "2.2.0", "totalPreinstalledPackages": 1, "preinstalledPackages": [{ "name": "com.android.settings", @@ -294,21 +299,21 @@ def test_parse_hubble_output_full_directory( file_path = tmp_path / filename if filename == "packages.txt": content = { - "version": "2.1.0", + "version": "2.2.0", "totalPackages": 1, "packages": [{"name": "com.example", "hasCode": True, "certIds": ["c1"]}], } elif filename == "preinstalled_packages.txt": content = { - "version": "2.1.0", + "version": "2.2.0", "totalPreinstalledPackages": 1, "preinstalledPackages": [{"name": "com.example", "isPreinstalled": True}], } elif filename == "certificates.txt": - content = {"version": "2.1.0", "totalCerts": 1, "certs": [{"hash": "c1"}]} + content = {"version": "2.2.0", "totalCerts": 1, "certs": [{"hash": "c1"}]} elif filename == "device_properties.txt": content = { - "version": "2.1.0", + "version": "2.2.0", "b64EncodedDeviceProps": [{ "encodedDevProps": base64.b64encode( b"ro.build.version.release=15" @@ -317,7 +322,7 @@ def test_parse_hubble_output_full_directory( } elif filename == "build.txt": content = { - "version": "2.1.0", + "version": "2.2.0", "buildInfo": [{ "apiLevel": 35, "fingerprint": "google/pixel/device:15/AP1A/123:user/release-keys", @@ -325,7 +330,7 @@ def test_parse_hubble_output_full_directory( } elif filename == "hardware.txt": content = { - "version": "2.1.0", + "version": "2.2.0", "hwInfo": [{"oem": "Google", "model": "Pixel"}], } file_path.write_text(json.dumps(content)) @@ -339,7 +344,7 @@ def test_parse_hubble_output_full_directory( def test_parse_hubble_output_rejects_legacy_pre_2_1_0_directory( parser: HubbleParser, logger: logging.Logger, tmp_path: Path ): - """Verifies 1.0.0 and pre-2.1.0 directories are rejected loudly.""" + """Verifies pre-2.1.0 directories are rejected loudly, but 2.1.0 is accepted.""" # Case 1: Missing preinstalled_packages.txt fails core file check for key, filename in HubbleParser.CORE_FILES_DICT.items(): if filename == "preinstalled_packages.txt": @@ -347,13 +352,37 @@ def test_parse_hubble_output_rejects_legacy_pre_2_1_0_directory( (tmp_path / filename).write_text(json.dumps({"version": "1.0.0"})) assert parser.parse_hubble_output(str(tmp_path)) is False - # Case 2: 1.0.0 version in file is rejected loudly by read_in_json + # Case 2: versions below 2.1.0 in file are rejected loudly by read_in_json + for legacy_ver in ("1.0.0", "2.0.1"): + legacy_file = tmp_path / "packages.txt" + legacy_file.write_text(json.dumps({"version": legacy_ver, "packages": []})) + with mock.patch.object(logger, "error") as mock_error: + assert parser.read_in_json(str(legacy_file)) is None + mock_error.assert_called_once() + assert "NOT supported" in mock_error.call_args[0][0] + + +def test_read_in_json_accepts_2_1_0_output_without_signing_info( + parser: HubbleParser, logger: logging.Logger, tmp_path: Path +): + """Regression: 2.1.0 corpora must stay readable after the 2.2.0 signingInfo addition. + + `signingInfo` is purely additive and `certIds` is unchanged between 2.1.0 and + 2.2.0, so raising the supported floor would silently orphan every previously + collected observation. + """ legacy_file = tmp_path / "packages.txt" - legacy_file.write_text(json.dumps({"version": "1.0.0", "packages": []})) + legacy_file.write_text( + json.dumps({ + "version": "2.1.0", + "packages": [{"name": "android", "certIds": ["cert_platform"]}], + }) + ) with mock.patch.object(logger, "error") as mock_error: - assert parser.read_in_json(str(legacy_file)) is None - mock_error.assert_called_once() - assert "NOT supported" in mock_error.call_args[0][0] + content = parser.read_in_json(str(legacy_file)) + mock_error.assert_not_called() + assert content is not None + assert content["packages"][0]["certIds"] == ["cert_platform"] def test_state_helpers_when_only_preinstalled_parsed_or_unpopulated( @@ -397,15 +426,475 @@ def test_state_helpers_when_only_preinstalled_parsed_or_unpopulated( # Verify _output_version anchors to the first file and warns on mixed versions f1 = tmp_path / "f1.txt" f2 = tmp_path / "f2.txt" - f1.write_text(json.dumps({"version": "2.1.0"})) - f2.write_text(json.dumps({"version": "2.2.0"})) + f1.write_text(json.dumps({"version": "2.2.0"})) + f2.write_text(json.dumps({"version": "2.3.0"})) parser.read_in_json(str(f1)) - assert parser._output_version == "2.1.0" + assert parser._output_version == "2.2.0" with mock.patch.object(logger, "warning") as mock_warn: parser.read_in_json(str(f2)) - assert parser._output_version == "2.1.0" + assert parser._output_version == "2.2.0" mock_warn.assert_called_once() +def test_signing_lineage_vs_cosigning_classification_and_helpers( + parser: HubbleParser, logger: logging.Logger +): + """Verifies distinction between single signer, v3 key rotation lineage, and co-signed packages.""" + single_signer_pkg = { + "name": "com.android.settings", + "hasCode": True, + "certIds": ["cert_platform"], + "signingInfo": { + "hasMultipleSigners": False, + "hasPastSigningCertificates": False, + "apkContentsSigners": ["cert_platform"], + "signingCertificateLineage": ["cert_platform"], + }, + } + rotated_lineage_pkg = { + "name": "com.google.android.apps.messaging", + "hasCode": True, + "certIds": ["cert_oldest_v1", "cert_mid_v2", "cert_active_v3"], + "signingInfo": { + "hasMultipleSigners": False, + "hasPastSigningCertificates": True, + "apkContentsSigners": ["cert_active_v3"], + "signingCertificateLineage": [ + "cert_oldest_v1", + "cert_mid_v2", + "cert_active_v3", + ], + }, + } + cosigned_pkg = { + "name": "com.example.cosigned", + "hasCode": True, + "certIds": ["cert_signer_a", "cert_signer_b"], + "signingInfo": { + "hasMultipleSigners": True, + "hasPastSigningCertificates": False, + "apkContentsSigners": ["cert_signer_a", "cert_signer_b"], + "signingCertificateLineage": [], + }, + } + missing_signing_info_pkg = { + "name": "com.example.missing", + "hasCode": True, + "certIds": ["cert_legacy"], + } + + # 1. Classification + assert ( + HubbleParser.classify_package_signing(single_signer_pkg) + == HubbleParser.SIGNING_MODE_SINGLE_SIGNER + ) + assert ( + HubbleParser.classify_package_signing(rotated_lineage_pkg) + == HubbleParser.SIGNING_MODE_KEY_ROTATION_LINEAGE + ) + assert ( + HubbleParser.classify_package_signing(cosigned_pkg) + == HubbleParser.SIGNING_MODE_MULTIPLE_SIGNERS + ) + # Legacy (< 2.2.0) output has no signingInfo at all. That is a supported, + # expected state now, so it is reported at debug level rather than as a + # warning, but it must still classify as UNKNOWN (never guessed from certIds). + assert not HubbleParser.has_structured_signing_info(missing_signing_info_pkg) + with mock.patch.object(logger, "debug") as mock_debug: + assert ( + HubbleParser.classify_package_signing(missing_signing_info_pkg, logger) + == HubbleParser.SIGNING_MODE_UNKNOWN + ) + mock_debug.assert_called_once() + + # 2. Active signers vs. Lineage vs. Past certificates + assert HubbleParser.get_active_signers(single_signer_pkg) == ["cert_platform"] + assert HubbleParser.get_signing_lineage(single_signer_pkg) == ["cert_platform"] + assert HubbleParser.get_past_signing_certificates(single_signer_pkg) == [] + + assert HubbleParser.get_active_signers(rotated_lineage_pkg) == [ + "cert_active_v3" + ] + assert HubbleParser.get_signing_lineage(rotated_lineage_pkg) == [ + "cert_oldest_v1", + "cert_mid_v2", + "cert_active_v3", + ] + assert HubbleParser.get_past_signing_certificates(rotated_lineage_pkg) == [ + "cert_oldest_v1", + "cert_mid_v2", + ] + + assert HubbleParser.get_active_signers(cosigned_pkg) == [ + "cert_signer_a", + "cert_signer_b", + ] + assert HubbleParser.get_signing_lineage(cosigned_pkg) == [] + assert HubbleParser.get_past_signing_certificates(cosigned_pkg) == [] + + # 3. Parser query methods + parser.packages = [ + single_signer_pkg, + rotated_lineage_pkg, + cosigned_pkg, + ] + assert parser.get_key_rotated_packages() == [ + "com.google.android.apps.messaging" + ] + assert parser.get_cosigned_packages() == ["com.example.cosigned"] + assert parser.get_packages_by_signing_mode( + HubbleParser.SIGNING_MODE_SINGLE_SIGNER + ) == ["com.android.settings"] + + +def test_pre_p_null_rotation_state_classifies_as_unknown( + logger: logging.Logger, +): + """API < 28 cannot observe a v3 lineage, so rotation state must not be guessed. + + Hubble emits `hasPastSigningCertificates: null` (not `false`) and an empty + lineage on pre-P devices. Classifying such a package as SINGLE_SIGNER would be + an affirmative "never rotated" claim the platform cannot support, and would + contradict the same never-guess rule applied to legacy 2.1.0 corpora. + """ + pre_p_pkg = { + "name": "com.android.settings", + "hasCode": True, + "certIds": ["cert_active"], + "signingInfo": { + "hasMultipleSigners": False, + "hasPastSigningCertificates": None, + "apkContentsSigners": ["cert_active"], + "signingCertificateLineage": [], + "platformSignatureMatch": "MATCH", + }, + } + + with mock.patch.object(logger, "debug") as mock_debug: + assert ( + HubbleParser.classify_package_signing(pre_p_pkg, logger) + == HubbleParser.SIGNING_MODE_UNKNOWN + ) + mock_debug.assert_called_once() + + # No lineage is fabricated from the active signer. + assert HubbleParser.get_signing_lineage(pre_p_pkg) == [] + assert HubbleParser.get_past_signing_certificates(pre_p_pkg) == [] + # The active signer is still observable, and still usable for platform + # matching, which is why this remains distinct from "no signingInfo at all". + assert HubbleParser.has_structured_signing_info(pre_p_pkg) + assert HubbleParser.get_active_signers(pre_p_pkg) == ["cert_active"] + + # An explicit False (API 28+) must keep classifying as SINGLE_SIGNER. + api_28_pkg = copy.deepcopy(pre_p_pkg) + api_28_pkg["signingInfo"]["hasPastSigningCertificates"] = False + api_28_pkg["signingInfo"]["signingCertificateLineage"] = ["cert_active"] + assert ( + HubbleParser.classify_package_signing(api_28_pkg) + == HubbleParser.SIGNING_MODE_SINGLE_SIGNER + ) + + +def test_pre_p_cosigning_is_still_reported_affirmatively(): + """A pre-P signer count > 1 is observable, so MULTIPLE_SIGNERS is not downgraded. + + Hubble derives `hasMultipleSigners` from the raw `PackageInfo.signatures` + length *before* digests are computed, so a failed digest cannot silently turn + a co-signed APK into a single-signer one. Model that here: two declared + signers, but only one digest survived. + """ + pre_p_cosigned_pkg = { + "name": "com.example.cosigned", + "hasCode": True, + "certIds": ["cert_signer_a"], + "signingInfo": { + "hasMultipleSigners": True, + "hasPastSigningCertificates": None, + "apkContentsSigners": ["cert_signer_a"], + "signingCertificateLineage": [], + }, + } + assert ( + HubbleParser.classify_package_signing(pre_p_cosigned_pkg) + == HubbleParser.SIGNING_MODE_MULTIPLE_SIGNERS + ) + + +def test_get_platform_signature_and_lineage_matching_on_key_rotation( + parser: HubbleParser, capsys: pytest.CaptureFixture[str] +): + """Verifies platform matching intersects the full platform lineage + active signer set.""" + parser.packages = [ + { + "name": "android", + "hasCode": True, + "sharedUserId": "android.uid.system", + "certIds": ["retired_platform_cert_v1", "active_platform_cert_v2"], + "signingInfo": { + "hasMultipleSigners": False, + "hasPastSigningCertificates": True, + "apkContentsSigners": ["active_platform_cert_v2"], + "signingCertificateLineage": [ + "retired_platform_cert_v1", + "active_platform_cert_v2", + ], + }, + }, + { + "name": "com.android.legacy_platform_app", + "hasCode": True, + "sharedUserId": "android.uid.system", + "certIds": ["retired_platform_cert_v1"], + "signingInfo": { + "hasMultipleSigners": False, + "hasPastSigningCertificates": False, + "apkContentsSigners": ["retired_platform_cert_v1"], + "signingCertificateLineage": ["retired_platform_cert_v1"], + }, + }, + { + "name": "com.android.new_platform_app", + "hasCode": True, + "sharedUserId": "android.uid.phone", + "certIds": ["active_platform_cert_v2"], + "signingInfo": { + "hasMultipleSigners": False, + "hasPastSigningCertificates": False, + "apkContentsSigners": ["active_platform_cert_v2"], + "signingCertificateLineage": ["active_platform_cert_v2"], + }, + }, + { + "name": "com.example.third_party", + "hasCode": True, + "sharedUserId": "com.example.uid", + "certIds": ["third_party_cert"], + "signingInfo": { + "hasMultipleSigners": False, + "hasPastSigningCertificates": False, + "apkContentsSigners": ["third_party_cert"], + "signingCertificateLineage": ["third_party_cert"], + }, + }, + ] + assert parser.get_platform_signature() == "active_platform_cert_v2" + assert parser.get_platform_signatures() == { + "retired_platform_cert_v1", + "active_platform_cert_v2", + } + assert parser.get_platform_packages(get_codes_only=True) == [ + "android", + "com.android.legacy_platform_app", + "com.android.new_platform_app", + ] + assert parser.get_shared_uid_packages() == { + "android.uid.system": ["android", "com.android.legacy_platform_app"], + "android.uid.phone": ["com.android.new_platform_app"], + } + parser.print_platform_packages(print_codes_only=True) + printed = capsys.readouterr().out + assert '"com.android.legacy_platform_app"' in printed + assert '"com.android.new_platform_app"' in printed + assert '"com.example.third_party"' not in printed + + # Verify cache invalidation when reusing the same HubbleParser instance across + # a second observation (and verify missing 'android' package caches empty set + # without re-scanning on subsequent calls). + parser.packages = [{ + "name": "com.example.only_third_party", + "hasCode": True, + "sharedUserId": "android.uid.system", + "certIds": ["active_platform_cert_v2"], + }] + assert parser.get_platform_signatures() == set() + assert parser._platform_signatures == set() + assert parser.get_platform_signature() == "" + assert parser.get_platform_packages(get_codes_only=True) == [] + assert parser.get_shared_uid_packages() == {} + + +def test_platform_matching_is_directional_on_rotated_away_package( + parser: HubbleParser, +): + """A package that rotated AWAY from the platform key is no longer platform-signed. + + Matching must compare the package's ACTIVE signer(s) against the platform's + full lineage - not full-set against full-set. A symmetric intersection would + keep matching on the package's own retired platform certificate forever. + """ + parser.packages = [ + { + "name": "android", + "hasCode": True, + "sharedUserId": "android.uid.system", + "certIds": ["platform_cert"], + "signingInfo": { + "hasMultipleSigners": False, + "hasPastSigningCertificates": False, + "apkContentsSigners": ["platform_cert"], + "signingCertificateLineage": ["platform_cert"], + }, + }, + { + # Was platform-signed, has since rotated to its own key. + "name": "com.example.divested", + "hasCode": True, + "sharedUserId": None, + "certIds": ["platform_cert", "own_cert_v2"], + "signingInfo": { + "hasMultipleSigners": False, + "hasPastSigningCertificates": True, + "apkContentsSigners": ["own_cert_v2"], + "signingCertificateLineage": ["platform_cert", "own_cert_v2"], + }, + }, + ] + + assert parser.get_platform_signatures() == {"platform_cert"} + # The retired platform cert is still in the package's lineage... + assert "platform_cert" in HubbleParser.get_package_certificate_set( + parser.packages[1] + ) + # ...but it is no longer the active signer, so it is NOT platform-signed. + assert parser.is_platform_signed(parser.packages[1]) is False + assert parser.get_platform_packages(get_codes_only=True) == ["android"] + + +def test_platform_signature_match_is_recorded_but_not_authoritative( + parser: HubbleParser, +): + """`platformSignatureMatch` is descriptive only; it must not drive matching. + + `PackageManager.checkSignatures()` is a legacy, pre-rotation-compatible API + (AOSP `ComputerEngine.checkSignaturesInternal`): it compares *current* signer + sets for exact equality, then retries with only the *oldest* ancestor of each + lineage. It never calls `SigningDetails.checkCapability()`, which makes it + unsound in both directions for "is this platform-signed?". + """ + parser.packages = [ + { + "name": "android", + "hasCode": True, + "sharedUserId": "android.uid.system", + "certIds": ["platform_cert"], + "signingInfo": { + "hasMultipleSigners": False, + "hasPastSigningCertificates": False, + "apkContentsSigners": ["platform_cert"], + "signingCertificateLineage": ["platform_cert"], + "platformSignatureMatch": "MATCH", + }, + }, + { + # Rotated AWAY from the platform key. checkSignatures reports MATCH via + # its oldest-ancestor retry, but this package is NOT platform-signed. + "name": "com.example.divested", + "hasCode": True, + "sharedUserId": None, + "certIds": ["platform_cert", "own_cert_v2"], + "signingInfo": { + "hasMultipleSigners": False, + "hasPastSigningCertificates": True, + "apkContentsSigners": ["own_cert_v2"], + "signingCertificateLineage": ["platform_cert", "own_cert_v2"], + "platformSignatureMatch": "MATCH", + }, + }, + { + # Co-signed by the platform key plus another key. checkSignatures + # reports NO_MATCH (exact set equality fails), but the platform key is + # an active signer, so it IS platform-signed for our purposes. + "name": "com.example.cosigned_with_platform", + "hasCode": True, + "sharedUserId": None, + "certIds": ["platform_cert", "partner_cert"], + "signingInfo": { + "hasMultipleSigners": True, + "hasPastSigningCertificates": False, + "apkContentsSigners": ["platform_cert", "partner_cert"], + "signingCertificateLineage": [], + "platformSignatureMatch": "NO_MATCH", + }, + }, + { + # PackageManager cannot resolve this one (observed in practice for + # com.android.privatespace); matching must not depend on the verdict. + "name": "com.example.unresolvable", + "hasCode": True, + "sharedUserId": None, + "certIds": ["platform_cert"], + "signingInfo": { + "hasMultipleSigners": False, + "hasPastSigningCertificates": False, + "apkContentsSigners": ["platform_cert"], + "signingCertificateLineage": ["platform_cert"], + "platformSignatureMatch": "UNKNOWN_PACKAGE", + }, + }, + ] + + # The verdict is preserved verbatim for consumers that want it... + assert [HubbleParser.get_platform_signature_match(p) for p in parser.packages] == [ + "MATCH", + "MATCH", + "NO_MATCH", + "UNKNOWN_PACKAGE", + ] + + # ...but platform matching ignores it entirely and stays directional. + assert parser.is_platform_signed(parser.packages[1]) is False # MATCH, yet no + assert parser.is_platform_signed(parser.packages[2]) is True # NO_MATCH, yet yes + assert parser.is_platform_signed(parser.packages[3]) is True + assert parser.get_platform_packages(get_codes_only=True) == [ + "android", + "com.example.cosigned_with_platform", + "com.example.unresolvable", + ] + + +def test_get_platform_signature_match_absent_for_legacy_output(): + """Legacy (< 2.2.0) packages have no recorded verdict.""" + assert HubbleParser.get_platform_signature_match( + {"name": "android", "certIds": ["platform_cert"]} + ) is None + assert HubbleParser.get_platform_signature_match(None) is None + + +def test_platform_matching_falls_back_for_legacy_2_1_0_packages( + parser: HubbleParser, +): + """Legacy (< 2.2.0) packages have no signingInfo but must still match by certIds.""" + parser.packages = [ + { + "name": "android", + "hasCode": True, + "sharedUserId": "android.uid.system", + "certIds": ["platform_cert"], + }, + { + "name": "com.android.systemui", + "hasCode": True, + "sharedUserId": "android.uid.system", + "certIds": ["platform_cert"], + }, + { + "name": "com.example.third_party", + "hasCode": True, + "sharedUserId": None, + "certIds": ["third_party_cert"], + }, + ] + + assert not HubbleParser.has_structured_signing_info(parser.packages[0]) + assert parser.get_platform_signature() == "platform_cert" + assert parser.get_platform_packages(get_codes_only=True) == [ + "android", + "com.android.systemui", + ] + assert parser.get_shared_uid_packages() == { + "android.uid.system": ["android", "com.android.systemui"], + } + + if __name__ == "__main__": sys.exit(pytest.main([__file__])) diff --git a/uraniborg/scripts/python/tests/test_inclusion_proof_check.py b/uraniborg/scripts/python/tests/test_inclusion_proof_check.py index f87a351..75dc5da 100644 --- a/uraniborg/scripts/python/tests/test_inclusion_proof_check.py +++ b/uraniborg/scripts/python/tests/test_inclusion_proof_check.py @@ -359,7 +359,7 @@ def side_effect(cmd, **kwargs): preinstalled_file = tmp_path / "preinstalled_packages.txt" preinstalled_file.write_text(json.dumps({ - "version": "2.1.0", + "version": "2.2.0", "totalPreinstalledPackages": 2, "preinstalledPackages": [ {