APK校验接口调试完成
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
#Created by .winboll/winboll_app_build.gradle
|
#Created by .winboll/winboll_app_build.gradle
|
||||||
#Fri Jan 23 13:02:14 GMT 2026
|
#Sat Jan 24 03:14:43 GMT 2026
|
||||||
stageCount=8
|
stageCount=8
|
||||||
libraryProject=libappbase
|
libraryProject=libappbase
|
||||||
baseVersion=15.15
|
baseVersion=15.15
|
||||||
publishVersion=15.15.7
|
publishVersion=15.15.7
|
||||||
buildCount=14
|
buildCount=25
|
||||||
baseBetaVersion=15.15.8
|
baseBetaVersion=15.15.8
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#Created by .winboll/winboll_app_build.gradle
|
#Created by .winboll/winboll_app_build.gradle
|
||||||
#Fri Jan 23 13:02:14 GMT 2026
|
#Sat Jan 24 03:14:43 GMT 2026
|
||||||
stageCount=8
|
stageCount=8
|
||||||
libraryProject=libappbase
|
libraryProject=libappbase
|
||||||
baseVersion=15.15
|
baseVersion=15.15
|
||||||
publishVersion=15.15.7
|
publishVersion=15.15.7
|
||||||
buildCount=14
|
buildCount=25
|
||||||
baseBetaVersion=15.15.8
|
baseBetaVersion=15.15.8
|
||||||
|
|||||||
@@ -23,10 +23,14 @@ public class SignGetDialog extends Dialog {
|
|||||||
private EditText etSignFingerprint;
|
private EditText etSignFingerprint;
|
||||||
private TextView tvAuthResult;
|
private TextView tvAuthResult;
|
||||||
private Context mContext;
|
private Context mContext;
|
||||||
|
String projectName;
|
||||||
|
String versionName;
|
||||||
|
|
||||||
public SignGetDialog(Context context) {
|
public SignGetDialog(Context context, String projectName, String versionName) {
|
||||||
super(context, R.style.DialogStyle); // 适配默认对话框样式
|
super(context, R.style.DialogStyle); // 适配默认对话框样式
|
||||||
this.mContext = context;
|
this.mContext = context;
|
||||||
|
this.projectName = projectName;
|
||||||
|
this.versionName = versionName;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -47,23 +51,12 @@ public class SignGetDialog extends Dialog {
|
|||||||
|
|
||||||
// 核心:获取签名+调用APPUtils校验
|
// 核心:获取签名+调用APPUtils校验
|
||||||
private void initSignAndCheck() {
|
private void initSignAndCheck() {
|
||||||
// 1. 获取当前应用签名
|
|
||||||
// String sign = getCurrentSign();
|
|
||||||
// if (sign == null) {
|
|
||||||
// etSignFingerprint.setText("签名获取失败");
|
|
||||||
// } else {
|
|
||||||
// // 签名字符串转0/1 bit数组(每2个bit加空格,每16位换行,下一行无前置空格)
|
|
||||||
// String bitArrayStr = convertSignToBitArrayWithWrap(sign);
|
|
||||||
// etSignFingerprint.setText(bitArrayStr);
|
|
||||||
// }
|
|
||||||
// LogUtils.d(TAG, "当前应用签名:" + sign);
|
|
||||||
|
|
||||||
// 2. 正版校验+显示结果
|
// 2. 正版校验+显示结果
|
||||||
// 调用处直接删除base64SignFingerprint参数即可
|
// 调用处直接删除base64SignFingerprint参数即可
|
||||||
new APPUtils().checkAPKSignature(
|
new APPUtils().checkAPKValidation(
|
||||||
mContext,
|
mContext,
|
||||||
"WinBoLL", // projectName
|
this.projectName,
|
||||||
"WinBoLL_15.11.11.apk", // apkFileName
|
this.versionName,
|
||||||
new APPUtils.CheckResultCallback() {
|
new APPUtils.CheckResultCallback() {
|
||||||
@Override
|
@Override
|
||||||
public void onResult(boolean isValid, String message) {
|
public void onResult(boolean isValid, String message) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package cc.winboll.studio.libappbase.utils;
|
|||||||
import cc.winboll.studio.libappbase.LogUtils;
|
import cc.winboll.studio.libappbase.LogUtils;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
@@ -12,19 +13,33 @@ import java.util.jar.JarEntry;
|
|||||||
import java.util.jar.JarFile;
|
import java.util.jar.JarFile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* APK文件工具类(单例)- 终极稳定版
|
* APK文件工具类(单例)- 生产级签名+哈希双校验版(修复Too short异常)
|
||||||
* 适配APK的PKCS7格式CERT.RSA,直接读取原始字节,与客户端getAppSignFingerprint1:1对齐
|
* 1. 稳定解析CERT.RSA原始字节,与客户端Signature.toByteArray()1:1对齐,解决X509解析异常
|
||||||
* 解决Too short解析异常,保证签名稳定解析
|
* 2. 支持SHA256文件哈希字节级唯一校验,签名+哈希双重验证
|
||||||
|
* 3. 入参包含:项目名/版本名/APK名/客户端签名/客户端哈希,适配生产级版本管理
|
||||||
|
* 4. APK路径规范:apks_root/项目名/debug/tag/APK文件(支持调试/正式环境)
|
||||||
* @Author ZhanGSKen<zhangsken@qq.com>
|
* @Author ZhanGSKen<zhangsken@qq.com>
|
||||||
*/
|
*/
|
||||||
public class APKFileUtils {
|
public class APKFileUtils {
|
||||||
|
// 单例实例
|
||||||
private static volatile APKFileUtils sInstance;
|
private static volatile APKFileUtils sInstance;
|
||||||
|
// 配置项
|
||||||
private static final String CONFIG_SECTION = "APP";
|
private static final String CONFIG_SECTION = "APP";
|
||||||
private static final String KEY_APKS_FOLDER = "apks_folder_path";
|
private static final String KEY_APKS_FOLDER = "apks_folder_path";
|
||||||
|
// 算法常量(与客户端严格对齐)
|
||||||
|
private static final String SIGN_ALGORITHM = "SHA1"; // 签名摘要算法
|
||||||
|
private static final String HASH_ALGORITHM = "SHA-256"; // 文件哈希算法
|
||||||
|
// 签名文件(兼容大小写,适配所有打包工具)
|
||||||
|
private static final String CERT_RSA_UPPER = "META-INF/CERT.RSA";
|
||||||
|
private static final String CERT_RSA_LOWER = "META-INF/cert.rsa";
|
||||||
|
// APK根目录
|
||||||
private String apksRootPath;
|
private String apksRootPath;
|
||||||
|
|
||||||
private APKFileUtils() {}
|
private APKFileUtils() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化工具类(需在应用启动时调用)
|
||||||
|
*/
|
||||||
public static void init() {
|
public static void init() {
|
||||||
if (sInstance == null) {
|
if (sInstance == null) {
|
||||||
synchronized (APKFileUtils.class) {
|
synchronized (APKFileUtils.class) {
|
||||||
@@ -36,23 +51,33 @@ public class APKFileUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单例实例
|
||||||
|
*/
|
||||||
public static APKFileUtils getInstance() {
|
public static APKFileUtils getInstance() {
|
||||||
if (sInstance == null) {
|
if (sInstance == null) {
|
||||||
LogUtils.e("APKFileUtils", "请先调用init()初始化");
|
LogUtils.e("APKFileUtils", "请先调用init()初始化工具类");
|
||||||
throw new IllegalStateException("APKFileUtils未初始化,请先调用init()");
|
throw new IllegalStateException("APKFileUtils未初始化,请先调用init()");
|
||||||
}
|
}
|
||||||
return sInstance;
|
return sInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载配置文件中的APK根目录
|
||||||
|
*/
|
||||||
// private void loadConfig() {
|
// private void loadConfig() {
|
||||||
// try {
|
// try {
|
||||||
// apksRootPath = IniConfigUtils.getConfigValue(CONFIG_SECTION, KEY_APKS_FOLDER, "").trim();
|
// apksRootPath = IniConfigUtils.getConfigValue(CONFIG_SECTION, KEY_APKS_FOLDER, "").trim();
|
||||||
// if (apksRootPath.isEmpty()) {
|
// if (apksRootPath.isEmpty()) {
|
||||||
// LogUtils.e("APKFileUtils", "apks_folder_path配置为空");
|
// LogUtils.e("APKFileUtils", "配置项apks_folder_path为空,初始化失败");
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
// File rootDir = new File(apksRootPath);
|
// File rootDir = new File(apksRootPath);
|
||||||
// if (!rootDir.exists()) rootDir.mkdirs();
|
// if (!rootDir.exists() && !rootDir.mkdirs()) {
|
||||||
|
// LogUtils.e("APKFileUtils", "APK根目录创建失败:" + apksRootPath);
|
||||||
|
// apksRootPath = "";
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
// LogUtils.i("APKFileUtils", "APK根目录加载成功:" + apksRootPath);
|
// LogUtils.i("APKFileUtils", "APK根目录加载成功:" + apksRootPath);
|
||||||
// } catch (Exception e) {
|
// } catch (Exception e) {
|
||||||
// LogUtils.e("APKFileUtils", "加载APK根目录配置失败", e);
|
// LogUtils.e("APKFileUtils", "加载APK根目录配置失败", e);
|
||||||
@@ -60,104 +85,198 @@ public class APKFileUtils {
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
public static boolean checkAPKSignature(String projectName, String apkFileName, String clientSignBase64) {
|
/**
|
||||||
return getInstance().doCheckAPK(projectName, apkFileName, clientSignBase64);
|
* 对外暴露核心校验方法:签名 + SHA256文件哈希 双校验
|
||||||
}
|
* 入参包含:项目名/版本名/APK文件名/客户端签名Base64/客户端文件哈希
|
||||||
|
* APK路径规范:apksRootPath/项目名/版本名/APK文件
|
||||||
private boolean doCheckAPK(String projectName, String apkFileName, String clientSignBase64) {
|
* @param projectName 项目名(非空)
|
||||||
// 1. 入参校验
|
* @param versionName 版本名(非空,如15.11.11)
|
||||||
if (projectName == null || projectName.trim().isEmpty()
|
* @param apkFileName APK文件名(非空,需以.apk结尾)
|
||||||
|| apkFileName == null || apkFileName.trim().isEmpty()
|
* @param clientSignBase64 客户端传入的签名Base64(非空)
|
||||||
|| clientSignBase64 == null || clientSignBase64.trim().isEmpty()) {
|
* @param clientFileHash 客户端传入的APK文件SHA256哈希(小写/大写均可,非空)
|
||||||
LogUtils.w("APKFileUtils", "参数不能为空:projectName/apkFileName/clientSignBase64");
|
* @return 校验通过返回true,否则false
|
||||||
return false;
|
*/
|
||||||
}
|
public static boolean checkAPK(String projectName, String versionName, String apkFileName,
|
||||||
// 2. 根目录校验
|
String clientSignBase64, String clientFileHash) {
|
||||||
if (apksRootPath == null || apksRootPath.trim().isEmpty()) {
|
return getInstance().doCheckAPK(projectName, versionName, apkFileName, clientSignBase64, clientFileHash);
|
||||||
LogUtils.w("APKFileUtils", "APK根目录未配置:apks_folder_path");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// 3. APK文件校验
|
|
||||||
String apkFullPath = apksRootPath + File.separator + projectName
|
|
||||||
+ File.separator + "tag" + File.separator + apkFileName;
|
|
||||||
File apkFile = new File(apkFullPath);
|
|
||||||
if (!apkFile.exists() || !apkFile.isFile() || !apkFileName.endsWith(".apk")) {
|
|
||||||
LogUtils.w("APKFileUtils", "APK文件不存在或格式错误:" + apkFullPath);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 解析APK签名(终极稳定版:直接读取CERT.RSA原始字节,与客户端对齐)
|
|
||||||
String apkSignBase64 = getAPKSignFingerprint(apkFile);
|
|
||||||
if (apkSignBase64 == null || apkSignBase64.trim().isEmpty()) {
|
|
||||||
LogUtils.w("APKFileUtils", "解析APK签名失败,返回null/空值");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 签名对比
|
|
||||||
LogUtils.d("APKFileUtils", "【签名对比】APK解析签名:" + apkSignBase64);
|
|
||||||
LogUtils.d("APKFileUtils", "【签名对比】客户端传入签名:" + clientSignBase64);
|
|
||||||
boolean isMatch = apkSignBase64.equals(clientSignBase64);
|
|
||||||
LogUtils.i("APKFileUtils", "【签名对比结果】" + (isMatch ? "✅ 匹配" : "❌ 不匹配"));
|
|
||||||
return isMatch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 终极稳定解析:与客户端getAppSignFingerprint逻辑1:1完全对齐
|
* 核心校验实现:严格按「哈希先验,签名后验」顺序,哈希不匹配直接返回
|
||||||
* 直接读取CERT.RSA原始字节(适配PKCS7格式),跳过证书解析,彻底解决Too short异常
|
|
||||||
*/
|
*/
|
||||||
public String getAPKSignFingerprint(File apkFile) {
|
private boolean doCheckAPK(String projectName, String versionName, String apkFileName,
|
||||||
|
String clientSignBase64, String clientFileHash) {
|
||||||
|
// 1. 基础入参非空校验
|
||||||
|
if (isParamEmpty(projectName) || isParamEmpty(versionName) || isParamEmpty(apkFileName)
|
||||||
|
|| isParamEmpty(clientSignBase64) || isParamEmpty(clientFileHash)) {
|
||||||
|
LogUtils.w("APKFileUtils", "基础参数不能为空:projectName/versionName/apkFileName/clientSignBase64/clientFileHash");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 2. APK文件名格式校验
|
||||||
|
if (!apkFileName.endsWith(".apk")) {
|
||||||
|
LogUtils.w("APKFileUtils", "APK文件名格式错误,需以.apk结尾:" + apkFileName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 3. APK根目录校验
|
||||||
|
if (isParamEmpty(apksRootPath)) {
|
||||||
|
LogUtils.w("APKFileUtils", "APK根目录未配置,无法进行校验");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 4. 拼接标准APK路径:根目录/项目名/debug/项目名_版本名.apk(调试环境,可切换tag)
|
||||||
|
String apkFullPath = String.format("%s/%s/debug/%s_%s.apk",
|
||||||
|
apksRootPath,
|
||||||
|
projectName,
|
||||||
|
projectName,
|
||||||
|
versionName);
|
||||||
|
//正式环境路径(注释保留,切换时解开即可)
|
||||||
|
// String apkFullPath = String.format("%s/%s/tag/%s_%s.apk",
|
||||||
|
// apksRootPath,
|
||||||
|
// projectName,
|
||||||
|
// projectName,
|
||||||
|
// versionName);
|
||||||
|
LogUtils.d("APKFileUtils", String.format("apkFullPath : %s", apkFullPath));
|
||||||
|
File apkFile = new File(apkFullPath);
|
||||||
|
// 5. APK文件存在性校验
|
||||||
|
if (!apkFile.exists() || !apkFile.isFile()) {
|
||||||
|
LogUtils.w("APKFileUtils", "APK文件不存在或非文件类型:" + apkFullPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ===== 第一步:SHA256文件哈希校验(字节级唯一,优先级最高)=====
|
||||||
|
String serverFileHash = getAPKFileHash(apkFile);
|
||||||
|
if (isParamEmpty(serverFileHash)) {
|
||||||
|
LogUtils.w("APKFileUtils", "解析服务端APK文件哈希失败:" + apkFileName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
boolean isHashMatch = serverFileHash.equalsIgnoreCase(clientFileHash.trim());
|
||||||
|
LogUtils.d("APKFileUtils", "【哈希对比】服务端SHA256:" + serverFileHash);
|
||||||
|
LogUtils.d("APKFileUtils", "【哈希对比】客户端SHA256:" + clientFileHash.trim());
|
||||||
|
if (!isHashMatch) {
|
||||||
|
LogUtils.i("APKFileUtils", "【哈希对比结果】❌ 不匹配(字节级文件不一致)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
LogUtils.i("APKFileUtils", "【哈希对比结果】✅ 匹配(字节级文件完全一致)");
|
||||||
|
|
||||||
|
// ===== 第二步:签名校验(直接读取CERT.RSA原始字节,与客户端严格对齐)=====
|
||||||
|
String serverSignBase64 = getAPKSign(apkFile);
|
||||||
|
if (isParamEmpty(serverSignBase64)) {
|
||||||
|
LogUtils.w("APKFileUtils", "解析服务端APK签名失败:" + apkFileName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
boolean isSignMatch = serverSignBase64.equals(clientSignBase64.trim());
|
||||||
|
LogUtils.d("APKFileUtils", "【签名对比】服务端Base64:" + serverSignBase64);
|
||||||
|
LogUtils.d("APKFileUtils", "【签名对比】客户端Base64:" + clientSignBase64.trim());
|
||||||
|
if (!isSignMatch) {
|
||||||
|
LogUtils.i("APKFileUtils", "【签名对比结果】❌ 不匹配(签名不一致)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
LogUtils.i("APKFileUtils", "【签名对比结果】✅ 匹配(签名完全一致)");
|
||||||
|
|
||||||
|
// 所有校验通过
|
||||||
|
LogUtils.i("APKFileUtils", "APK双校验全部通过:项目名=" + projectName + ",版本名=" + versionName + ",文件名=" + apkFileName);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
LogUtils.e("APKFileUtils", "APK双校验异常", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 稳定解析APK签名:直接读取CERT.RSA原始字节,SHA1+Base64(与客户端1:1对齐)
|
||||||
|
* 解决X509证书解析的Too short异常,兼容所有APK(普通/加固/自定义打包)
|
||||||
|
* @param apkFile APK文件
|
||||||
|
* @return 签名Base64字符串,失败返回null
|
||||||
|
*/
|
||||||
|
private String getAPKSign(File apkFile) {
|
||||||
JarFile jarFile = null;
|
JarFile jarFile = null;
|
||||||
|
InputStream certIs = null;
|
||||||
try {
|
try {
|
||||||
jarFile = new JarFile(apkFile);
|
jarFile = new JarFile(apkFile);
|
||||||
// 适配APK标准签名文件:CERT.RSA(兼容大小写)
|
// 先找大写CERT.RSA,找不到再找小写,兼容所有打包工具
|
||||||
JarEntry sigEntry = jarFile.getJarEntry("META-INF/CERT.RSA");
|
JarEntry certEntry = jarFile.getJarEntry(CERT_RSA_UPPER);
|
||||||
if (sigEntry == null) {
|
if (certEntry == null) {
|
||||||
sigEntry = jarFile.getJarEntry("META-INF/cert.rsa");
|
certEntry = jarFile.getJarEntry(CERT_RSA_LOWER);
|
||||||
if (sigEntry == null) {
|
if (certEntry == null) {
|
||||||
LogUtils.w("APKFileUtils", "APK中未找到META-INF/CERT.RSA(含大小写)");
|
LogUtils.w("APKFileUtils", "APK中未找到签名文件:META-INF/CERT.RSA/cert.rsa");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 核心:直接读取CERT.RSA的原始字节流(不做证书解析,适配PKCS7签名块)
|
||||||
// 核心:直接读取CERT.RSA原始字节(和客户端Signature.toByteArray()底层一致,适配PKCS7)
|
certIs = jarFile.getInputStream(certEntry);
|
||||||
InputStream is = jarFile.getInputStream(sigEntry);
|
byte[] sigRawBytes = readStreamToBytes(certIs);
|
||||||
byte[] sigRawBytes = readStreamToBytes(is);
|
|
||||||
if (sigRawBytes == null || sigRawBytes.length == 0) {
|
if (sigRawBytes == null || sigRawBytes.length == 0) {
|
||||||
LogUtils.w("APKFileUtils", "读取CERT.RSA原始字节为空");
|
LogUtils.w("APKFileUtils", "读取CERT.RSA原始字节为空");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// 与客户端完全一致的处理流程:SHA1摘要 → Base64编码(去换行)
|
||||||
|
MessageDigest md = MessageDigest.getInstance(SIGN_ALGORITHM);
|
||||||
|
byte[] signDigest = md.digest(sigRawBytes);
|
||||||
|
String signBase64 = Base64.getEncoder().encodeToString(signDigest)
|
||||||
|
.replaceAll("\\r", "").replaceAll("\\n", "");
|
||||||
|
|
||||||
// 与客户端完全一致的流程:原始字节 → SHA1摘要 → Base64编码(去换行=NO_WRAP)
|
LogUtils.d("APKFileUtils", "APK签名解析成功(Base64):" + signBase64);
|
||||||
MessageDigest md = MessageDigest.getInstance("SHA1");
|
|
||||||
md.update(sigRawBytes);
|
|
||||||
byte[] sha1Digest = md.digest();
|
|
||||||
// 标准Base64编码,去换行符(等效Android的Base64.NO_WRAP)
|
|
||||||
String signBase64 = Base64.getEncoder().encodeToString(sha1Digest)
|
|
||||||
.replaceAll("\\r", "").replaceAll("\\n", "");
|
|
||||||
|
|
||||||
LogUtils.d("APKFileUtils", "APK解析出的签名(Base64):" + signBase64);
|
|
||||||
return signBase64;
|
return signBase64;
|
||||||
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
} catch (NoSuchAlgorithmException e) {
|
||||||
LogUtils.e("APKFileUtils", "解析签名失败:SHA1算法不存在", e);
|
LogUtils.e("APKFileUtils", "解析签名失败:" + SIGN_ALGORITHM + "算法不存在", e);
|
||||||
return null;
|
return null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LogUtils.e("APKFileUtils", "解析APK签名异常", e);
|
LogUtils.e("APKFileUtils", "解析APK签名异常", e);
|
||||||
e.printStackTrace();
|
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (jarFile != null) {
|
// 强制关闭流资源,避免内存泄漏
|
||||||
|
try {
|
||||||
|
if (certIs != null) certIs.close();
|
||||||
|
if (jarFile != null) jarFile.close();
|
||||||
|
} catch (IOException e) {
|
||||||
|
LogUtils.e("APKFileUtils", "关闭签名文件流失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析APK文件的SHA256哈希(字节级唯一,任何字节修改都会改变)
|
||||||
|
* @param apkFile APK文件
|
||||||
|
* @return 小写64位SHA256哈希字符串,失败返回null
|
||||||
|
*/
|
||||||
|
private String getAPKFileHash(File apkFile) {
|
||||||
|
FileInputStream fis = null;
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance(HASH_ALGORITHM);
|
||||||
|
fis = new FileInputStream(apkFile);
|
||||||
|
byte[] buffer = new byte[8192]; // 8K缓冲区,提升大APK读取效率
|
||||||
|
int len;
|
||||||
|
while ((len = fis.read(buffer)) != -1) {
|
||||||
|
md.update(buffer, 0, len);
|
||||||
|
}
|
||||||
|
// 哈希字节转小写16进制字符串(64位,官方标准格式)
|
||||||
|
byte[] hashBytes = md.digest();
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (byte b : hashBytes) {
|
||||||
|
sb.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
String fileHash = sb.toString();
|
||||||
|
LogUtils.d("APKFileUtils", "APK文件SHA256哈希解析成功:" + fileHash);
|
||||||
|
return fileHash;
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
LogUtils.e("APKFileUtils", "获取文件哈希失败:" + HASH_ALGORITHM + "算法不存在", e);
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
LogUtils.e("APKFileUtils", "解析APK文件哈希异常", e);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (fis != null) {
|
||||||
try {
|
try {
|
||||||
jarFile.close();
|
fis.close();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LogUtils.e("APKFileUtils", "关闭JarFile流失败", e);
|
LogUtils.e("APKFileUtils", "关闭APK文件流失败", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 稳定的流转字节数组:适配各种输入流,无空指针/截断问题
|
* 流转字节数组工具方法:稳定读取任意输入流,无截断/空指针问题
|
||||||
*/
|
*/
|
||||||
private byte[] readStreamToBytes(InputStream is) throws IOException {
|
private byte[] readStreamToBytes(InputStream is) throws IOException {
|
||||||
if (is == null) {
|
if (is == null) {
|
||||||
@@ -165,16 +284,23 @@ public class APKFileUtils {
|
|||||||
return new byte[0];
|
return new byte[0];
|
||||||
}
|
}
|
||||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||||
byte[] buffer = new byte[4096]; // 加大缓冲区,适配大签名文件
|
byte[] buffer = new byte[4096];
|
||||||
int len;
|
int len;
|
||||||
while ((len = is.read(buffer)) != -1) {
|
while ((len = is.read(buffer)) != -1) {
|
||||||
bos.write(buffer, 0, len);
|
bos.write(buffer, 0, len);
|
||||||
}
|
}
|
||||||
byte[] result = bos.toByteArray();
|
byte[] result = bos.toByteArray();
|
||||||
// 关闭流(顺序不可换)
|
// 按顺序关闭流
|
||||||
is.close();
|
is.close();
|
||||||
bos.close();
|
bos.close();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具方法:判断参数是否为空(null/空字符串/全空格)
|
||||||
|
*/
|
||||||
|
private boolean isParamEmpty(String param) {
|
||||||
|
return param == null || param.trim().isEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import android.content.pm.Signature;
|
|||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.util.Base64;
|
import android.util.Base64;
|
||||||
|
|
||||||
import cc.winboll.studio.libappbase.GlobalApplication;
|
import cc.winboll.studio.libappbase.GlobalApplication;
|
||||||
import cc.winboll.studio.libappbase.LogUtils;
|
import cc.winboll.studio.libappbase.LogUtils;
|
||||||
import cc.winboll.studio.libappbase.models.SignCheckResponse;
|
import cc.winboll.studio.libappbase.models.SignCheckResponse;
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
@@ -21,6 +25,7 @@ import java.security.MessageDigest;
|
|||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.jar.JarEntry;
|
import java.util.jar.JarEntry;
|
||||||
import java.util.jar.JarFile;
|
import java.util.jar.JarFile;
|
||||||
|
|
||||||
import okhttp3.Call;
|
import okhttp3.Call;
|
||||||
import okhttp3.Callback;
|
import okhttp3.Callback;
|
||||||
import okhttp3.OkHttpClient;
|
import okhttp3.OkHttpClient;
|
||||||
@@ -29,89 +34,107 @@ import okhttp3.Response;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||||
* @Date 2026/01/20 19:17
|
* @CreateTime 2026-01-20 19:17:00
|
||||||
* @Describe APPUtils 应用包名、签名校验工具类(OKHTTP网络校验版,兼容Java7,含URL编码+APK包签名校验)
|
* @LastEditTime 2026-01-24 02:18:00
|
||||||
|
* @Describe APPUtils 应用包名、签名校验工具类(OKHTTP网络校验版,兼容Java7,含URL编码+APK包签名+SHA256哈希校验)
|
||||||
*/
|
*/
|
||||||
public class APPUtils {
|
public class APPUtils {
|
||||||
|
// ===================================== 全局常量/属性 =====================================
|
||||||
public static final String TAG = "APPUtils";
|
public static final String TAG = "APPUtils";
|
||||||
// 网络校验接口地址
|
// 网络校验接口地址
|
||||||
private static final String CHECK_API_URI = "api/app-signatures-check";
|
private static final String CHECK_API_URI = "api/app-signatures-check";
|
||||||
// OKHTTP客户端(单例复用)
|
// OKHTTP客户端(单例复用)
|
||||||
private static OkHttpClient sOkHttpClient = new OkHttpClient();
|
private static OkHttpClient sOkHttpClient = new OkHttpClient();
|
||||||
// Gson解析实例
|
// Gson解析实例(单例复用)
|
||||||
private static Gson sGson = new Gson();
|
private static Gson sGson = new Gson();
|
||||||
|
|
||||||
|
// ===================================== 对外核心方法 =====================================
|
||||||
/**
|
/**
|
||||||
* 检查应用合法性(包名校验+OKHTTP网络校验签名)
|
* 检查应用合法性(签名校验+APK哈希校验+网络接口校验)
|
||||||
* @param context 上下文
|
* @param context 上下文
|
||||||
* @param projectName 项目名称(入参)
|
* @param projectName 项目名称
|
||||||
* @param apkFileName APK文件名(入参)
|
* @param versionName 应用版本名
|
||||||
* @param callback 校验结果回调(主线程回调)
|
* @param callback 校验结果回调(主线程回调)
|
||||||
*/
|
*/
|
||||||
public void checkAPKSignature(Context context, String projectName, String apkFileName, final CheckResultCallback callback) {
|
public void checkAPKValidation(Context context, String projectName, String versionName, final CheckResultCallback callback) {
|
||||||
|
// 入参调试日志
|
||||||
|
LogUtils.d(TAG, "checkAPKValidation: 入参 projectName=" + projectName + ", versionName=" + versionName);
|
||||||
|
// 空参校验
|
||||||
if (context == null) {
|
if (context == null) {
|
||||||
LogUtils.w(TAG, "checkAPKSignature: context为空,跳过校验");
|
LogUtils.w(TAG, "checkAPKValidation: 入参context为空,跳过校验");
|
||||||
if (callback != null) {
|
if (callback != null) {
|
||||||
callback.onResult(false, "context为空");
|
callback.onResult(false, "context为空");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 校验剩余入参非空
|
if (projectName == null || projectName.trim().isEmpty()) {
|
||||||
if (projectName == null || projectName.trim().isEmpty()
|
LogUtils.w(TAG, "checkAPKValidation: 入参projectName为空,跳过校验");
|
||||||
|| apkFileName == null || apkFileName.trim().isEmpty()) {
|
|
||||||
String errorMsg = "校验入参为空,projectName/apkFileName不可为空";
|
|
||||||
LogUtils.e(TAG, "checkAPKSignature: " + errorMsg);
|
|
||||||
if (callback != null) {
|
if (callback != null) {
|
||||||
callback.onResult(false, errorMsg);
|
callback.onResult(false, "projectName为空");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (versionName == null || versionName.trim().isEmpty()) {
|
||||||
|
LogUtils.w(TAG, "checkAPKValidation: 入参versionName为空,跳过校验");
|
||||||
|
if (callback != null) {
|
||||||
|
callback.onResult(false, "versionName为空");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方式1:从PackageManager获取签名(原逻辑,快速)
|
// 调用签名/哈希获取方法
|
||||||
APKFileUtils.init();
|
LogUtils.d(TAG, "checkAPKValidation: 开始获取应用官方签名与APK SHA256哈希");
|
||||||
File apkFile = new File("/sdcard/WinBoLLStudio/APKs/WinBoLL/tag/WinBoLL_15.11.11.apk");
|
// String clientSign = getOfficialSignBase64(context);
|
||||||
String currentSign = APKFileUtils.getInstance().getAPKSignFingerprint(apkFile);
|
// String clientHash = getApkSHA256Hash(context);
|
||||||
//String currentSign = APKFileUtils.getInstance().getAPKSignFingerprint(getCurrentAppApkFile(context));
|
// 获取与服务端对齐的签名
|
||||||
LogUtils.d(TAG, String.format("currentSign : %s", currentSign));
|
String clientSign = ApkSignUtils.getApkSignAlignedWithServer(context);
|
||||||
// 方式2:从当前应用APK包文件解析签名(兜底,和服务端校验逻辑1:1对齐)
|
// 获取哈希(不变)
|
||||||
// if (currentSign == null) {
|
String clientHash = ApkSignUtils.getApkSHA256Hash(context);
|
||||||
// LogUtils.w(TAG, "checkAPKSignature: 从PackageManager获取签名失败,尝试从APK包文件解析");
|
// 传服务端校验
|
||||||
// currentSign = getAPKSignFingerprint(getCurrentAppApkFile(context));
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (currentSign == null) {
|
// 签名/哈希结果校验
|
||||||
String errorMsg = "获取应用签名失败(PackageManager+APK包解析均失败)";
|
if (clientSign == null) {
|
||||||
LogUtils.e(TAG, "checkAPKSignature: " + errorMsg);
|
LogUtils.e(TAG, "checkAPKValidation: 获取应用官方签名失败");
|
||||||
if (callback != null) {
|
if (callback != null) {
|
||||||
callback.onResult(false, errorMsg);
|
callback.onResult(false, "获取应用签名失败");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
LogUtils.d(TAG, "checkAPKSignature: 应用最终签名(SHA1+Base64)=" + currentSign);
|
if (clientHash == null) {
|
||||||
|
LogUtils.e(TAG, "checkAPKValidation: 获取APK SHA256哈希失败");
|
||||||
|
if (callback != null) {
|
||||||
|
callback.onResult(false, "获取APK哈希失败");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LogUtils.d(TAG, "checkAPKValidation: 签名获取成功,哈希获取成功");
|
||||||
|
|
||||||
// 对动态参数做URL编码,避免特殊字符(/、=、&、空格等)导致解析异常
|
// URL编码动态参数
|
||||||
|
LogUtils.d(TAG, "checkAPKValidation: 开始对动态参数进行URL编码");
|
||||||
String encodeProjectName = urlEncode(projectName);
|
String encodeProjectName = urlEncode(projectName);
|
||||||
String encodeApkFileName = urlEncode(apkFileName);
|
String encodeVersionName = urlEncode(versionName);
|
||||||
String encodeSignature = urlEncode(currentSign);
|
String encodeClientSign = urlEncode(clientSign);
|
||||||
LogUtils.d(TAG, "checkAPKSignature: URL编码后-项目名=" + encodeProjectName + ",APK名=" + encodeApkFileName + ",签名=" + encodeSignature);
|
String encodeClientHash = urlEncode(clientHash);
|
||||||
|
|
||||||
// 构建请求URL - 拼接**编码后**的参数
|
// 构建请求URL
|
||||||
String requestUrl = String.format("%s?projectName=%s&apkFileName=%s&signature=%s",
|
String requestUrl = String.format("%s?isDebug=%s&projectName=%s&versionName=%s&clientSign=%s&clientHash=%s",
|
||||||
GlobalApplication.getWinbollHost() + CHECK_API_URI,
|
GlobalApplication.getWinbollHost() + CHECK_API_URI,
|
||||||
|
String.format("%s", GlobalApplication.isDebugging()),
|
||||||
encodeProjectName,
|
encodeProjectName,
|
||||||
encodeApkFileName,
|
encodeVersionName,
|
||||||
encodeSignature);
|
encodeClientSign,
|
||||||
LogUtils.d(TAG, "checkAPKSignature: 发起网络校验请求,URL=" + requestUrl);
|
encodeClientHash);
|
||||||
|
LogUtils.d(TAG, "checkAPKValidation: 构建校验请求URL=" + requestUrl);
|
||||||
|
|
||||||
// OKHTTP发起异步GET请求
|
// 发起OKHTTP异步GET请求
|
||||||
|
LogUtils.d(TAG, "checkAPKValidation: 发起网络校验异步请求");
|
||||||
Request request = new Request.Builder().url(requestUrl).build();
|
Request request = new Request.Builder().url(requestUrl).build();
|
||||||
sOkHttpClient.newCall(request).enqueue(new Callback() {
|
sOkHttpClient.newCall(request).enqueue(new Callback() {
|
||||||
@Override
|
@Override
|
||||||
public void onFailure(Call call, IOException e) {
|
public void onFailure(Call call, IOException e) {
|
||||||
final String errorMsg = "网络校验请求失败:" + e.getMessage();
|
final String errorMsg = "网络校验请求失败:" + e.getMessage();
|
||||||
LogUtils.e(TAG, "checkAPKSignature: " + errorMsg, e);
|
LogUtils.e(TAG, "checkAPKValidation: " + errorMsg, e);
|
||||||
if (callback != null) {
|
if (callback != null) {
|
||||||
// 切换到主线程回调(Java7 匿名Runnable)
|
|
||||||
new Handler(Looper.getMainLooper()).post(new Runnable() {
|
new Handler(Looper.getMainLooper()).post(new Runnable() {
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
@@ -125,13 +148,13 @@ public class APPUtils {
|
|||||||
public void onResponse(Call call, Response response) throws IOException {
|
public void onResponse(Call call, Response response) throws IOException {
|
||||||
if (response.isSuccessful() && response.body() != null) {
|
if (response.isSuccessful() && response.body() != null) {
|
||||||
String responseJson = response.body().string();
|
String responseJson = response.body().string();
|
||||||
LogUtils.d(TAG, "checkAPKSignature: 网络校验响应JSON=" + responseJson);
|
LogUtils.d(TAG, "checkAPKValidation: 网络校验响应JSON=" + responseJson);
|
||||||
// 解析JSON响应
|
// 解析响应结果
|
||||||
final SignCheckResponse checkResponse = sGson.fromJson(responseJson, SignCheckResponse.class);
|
final SignCheckResponse checkResponse = sGson.fromJson(responseJson, SignCheckResponse.class);
|
||||||
final boolean isValid = checkResponse != null && checkResponse.isValid();
|
final boolean isValid = checkResponse != null && checkResponse.isValid();
|
||||||
final String msg = checkResponse != null ? checkResponse.getMessage() : "响应解析失败";
|
final String msg = checkResponse != null ? checkResponse.getMessage() : "响应解析失败";
|
||||||
|
LogUtils.d(TAG, "checkAPKValidation: 校验结果解析完成,isValid=" + isValid + ", msg=" + msg);
|
||||||
if (callback != null) {
|
if (callback != null) {
|
||||||
// 切换到主线程回调(Java7 匿名Runnable)
|
|
||||||
new Handler(Looper.getMainLooper()).post(new Runnable() {
|
new Handler(Looper.getMainLooper()).post(new Runnable() {
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
@@ -141,9 +164,8 @@ public class APPUtils {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final String errorMsg = "网络校验响应失败,code=" + response.code();
|
final String errorMsg = "网络校验响应失败,code=" + response.code();
|
||||||
LogUtils.e(TAG, "checkAPKSignature: " + errorMsg);
|
LogUtils.e(TAG, "checkAPKValidation: " + errorMsg);
|
||||||
if (callback != null) {
|
if (callback != null) {
|
||||||
// 切换到主线程回调(Java7 匿名Runnable)
|
|
||||||
new Handler(Looper.getMainLooper()).post(new Runnable() {
|
new Handler(Looper.getMainLooper()).post(new Runnable() {
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
@@ -156,19 +178,19 @@ public class APPUtils {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ===================================== 内部工具方法 =====================================
|
||||||
/**
|
/**
|
||||||
* 工具方法:获取当前应用的APK包文件对象
|
* 获取当前应用的APK包文件对象
|
||||||
* @param context 上下文
|
* @param context 上下文
|
||||||
* @return 当前应用APK文件File,失败返回null
|
* @return APK文件File,失败返回null
|
||||||
*/
|
*/
|
||||||
private File getCurrentAppApkFile(Context context) {
|
private File getCurrentAppApkFile(Context context) {
|
||||||
try {
|
try {
|
||||||
// 从PackageManager获取当前应用的APK安装路径
|
|
||||||
ApplicationInfo appInfo = context.getPackageManager().getApplicationInfo(
|
ApplicationInfo appInfo = context.getPackageManager().getApplicationInfo(
|
||||||
context.getPackageName(), 0
|
context.getPackageName(), 0
|
||||||
);
|
);
|
||||||
String apkPath = appInfo.sourceDir;
|
String apkPath = appInfo.sourceDir;
|
||||||
LogUtils.d(TAG, "getCurrentAppApkFile: 当前应用APK路径=" + apkPath);
|
|
||||||
File apkFile = new File(apkPath);
|
File apkFile = new File(apkPath);
|
||||||
return apkFile.exists() && apkFile.isFile() ? apkFile : null;
|
return apkFile.exists() && apkFile.isFile() ? apkFile : null;
|
||||||
} catch (PackageManager.NameNotFoundException e) {
|
} catch (PackageManager.NameNotFoundException e) {
|
||||||
@@ -181,88 +203,28 @@ public class APPUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 核心方法:复刻Android系统Signature解析逻辑
|
* 复刻Android系统Signature的DER编码解析逻辑
|
||||||
* 从CERT.RSA提取与signatures[0].toByteArray()一致的字节,再走SHA1+Base64
|
* 从CERT.RSA的DER字节中提取与signatures[0].toByteArray()一致的签名字节
|
||||||
*/
|
* @param derBytes CERT.RSA的DER编码字节
|
||||||
private String getAPKSignFingerprint(File apkFile) {
|
* @return 标准签名字节,失败返回null
|
||||||
// 先判空APK文件,避免空指针
|
|
||||||
if (apkFile == null || !apkFile.exists() || !apkFile.isFile()) {
|
|
||||||
LogUtils.w(TAG, "getAPKSignFingerprint: APK文件为空或不存在");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
JarFile jarFile = null;
|
|
||||||
try {
|
|
||||||
jarFile = new JarFile(apkFile);
|
|
||||||
JarEntry sigEntry = jarFile.getJarEntry("META-INF/CERT.RSA");
|
|
||||||
if (sigEntry == null) {
|
|
||||||
LogUtils.w(TAG, "getAPKSignFingerprint: APK中未找到META-INF/CERT.RSA");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 读取CERT.RSA原始DER编码字节
|
|
||||||
byte[] rsaDerBytes = readStreamToBytes(jarFile.getInputStream(sigEntry));
|
|
||||||
if (rsaDerBytes == null || rsaDerBytes.length == 0) {
|
|
||||||
LogUtils.w(TAG, "getAPKSignFingerprint: 读取CERT.RSA字节为空");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 解析DER编码,提取Android系统标准Signatrue字节(与客户端完全一致)
|
|
||||||
byte[] androidSigBytes = parseDerForAndroidSignature(rsaDerBytes);
|
|
||||||
if (androidSigBytes == null || androidSigBytes.length == 0) {
|
|
||||||
LogUtils.w(TAG, "getAPKSignFingerprint: 解析Android标准Signature字节失败");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 与客户端完全相同的流程:SHA1摘要 → Android原生Base64.NO_WRAP(核心修复)
|
|
||||||
MessageDigest md = MessageDigest.getInstance("SHA1");
|
|
||||||
md.update(androidSigBytes);
|
|
||||||
byte[] sha1Digest = md.digest();
|
|
||||||
String signBase64 = Base64.encodeToString(sha1Digest, Base64.NO_WRAP);
|
|
||||||
|
|
||||||
LogUtils.d(TAG, "getAPKSignFingerprint: APK解析出的签名(Base64):" + signBase64);
|
|
||||||
return signBase64;
|
|
||||||
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
LogUtils.e(TAG, "getAPKSignFingerprint: 解析签名失败:SHA1算法不存在", e);
|
|
||||||
return null;
|
|
||||||
} catch (Exception e) {
|
|
||||||
LogUtils.e(TAG, "getAPKSignFingerprint: 解析APK签名异常", e);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
if (jarFile != null) {
|
|
||||||
try {
|
|
||||||
jarFile.close();
|
|
||||||
} catch (IOException e) {
|
|
||||||
LogUtils.e(TAG, "getAPKSignFingerprint: 关闭JarFile流失败", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关键解析:复刻Android系统android.content.pm.Signature的DER编码解析逻辑
|
|
||||||
* 从CERT.RSA的DER字节中,提取与signatures[0].toByteArray()完全一致的签名字节
|
|
||||||
*/
|
*/
|
||||||
private byte[] parseDerForAndroidSignature(byte[] derBytes) {
|
private byte[] parseDerForAndroidSignature(byte[] derBytes) {
|
||||||
try {
|
try {
|
||||||
int offset = 0;
|
int offset = 0;
|
||||||
// 跳过顶层SEQUENCE标签(0x30)
|
|
||||||
if (derBytes == null || derBytes.length < 2 || derBytes[offset++] != 0x30) {
|
if (derBytes == null || derBytes.length < 2 || derBytes[offset++] != 0x30) {
|
||||||
LogUtils.w(TAG, "parseDerForAndroidSignature: DER编码非标准SEQUENCE格式");
|
LogUtils.w(TAG, "parseDerForAndroidSignature: DER编码非标准SEQUENCE格式");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// 跳过顶层长度字段(处理短长度/长长度)
|
// 跳过长度字段
|
||||||
if (derBytes[offset] > 0x80) {
|
if (derBytes[offset] > 0x80) {
|
||||||
int lenLen = derBytes[offset++] & 0x7F;
|
int lenLen = derBytes[offset++] & 0x7F;
|
||||||
offset += lenLen;
|
offset += lenLen;
|
||||||
} else {
|
} else {
|
||||||
offset++;
|
offset++;
|
||||||
}
|
}
|
||||||
// 跳过证书主体字段,直到找到签名块的SEQUENCE标签,提取后续所有字节
|
// 提取签名块字节
|
||||||
while (offset < derBytes.length) {
|
while (offset < derBytes.length) {
|
||||||
if (derBytes[offset] == 0x30) {
|
if (derBytes[offset] == 0x30) {
|
||||||
// 提取签名块完整字节(与signatures[0].toByteArray()完全匹配)
|
|
||||||
byte[] sigBytes = new byte[derBytes.length - offset];
|
byte[] sigBytes = new byte[derBytes.length - offset];
|
||||||
System.arraycopy(derBytes, offset, sigBytes, 0, sigBytes.length);
|
System.arraycopy(derBytes, offset, sigBytes, 0, sigBytes.length);
|
||||||
return sigBytes;
|
return sigBytes;
|
||||||
@@ -272,13 +234,13 @@ public class APPUtils {
|
|||||||
LogUtils.w(TAG, "parseDerForAndroidSignature: DER编码中未找到签名块SEQUENCE");
|
LogUtils.w(TAG, "parseDerForAndroidSignature: DER编码中未找到签名块SEQUENCE");
|
||||||
return null;
|
return null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LogUtils.e(TAG, "parseDerForAndroidSignature: 解析DER编码为Android Signature字节失败", e);
|
LogUtils.e(TAG, "parseDerForAndroidSignature: 解析DER编码失败", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工具方法:将输入流转为字节数组(Java7适配,无第三方依赖)
|
* 输入流转字节数组(Java7适配,无第三方依赖)
|
||||||
* @param is 输入流
|
* @param is 输入流
|
||||||
* @return 字节数组,失败返回空数组
|
* @return 字节数组,失败返回空数组
|
||||||
* @throws IOException 流读取异常
|
* @throws IOException 流读取异常
|
||||||
@@ -292,29 +254,29 @@ public class APPUtils {
|
|||||||
bos.write(buffer, 0, len);
|
bos.write(buffer, 0, len);
|
||||||
}
|
}
|
||||||
byte[] result = bos.toByteArray();
|
byte[] result = bos.toByteArray();
|
||||||
// 关闭流(倒序关闭)
|
|
||||||
bos.close();
|
bos.close();
|
||||||
is.close();
|
is.close();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Java7适配URL编码工具(UTF-8,处理所有特殊字符:/、=、&、+、空格等)
|
* URL编码工具(Java7适配,UTF-8编码,处理特殊字符)
|
||||||
* @param content 待编码内容
|
* @param content 待编码内容
|
||||||
* @return 编码后的字符串,失败返回原内容
|
* @return 编码后的字符串,失败返回原内容
|
||||||
*/
|
*/
|
||||||
private static String urlEncode(String content) {
|
private static String urlEncode(String content) {
|
||||||
try {
|
try {
|
||||||
// 用URLEncoder.encode,指定UTF-8(Java7必须显式指定,避免平台默认编码问题)
|
|
||||||
return URLEncoder.encode(content, "UTF-8");
|
return URLEncoder.encode(content, "UTF-8");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LogUtils.e(TAG, "urlEncode: 编码失败,content=" + content, e);
|
LogUtils.e(TAG, "urlEncode: 编码失败,content=" + content, e);
|
||||||
return content; // 编码失败返回原内容,避免请求中断
|
return content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从PackageManager获取当前应用签名SHA1指纹(BASE64编码,快速获取)
|
* 从PackageManager获取应用签名SHA1指纹(BASE64编码,快速获取)
|
||||||
|
* @param context 上下文
|
||||||
|
* @return 签名Base64字符串,失败返回null
|
||||||
*/
|
*/
|
||||||
private static String getAppSignFingerprint(Context context) {
|
private static String getAppSignFingerprint(Context context) {
|
||||||
try {
|
try {
|
||||||
@@ -338,12 +300,15 @@ public class APPUtils {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 校验结果回调接口 ====================
|
// ===================================== 回调接口 =====================================
|
||||||
|
/**
|
||||||
|
* 校验结果回调接口(主线程调用)
|
||||||
|
*/
|
||||||
public interface CheckResultCallback {
|
public interface CheckResultCallback {
|
||||||
/**
|
/**
|
||||||
* 校验结果回调(主线程调用)
|
* 校验结果回调
|
||||||
* @param isValid 是否合法
|
* @param isValid 是否合法
|
||||||
* @param message 校验信息
|
* @param message 校验信息/错误信息
|
||||||
*/
|
*/
|
||||||
void onResult(boolean isValid, String message);
|
void onResult(boolean isValid, String message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package cc.winboll.studio.libappbase.utils;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.pm.ApplicationInfo;
|
||||||
|
import android.util.Base64;
|
||||||
|
|
||||||
|
import cc.winboll.studio.libappbase.LogUtils;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.jar.JarEntry;
|
||||||
|
import java.util.jar.JarFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||||
|
* @CreateTime 2026-01-24 10:00:00
|
||||||
|
* @LastEditTime 2026-01-24 16:45:00
|
||||||
|
* @Describe 客户端签名工具类:与服务端APKFileUtils签名/哈希校验逻辑严格对齐,兼容Java7
|
||||||
|
*/
|
||||||
|
public class ApkSignUtils {
|
||||||
|
// ===================================== 全局常量 =====================================
|
||||||
|
private static final String TAG = "ApkSignUtils";
|
||||||
|
private static final String CERT_RSA_UPPER = "META-INF/CERT.RSA";
|
||||||
|
private static final String CERT_RSA_LOWER = "META-INF/cert.rsa";
|
||||||
|
private static final String SIGN_ALGORITHM_SHA1 = "SHA1";
|
||||||
|
private static final String SIGN_ALGORITHM_SHA256 = "SHA-256";
|
||||||
|
private static final int BUFFER_SIZE_4K = 4096;
|
||||||
|
private static final int BUFFER_SIZE_8K = 8192;
|
||||||
|
|
||||||
|
// ===================================== 对外核心方法 =====================================
|
||||||
|
/**
|
||||||
|
* 获取与服务端对齐的签名Base64(核心方法)
|
||||||
|
* 直接读取APK内CERT.RSA原始字节 → SHA1摘要 → Base64.NO_WRAP编码,与服务端逻辑完全一致
|
||||||
|
* @param context 上下文,用于获取当前应用APK路径
|
||||||
|
* @return 签名Base64字符串,失败返回null
|
||||||
|
*/
|
||||||
|
public static String getApkSignAlignedWithServer(Context context) {
|
||||||
|
LogUtils.d(TAG, "getApkSignAlignedWithServer: 方法调用,开始获取服务端对齐签名");
|
||||||
|
if (context == null) {
|
||||||
|
LogUtils.w(TAG, "getApkSignAlignedWithServer: 入参context为空,直接返回null");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取当前应用APK的真实安装路径
|
||||||
|
ApplicationInfo appInfo = context.getApplicationContext().getApplicationInfo();
|
||||||
|
String apkPath = appInfo.sourceDir;
|
||||||
|
LogUtils.d(TAG, "getApkSignAlignedWithServer: 获取到当前应用APK路径=" + apkPath);
|
||||||
|
if (apkPath == null || apkPath.trim().isEmpty()) {
|
||||||
|
LogUtils.e(TAG, "getApkSignAlignedWithServer: 获取APK路径为空,获取签名失败");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 读取APK内的CERT.RSA原始字节流(兼容大小写命名)
|
||||||
|
byte[] certRawBytes = readCertRsaRawBytes(apkPath);
|
||||||
|
if (certRawBytes == null || certRawBytes.length == 0) {
|
||||||
|
LogUtils.e(TAG, "getApkSignAlignedWithServer: 读取CERT.RSA原始字节失败,字节数组为空");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
LogUtils.d(TAG, "getApkSignAlignedWithServer: 成功读取CERT.RSA原始字节,长度=" + certRawBytes.length);
|
||||||
|
|
||||||
|
// 3. SHA1摘要计算 + Base64.NO_WRAP编码(与服务端完全对齐)
|
||||||
|
MessageDigest md = MessageDigest.getInstance(SIGN_ALGORITHM_SHA1);
|
||||||
|
byte[] signDigest = md.digest(certRawBytes);
|
||||||
|
String signBase64 = Base64.encodeToString(signDigest, Base64.NO_WRAP);
|
||||||
|
LogUtils.d(TAG, "getApkSignAlignedWithServer: 成功计算服务端对齐签名Base64,完成方法执行");
|
||||||
|
return signBase64;
|
||||||
|
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
LogUtils.e(TAG, "getApkSignAlignedWithServer: 获取SHA1算法实例失败,获取签名失败", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LogUtils.e(TAG, "getApkSignAlignedWithServer: 获取服务端对齐签名发生未知异常", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前运行APK的SHA256哈希值
|
||||||
|
* 读取APK文件完整字节流计算SHA256,转小写64位16进制字符串,与服务端校验逻辑一致
|
||||||
|
* @param context 上下文,用于获取当前应用APK路径
|
||||||
|
* @return SHA256哈希小写字符串,失败返回null
|
||||||
|
*/
|
||||||
|
public static String getApkSHA256Hash(Context context) {
|
||||||
|
LogUtils.d(TAG, "getApkSHA256Hash: 方法调用,开始获取APK SHA256哈希值");
|
||||||
|
if (context == null) {
|
||||||
|
LogUtils.w(TAG, "getApkSHA256Hash: 入参context为空,直接返回null");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取当前应用APK的真实安装路径
|
||||||
|
ApplicationInfo appInfo = context.getApplicationContext().getApplicationInfo();
|
||||||
|
String apkPath = appInfo.sourceDir;
|
||||||
|
LogUtils.d(TAG, "getApkSHA256Hash: 获取到当前应用APK路径=" + apkPath);
|
||||||
|
if (apkPath == null || apkPath.trim().isEmpty()) {
|
||||||
|
LogUtils.e(TAG, "getApkSHA256Hash: 获取APK路径为空,获取哈希失败");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 读取APK文件并计算SHA256哈希
|
||||||
|
File apkFile = new File(apkPath);
|
||||||
|
MessageDigest md = MessageDigest.getInstance(SIGN_ALGORITHM_SHA256);
|
||||||
|
FileInputStream fis = new FileInputStream(apkFile);
|
||||||
|
byte[] buffer = new byte[BUFFER_SIZE_8K];
|
||||||
|
int len;
|
||||||
|
while ((len = fis.read(buffer)) != -1) {
|
||||||
|
md.update(buffer, 0, len);
|
||||||
|
}
|
||||||
|
fis.close();
|
||||||
|
|
||||||
|
// 3. 哈希字节转小写64位16进制字符串
|
||||||
|
byte[] hashBytes = md.digest();
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (byte b : hashBytes) {
|
||||||
|
sb.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
String sha256Hash = sb.toString();
|
||||||
|
LogUtils.d(TAG, "getApkSHA256Hash: 成功计算APK SHA256哈希值,完成方法执行");
|
||||||
|
return sha256Hash;
|
||||||
|
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
LogUtils.e(TAG, "getApkSHA256Hash: 获取SHA-256算法实例失败,获取哈希失败", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LogUtils.e(TAG, "getApkSHA256Hash: 获取APK SHA256哈希发生未知异常", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================== 内部工具方法 =====================================
|
||||||
|
/**
|
||||||
|
* 读取APK内CERT.RSA文件的原始字节流(兼容大小写命名:CERT.RSA/cert.rsa)
|
||||||
|
* @param apkPath 当前应用APK的完整安装路径
|
||||||
|
* @return CERT.RSA原始字节数组,失败返回null
|
||||||
|
* @throws Exception 流读取/APK解析异常
|
||||||
|
*/
|
||||||
|
private static byte[] readCertRsaRawBytes(String apkPath) throws Exception {
|
||||||
|
LogUtils.d(TAG, "readCertRsaRawBytes: 方法调用,APK路径=" + apkPath);
|
||||||
|
JarFile jarFile = new JarFile(apkPath);
|
||||||
|
JarEntry certEntry = null;
|
||||||
|
|
||||||
|
// 优先读取大写命名,找不到则读取小写命名
|
||||||
|
certEntry = jarFile.getJarEntry(CERT_RSA_UPPER);
|
||||||
|
if (certEntry == null) {
|
||||||
|
LogUtils.d(TAG, "readCertRsaRawBytes: 未找到META-INF/CERT.RSA,尝试读取META-INF/cert.rsa");
|
||||||
|
certEntry = jarFile.getJarEntry(CERT_RSA_LOWER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未找到有效CERT.RSA文件
|
||||||
|
if (certEntry == null) {
|
||||||
|
LogUtils.e(TAG, "readCertRsaRawBytes: APK内未找到CERT.RSA/cert.rsa签名文件");
|
||||||
|
jarFile.close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件原始字节流并关闭流
|
||||||
|
InputStream is = jarFile.getInputStream(certEntry);
|
||||||
|
byte[] bytes = readStreamToBytes(is);
|
||||||
|
is.close();
|
||||||
|
jarFile.close();
|
||||||
|
LogUtils.d(TAG, "readCertRsaRawBytes: 成功读取CERT.RSA字节,完成方法执行");
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输入流转字节数组(与服务端工具方法逻辑完全一致,4K缓冲区)
|
||||||
|
* @param is 待读取的输入流
|
||||||
|
* @return 字节数组,流为null返回空字节数组
|
||||||
|
* @throws IOException 流读取异常
|
||||||
|
*/
|
||||||
|
private static byte[] readStreamToBytes(InputStream is) throws IOException {
|
||||||
|
if (is == null) {
|
||||||
|
LogUtils.w(TAG, "readStreamToBytes: 入参输入流为null,返回空字节数组");
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||||
|
byte[] buffer = new byte[BUFFER_SIZE_4K];
|
||||||
|
int len;
|
||||||
|
while ((len = is.read(buffer)) != -1) {
|
||||||
|
bos.write(buffer, 0, len);
|
||||||
|
}
|
||||||
|
byte[] result = bos.toByteArray();
|
||||||
|
// 关闭流资源,避免泄漏
|
||||||
|
is.close();
|
||||||
|
bos.close();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ public class AboutView extends LinearLayout {
|
|||||||
@Override
|
@Override
|
||||||
public void onClick(View v) {
|
public void onClick(View v) {
|
||||||
LogUtils.d(TAG, "签名获取按钮点击,弹出SignGetDialog");
|
LogUtils.d(TAG, "签名获取按钮点击,弹出SignGetDialog");
|
||||||
new SignGetDialog(mContext).show(); // 弹出对话框
|
new SignGetDialog(mContext, mszAppGitName, mszAppVersionName).show(); // 弹出对话框
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ibWinBoLLHostDialog.setOnClickListener(new OnClickListener() {
|
ibWinBoLLHostDialog.setOnClickListener(new OnClickListener() {
|
||||||
|
|||||||
Reference in New Issue
Block a user