此文章为记录文章,需要详细版本请看文章末尾博文,目的是实现 Android 的敏感代码加密。
业务实现不进入主 DEX。:hidden-landing-page 先编成 jar,再用 d8 --release 打成 classes.dex,最后用 AES-256-GCM 写成 assets/payload/hidden.dex.enc。
::note 接入方不用跑这条流水线。更换实现时,在 SDK 仓库重新 assemble,把新 AAR 交给宿主即可。 ::
密文布局
Java 加密器固定了 HDX1 头、nonce 长度和 GCM tag 位数。运行时 so 用同一套常量拆开密文。
java
static final byte[] MAGIC = new byte[] {'H', 'D', 'X', '1'};
static final int NONCE_LEN = 12;
static final int TAG_BITS = 128;
static final int KEY_LEN = 32;
拼出的字节流如下:
text
HDX1 (4) || nonce (12) || ciphertext + tag (16)
HDX1 供 Gradle 和自检脚本用文件头判断:这是加密 payload,不是明文 dex。明文 dex 的头是 dex。如果 APK 里还能搜到以 dex 开头的 hidden 文件,说明加密没有执行。
::warning 每次打包时,nonce 由 SecureRandom 重新生成,并写入密文头,不随密钥一起分发。同一份源码打包两次,密文也会不同。 ::
构建流水线
::steps
编 landing-page jar
:hidden-sdk 依赖 :hidden-landing-page:bundleLibRuntimeToJar*,拿到实现 class。
用 d8 打 release dex
脚本使用最新 build-tools 中的 d8,以 android.jar 和 SDK jar 作为 --lib。这些类不会打进加密 dex。
AES-GCM 加密
javac 编译 PayloadEncryptor.java,把 HIDDEN_PAYLOAD_KEY 传入子进程环境,写出 hidden.dex.enc。
放进 SDK assets
sourceSets 把 generated/encrypted_assets/<variant>/payload 指定给 debug / release,随后打进 AAR。 ::
python
d8_cmd = [str(d8), "--release", "--lib", str(android_jar)]
if args.sdk_jar is not None and args.sdk_jar.exists():
d8_cmd += ["--lib", str(args.sdk_jar)]
d8_cmd += ["--output", str(dex_dir), str(hidden_jar)]
subprocess.check_call(d8_cmd)
plain = dex_path.read_bytes()
if plain[:3] != b"dex":
raise SystemExit("非法 dex 头")
--lib 只提供 classpath(例如 KeepAlive 等符号),不会把 SDK 类拷进 payload。实现类也不再实现已经删除的 HiddenApi。
加密过程
encrypt 先生成 12 字节 nonce,再使用 AES/GCM/NoPadding。Java 的 doFinal 会把 16 字节 tag 接在密文后面,写出时不必再单独追加 tag。
java
static byte[] encrypt(byte[] plain, byte[] key) throws Exception {
byte[] nonce = new byte[NONCE_LEN];
new SecureRandom().nextBytes(nonce);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.ENCRYPT_MODE,
new SecretKeySpec(key, "AES"),
new GCMParameterSpec(TAG_BITS, nonce)
);
byte[] ct = cipher.doFinal(plain);
byte[] out = new byte[MAGIC.length + NONCE_LEN + ct.length];
System.arraycopy(MAGIC, 0, out, 0, MAGIC.length);
System.arraycopy(nonce, 0, out, MAGIC.length, NONCE_LEN);
System.arraycopy(ct, 0, out, MAGIC.length + NONCE_LEN, ct.length);
Arrays.fill(nonce, (byte) 0);
return out;
}
写完后立刻用同一把密钥解密并比对。成功后对 plaintext、key 和临时 buffer 执行 fill(0),避免明文 dex 长时间留在构建机内存里。
::tip 密钥必须是 64 个 hex 字符 ,解码后为 32 字节。不能把可打印字符串的 UTF-8 直接当作 AES key。解析逻辑见 密钥外置。 ::
注册 Gradle 任务
每个 library variant 注册 encryptHiddenPayloadDebug / encryptHiddenPayloadRelease。release 不允许 自动生成密钥,所以脚本不带 --allow-generate。
kotlin
val encrypt = tasks.register("encryptHiddenPayload$capital") {
dependsOn(
":hidden-landing-page:bundleLibRuntimeToJar$capital",
"bundleLibCompileToJar$capital",
)
doLast {
check(!jar.jarHasClass("com/example/minidex/sdk/HiddenSdk")) {
":hidden-landing-page 把 HiddenSdk 打进了 jar。必须 compileOnly。"
}
exec {
val args = mutableListOf(
"python3",
"tools/encrypt_hidden_payload.py",
"--hidden-jar", jar.absolutePath,
"--sdk-jar", sdkJar.get().asFile.absolutePath,
"--output-dir", payloadDir.get().asFile.absolutePath,
"--dex-dir", dexDir.get().asFile.absolutePath,
)
if (variantName != "release") {
args += "--allow-generate"
}
commandLine(args)
}
}
}
加密失败时,package*Assets / merge*Assets 不会继续,避免打出缺少 payload 的 AAR。
中间文件
| 文件 | 路径 | 给接入方吗 |
|---|---|---|
| 明文 dex | hidden-sdk/build/generated/hidden_dex/<variant>/classes.dex |
否 |
| 加密 payload | hidden-sdk/build/generated/encrypted_assets/<variant>/payload/hidden.dex.enc |
否,只随 AAR |
| 压缩包装 | app/build/intermediates/compressed_assets/.../hidden.dex.enc.jar |
否,AGP 中间物 |
::caution 不要把明文 classes.dex 或单独一份 hidden.dex.enc 再拷进宿主 src/main/assets/。AAR 已经带了这些文件,重复拷贝容易造成版本错位。 ::