Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
617 changes: 416 additions & 201 deletions LICENSE

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
</p>

<p align="center">
<a href="LICENSE"><img alt="License: Apache 2.0" src="https://img.shields.io/badge/License-Apache_2.0-blue.svg"></a>
<a href="LICENSE"><img alt="License: CC BY-ND 4.0" src="https://img.shields.io/badge/License-CC_BY--ND_4.0-blue.svg"></a>
<a href="https://jitpack.io/#iamjosephmj/DeviceIntelligence"><img alt="JitPack" src="https://jitpack.io/v/iamjosephmj/DeviceIntelligence.svg"></a>
<img alt="Platform" src="https://img.shields.io/badge/Platform-Android-3DDC84.svg?logo=android&logoColor=white">
<img alt="Min SDK" src="https://img.shields.io/badge/minSdk-28-green.svg">
Expand Down Expand Up @@ -190,4 +190,5 @@ The SDK makes **zero network calls** and reads no GAID, `ANDROID_ID`, IMEI/IMSI,

## License

Apache 2.0 — see [`LICENSE`](LICENSE).
**Creative Commons Attribution-NoDerivatives 4.0 International (CC BY-ND 4.0)** — see [`LICENSE`](LICENSE).
Commercial use and verbatim redistribution are permitted with attribution; redistributing **modified** copies is not (a source-available, not OSI-approved, license).
11 changes: 9 additions & 2 deletions deviceintelligence-gradle/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ dependencies {
// without shelling out to `apksigner`. This is the same library that
// AGP itself uses internally.
implementation("com.android.tools.build:apksig:8.13.2")

// JUnit 5 for plugin unit tests
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
}

tasks.named<Test>("test") {
useJUnitPlatform()
}

gradlePlugin {
Expand Down Expand Up @@ -129,8 +136,8 @@ afterEvaluate {
url.set("https://github.com/iamjosephmj/DeviceIntelligence")
licenses {
license {
name.set("The Apache License, Version 2.0")
url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
name.set("Creative Commons Attribution-NoDerivatives 4.0 International (CC BY-ND 4.0)")
url.set("https://creativecommons.org/licenses/by-nd/4.0/legalcode")
distribution.set("repo")
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package io.ssemaj.deviceintelligence.gradle

import org.gradle.api.provider.Property
import org.gradle.api.provider.SetProperty

/**
* Opt-in App Bundle integrity ("bundle mode").
*
* When [enabled], the plugin bakes a bundle-mode fingerprint (decompressed
* dex/`.so` hashes + signer pins) into the AAB's base assets and re-signs the
* AAB, instead of instrumenting the APK. The runtime then hashes those entries'
* decompressed bodies across `sourceDir ∪ splitSourceDirs` and checks the
* installed signer is a member of the baked allow-set.
*
* APK mode and bundle mode are mutually exclusive per variant.
*/
abstract class AppBundleOptions {
/** Enable bundle mode for AAB builds. Default `false`. */
abstract val enabled: Property<Boolean>

/**
* Play App Signing certificate SHA-256(s) to include in the signer
* allow-set, normalized to lowercase hex with `:` separators stripped.
* Under Play App Signing, Google re-signs delivered APKs with the app
* signing key, so the runtime must accept that signer in addition to the
* upload key. Empty = only the upload-key cert is in the allow-set.
*/
abstract val playSigningCertSha256: SetProperty<String>

/** DSL sugar: `appBundle { playSigningCertSha256("AB:CD:...") }`. */
fun playSigningCertSha256(vararg hex: String) {
for (h in hex) playSigningCertSha256.add(h.replace(":", "").lowercase())
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package io.ssemaj.deviceintelligence.gradle

import org.gradle.api.Action
import org.gradle.api.provider.Property
import org.gradle.api.provider.SetProperty
import org.gradle.api.tasks.Nested

/**
* Consumer-facing DSL block. Real options (reaction policy, detector set,
Expand Down Expand Up @@ -81,4 +83,15 @@ abstract class DeviceIntelligenceExtension {
* `-Pdeviceintelligence.disableAutoRuntimeDependency=true`.
*/
abstract val disableAutoRuntimeDependency: Property<Boolean>

/**
* Opt-in App Bundle integrity ("bundle mode"). When `appBundle.enabled`
* is `true`, the plugin bakes a bundle-mode fingerprint into the AAB and
* re-signs it instead of instrumenting the APK.
*/
@get:Nested
abstract val appBundle: AppBundleOptions

/** DSL sugar: `deviceIntelligence { appBundle { enabled = true } }`. */
fun appBundle(action: Action<AppBundleOptions>) = action.execute(appBundle)
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import io.ssemaj.deviceintelligence.gradle.internal.PluginCoordinates
import io.ssemaj.deviceintelligence.gradle.tasks.BakeFingerprintTask
import io.ssemaj.deviceintelligence.gradle.tasks.BundleIntegrityTask
import io.ssemaj.deviceintelligence.gradle.tasks.ComputeFingerprintTask
import io.ssemaj.deviceintelligence.gradle.tasks.GenerateKeyChunksTask
import io.ssemaj.deviceintelligence.gradle.tasks.GenerateOptionalManifestTask
Expand Down Expand Up @@ -40,6 +41,8 @@ class DeviceIntelligencePlugin : Plugin<Project> {
enableVpnDetection.convention(false)
enableBiometricsDetection.convention(false)
disableAutoRuntimeDependency.convention(false)
appBundle.enabled.convention(false)
appBundle.playSigningCertSha256.convention(emptySet())
}

// Auto-apply the matching runtime AAR. Eager registration (not
Expand Down Expand Up @@ -181,9 +184,6 @@ class DeviceIntelligencePlugin : Plugin<Project> {

val variantTitle = variant.name.replaceFirstChar { it.uppercase() }
val genKeyTaskName = "generate${variantTitle}DeviceIntelligenceKeyChunks"
val computeTaskName = "compute${variantTitle}DeviceIntelligenceFingerprint"
val bakeTaskName = "bake${variantTitle}DeviceIntelligenceFingerprint"
val instrumentTaskName = "instrument${variantTitle}DeviceIntelligenceApk"

val intermediatesDir = project.layout.buildDirectory
.dir("intermediates/io.ssemaj/${variant.name}")
Expand All @@ -193,6 +193,8 @@ class DeviceIntelligencePlugin : Plugin<Project> {
// 1) Codegen task — runs FIRST, no deps. Produces:
// - key.bin (build-private 32B key)
// - KeyChunkN.kt + KeyAssembler.kt (consumed by kotlin compile)
// Registered here (before the bundle-mode gate) so BOTH the
// bundle branch and the APK branch can reference it.
val genKeyTask = project.tasks.register<GenerateKeyChunksTask>(genKeyTaskName) {
group = "io.ssemaj"
description = "Generates the per-build XOR key + KeyChunkN/KeyAssembler codegen for variant '${variant.name}'."
Expand Down Expand Up @@ -230,6 +232,55 @@ class DeviceIntelligencePlugin : Plugin<Project> {
dependsOn(genKeyTask)
}

// App Bundle mode and APK instrumentation are mutually exclusive.
// When bundle mode is enabled, register BundleIntegrityTask on
// SingleArtifact.BUNDLE and skip the APK transform for this variant.
val bundleModeEnabled = ext.appBundle.enabled.getOrElse(false)
if (bundleModeEnabled) {
project.logger.lifecycle(
"io.ssemaj: appBundle.enabled=true — APK integrity transform skipped " +
"for variant '${variant.name}'; bundle-mode integrity applies"
)
val bundleTask = project.tasks.register<BundleIntegrityTask>(
"bundle${variantTitle}DeviceIntelligenceIntegrity",
) {
group = "io.ssemaj"
description = "Bakes bundle-mode fingerprint into the AAB and re-signs it " +
"(variant '${variant.name}')."

keyFile.set(genKeyTask.flatMap { it.keyFile })
keystoreFile.fileValue(cfgStoreFile)
keystorePassword.set(cfgStorePassword)
keyAlias.set(cfgKeyAlias)
if (cfgKeyPassword != null) keyPassword.set(cfgKeyPassword)
if (!cfgStoreType.isNullOrBlank()) keystoreType.set(cfgStoreType)
playSigningCertSha256.set(ext.appBundle.playSigningCertSha256)
variantName.set(variant.name)
applicationId.set(variant.applicationId)
pluginVersion.set(PLUGIN_VERSION)
}

variant.artifacts.use(bundleTask)
.wiredWithFiles(
BundleIntegrityTask::inputAab,
BundleIntegrityTask::outputAab,
)
.toTransform(SingleArtifact.BUNDLE)

project.afterEvaluate {
if (ext.verbose.getOrElse(false)) {
project.logger.lifecycle(
"io.ssemaj: registered ${bundleTask.name} (BUNDLE transform)"
)
}
}
return@onVariants // skip APK transform for this variant
}

val computeTaskName = "compute${variantTitle}DeviceIntelligenceFingerprint"
val bakeTaskName = "bake${variantTitle}DeviceIntelligenceFingerprint"
val instrumentTaskName = "instrument${variantTitle}DeviceIntelligenceApk"

// 2) Compute task — runs after package${Variant}; reads the signed
// APK (which by now contains classes.dex with KeyChunk classes)
// and emits fingerprint.json + fingerprint.cbo.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// deviceintelligence-gradle/src/main/kotlin/io/ssemaj/deviceintelligence/gradle/internal/AabHasher.kt
package io.ssemaj.deviceintelligence.gradle.internal

import java.io.File
import java.security.MessageDigest
import java.util.zip.ZipFile

/**
* Reads an Android App Bundle (`.aab`) and returns the SHA-256 of the
* DECOMPRESSED body of every `classes*.dex` and `.so` entry under `lib/<abi>/`
* in the base module, keyed by the APK-relative path the runtime sees on-device:
*
* `base/dex/classes.dex` → `classes.dex`
* `base/lib/arm64-v8a/libdicore.so` → `lib/arm64-v8a/libdicore.so`
*
* We hash the decompressed bytes (not the compressed body, as APK mode does)
* because Play re-encodes split APKs during delivery — only the inflated
* payload is stable between build time and the installed device.
*
* Resources and the manifest are intentionally excluded: they are covered
* transitively by the signer pin, and Play rewrites `resources.pb` to binary
* `resources.arsc` so a byte hash would never match.
*/
internal object AabHasher {

fun bundleEntryHashes(aab: File): Map<String, String> {
val out = LinkedHashMap<String, String>()
ZipFile(aab).use { zf ->
val entries = zf.entries()
while (entries.hasMoreElements()) {
val e = entries.nextElement()
if (e.isDirectory) continue
val apkPath = when {
e.name.startsWith("base/dex/") && e.name.endsWith(".dex") ->
e.name.removePrefix("base/dex/") // classes.dex
e.name.startsWith("base/lib/") && e.name.endsWith(".so") ->
e.name.removePrefix("base/") // lib/<abi>/<file>.so
else -> null
} ?: continue

val md = MessageDigest.getInstance("SHA-256")
// ZipFile.getInputStream yields the DECOMPRESSED bytes regardless of
// the entry's compression method — this is what we want.
zf.getInputStream(e).use { ins ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = ins.read(buf)
if (n < 0) break
md.update(buf, 0, n)
}
}
out[apkPath] = md.digest().joinToString("") { b -> "%02x".format(b) }
}
}
return out
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// deviceintelligence-gradle/src/main/kotlin/io/ssemaj/deviceintelligence/gradle/internal/AabSigner.kt
package io.ssemaj.deviceintelligence.gradle.internal

import jdk.security.jarsigner.JarSigner
import java.io.File
import java.io.FileOutputStream
import java.security.PrivateKey
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.zip.ZipFile

/**
* JAR-signs (v1 / "JAR signing") a modified `.aab` so `bundletool validate`
* and Play accept it after the plugin injects the bundle-mode fingerprint asset.
*
* Uses the JDK's in-process [JarSigner] (module `jdk.jartool`). The input
* `.aab` must contain ONLY file entries — bundletool rejects directory entries.
* [BundleIntegrityTask] is responsible for emitting a clean repack;
* this signer copies entries through verbatim.
*
* Single-signer only (matching [InstrumentApkTask]).
*/
internal object AabSigner {

fun sign(aab: File, key: PrivateKey, certs: List<X509Certificate>) {
require(certs.isNotEmpty()) { "no signer certificates supplied for $aab" }
val certPath = CertificateFactory.getInstance("X.509").generateCertPath(certs)
val signer = JarSigner.Builder(key, certPath)
.digestAlgorithm("SHA-256")
.signerName("DI")
.build()

// JarSigner requires distinct input/output streams. Sign to a temp
// sibling then atomically replace the original.
val signed = File(aab.parentFile, "${aab.name}.signed")
try {
ZipFile(aab).use { zf ->
FileOutputStream(signed).use { out -> signer.sign(zf, out) }
}
if (!signed.renameTo(aab)) {
signed.copyTo(aab, overwrite = true)
signed.delete()
}
} catch (t: Throwable) {
signed.delete()
throw t
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,25 @@ internal data class Fingerprint(
* pre-load `.so` replacement (Component 3 / Vector G2).
*/
val dicoreTextSha256ByAbi: Map<String, String> = emptyMap(),
/** v3 — true when baked for an App Bundle build (split-aware, decompressed hashing). */
val bundleMode: Boolean = false,
/**
* v3 — APK-relative entry path -> SHA-256 hex of the entry's DECOMPRESSED body,
* for `classes*.dex` + `.so` files under `lib/<abi>/`. Used only in bundle mode;
* `entries` is left empty in bundle mode because Play re-deflates, making
* compressed-byte hashes unstable.
*/
val bundleEntryHashes: Map<String, String> = emptyMap(),
) {
companion object {
/**
* Bumped from 1 to 2 to add `nativeLibInventoryByAbi`,
* `nativeLibHashesByAbi`, and `dicoreTextSha256ByAbi`. The
* runtime decoder accepts both 1 and 2; v1 blobs simply
* leave the new fields empty.
* Bumped from 1 to 2 to add `nativeLibInventoryByAbi`, `nativeLibHashesByAbi`,
* and `dicoreTextSha256ByAbi`.
* Bumped from 2 to 3 to add `bundleMode` and `bundleEntryHashes` for App Bundle
* integrity support. Runtime decoder accepts v1/v2/v3; older blobs leave the new
* fields at their defaults (bundleMode=false, bundleEntryHashes=emptyMap()).
*/
const val SCHEMA_VERSION: Int = 2
const val SCHEMA_VERSION: Int = 3
const val ASSET_PATH: String = "assets/io.ssemaj.deviceintelligence/fingerprint.bin"
val DEFAULT_IGNORED_ENTRY_PREFIXES: List<String> = listOf("META-INF/")
val DEFAULT_IGNORED_ENTRIES: List<String> = listOf(ASSET_PATH)
Expand Down
Loading