From 2751ce4a39333355867860d31ecc10f08c8da167 Mon Sep 17 00:00:00 2001 From: ZhanGSKen Date: Sat, 24 Jan 2026 11:16:37 +0800 Subject: [PATCH] =?UTF-8?q?APK=E6=A0=A1=E9=AA=8C=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E8=B0=83=E8=AF=95=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appbase/build.properties | 4 +- libappbase/build.properties | 4 +- .../libappbase/dialogs/SignGetDialog.java | 23 +- .../studio/libappbase/utils/APKFileUtils.java | 278 +++++++++++++----- .../studio/libappbase/utils/APPUtils.java | 223 ++++++-------- .../studio/libappbase/utils/ApkSignUtils.java | 193 ++++++++++++ .../studio/libappbase/views/AboutView.java | 2 +- 7 files changed, 502 insertions(+), 225 deletions(-) create mode 100644 libappbase/src/main/java/cc/winboll/studio/libappbase/utils/ApkSignUtils.java diff --git a/appbase/build.properties b/appbase/build.properties index 75329db..4bbb551 100644 --- a/appbase/build.properties +++ b/appbase/build.properties @@ -1,8 +1,8 @@ #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 libraryProject=libappbase baseVersion=15.15 publishVersion=15.15.7 -buildCount=14 +buildCount=25 baseBetaVersion=15.15.8 diff --git a/libappbase/build.properties b/libappbase/build.properties index 75329db..4bbb551 100644 --- a/libappbase/build.properties +++ b/libappbase/build.properties @@ -1,8 +1,8 @@ #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 libraryProject=libappbase baseVersion=15.15 publishVersion=15.15.7 -buildCount=14 +buildCount=25 baseBetaVersion=15.15.8 diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/dialogs/SignGetDialog.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/dialogs/SignGetDialog.java index 413f64e..425ffa3 100644 --- a/libappbase/src/main/java/cc/winboll/studio/libappbase/dialogs/SignGetDialog.java +++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/dialogs/SignGetDialog.java @@ -23,10 +23,14 @@ public class SignGetDialog extends Dialog { private EditText etSignFingerprint; private TextView tvAuthResult; private Context mContext; + String projectName; + String versionName; - public SignGetDialog(Context context) { + public SignGetDialog(Context context, String projectName, String versionName) { super(context, R.style.DialogStyle); // 适配默认对话框样式 this.mContext = context; + this.projectName = projectName; + this.versionName = versionName; } @Override @@ -47,23 +51,12 @@ public class SignGetDialog extends Dialog { // 核心:获取签名+调用APPUtils校验 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. 正版校验+显示结果 // 调用处直接删除base64SignFingerprint参数即可 - new APPUtils().checkAPKSignature( + new APPUtils().checkAPKValidation( mContext, - "WinBoLL", // projectName - "WinBoLL_15.11.11.apk", // apkFileName + this.projectName, + this.versionName, new APPUtils.CheckResultCallback() { @Override public void onResult(boolean isValid, String message) { diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/APKFileUtils.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/APKFileUtils.java index eb843ce..ae8dcda 100644 --- a/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/APKFileUtils.java +++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/APKFileUtils.java @@ -3,6 +3,7 @@ package cc.winboll.studio.libappbase.utils; 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; @@ -12,19 +13,33 @@ import java.util.jar.JarEntry; import java.util.jar.JarFile; /** - * APK文件工具类(单例)- 终极稳定版 - * 适配APK的PKCS7格式CERT.RSA,直接读取原始字节,与客户端getAppSignFingerprint1:1对齐 - * 解决Too short解析异常,保证签名稳定解析 + * APK文件工具类(单例)- 生产级签名+哈希双校验版(修复Too short异常) + * 1. 稳定解析CERT.RSA原始字节,与客户端Signature.toByteArray()1:1对齐,解决X509解析异常 + * 2. 支持SHA256文件哈希字节级唯一校验,签名+哈希双重验证 + * 3. 入参包含:项目名/版本名/APK名/客户端签名/客户端哈希,适配生产级版本管理 + * 4. APK路径规范:apks_root/项目名/debug/tag/APK文件(支持调试/正式环境) * @Author ZhanGSKen */ public class APKFileUtils { + // 单例实例 private static volatile APKFileUtils sInstance; + // 配置项 private static final String CONFIG_SECTION = "APP"; 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 APKFileUtils() {} + /** + * 初始化工具类(需在应用启动时调用) + */ public static void init() { if (sInstance == null) { synchronized (APKFileUtils.class) { @@ -36,23 +51,33 @@ public class APKFileUtils { } } + /** + * 获取单例实例 + */ public static APKFileUtils getInstance() { if (sInstance == null) { - LogUtils.e("APKFileUtils", "请先调用init()初始化"); + LogUtils.e("APKFileUtils", "请先调用init()初始化工具类"); throw new IllegalStateException("APKFileUtils未初始化,请先调用init()"); } return sInstance; } + /** + * 加载配置文件中的APK根目录 + */ // private void loadConfig() { // try { // apksRootPath = IniConfigUtils.getConfigValue(CONFIG_SECTION, KEY_APKS_FOLDER, "").trim(); // if (apksRootPath.isEmpty()) { -// LogUtils.e("APKFileUtils", "apks_folder_path配置为空"); +// LogUtils.e("APKFileUtils", "配置项apks_folder_path为空,初始化失败"); // return; // } // 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); // } catch (Exception 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); - } - - private boolean doCheckAPK(String projectName, String apkFileName, String clientSignBase64) { - // 1. 入参校验 - if (projectName == null || projectName.trim().isEmpty() - || apkFileName == null || apkFileName.trim().isEmpty() - || clientSignBase64 == null || clientSignBase64.trim().isEmpty()) { - LogUtils.w("APKFileUtils", "参数不能为空:projectName/apkFileName/clientSignBase64"); - return false; - } - // 2. 根目录校验 - if (apksRootPath == null || apksRootPath.trim().isEmpty()) { - 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; + /** + * 对外暴露核心校验方法:签名 + SHA256文件哈希 双校验 + * 入参包含:项目名/版本名/APK文件名/客户端签名Base64/客户端文件哈希 + * APK路径规范:apksRootPath/项目名/版本名/APK文件 + * @param projectName 项目名(非空) + * @param versionName 版本名(非空,如15.11.11) + * @param apkFileName APK文件名(非空,需以.apk结尾) + * @param clientSignBase64 客户端传入的签名Base64(非空) + * @param clientFileHash 客户端传入的APK文件SHA256哈希(小写/大写均可,非空) + * @return 校验通过返回true,否则false + */ + public static boolean checkAPK(String projectName, String versionName, String apkFileName, + String clientSignBase64, String clientFileHash) { + return getInstance().doCheckAPK(projectName, versionName, apkFileName, clientSignBase64, clientFileHash); } /** - * 终极稳定解析:与客户端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; + InputStream certIs = null; try { jarFile = new JarFile(apkFile); - // 适配APK标准签名文件:CERT.RSA(兼容大小写) - JarEntry sigEntry = jarFile.getJarEntry("META-INF/CERT.RSA"); - if (sigEntry == null) { - sigEntry = jarFile.getJarEntry("META-INF/cert.rsa"); - if (sigEntry == null) { - LogUtils.w("APKFileUtils", "APK中未找到META-INF/CERT.RSA(含大小写)"); + // 先找大写CERT.RSA,找不到再找小写,兼容所有打包工具 + JarEntry certEntry = jarFile.getJarEntry(CERT_RSA_UPPER); + if (certEntry == null) { + certEntry = jarFile.getJarEntry(CERT_RSA_LOWER); + if (certEntry == null) { + LogUtils.w("APKFileUtils", "APK中未找到签名文件:META-INF/CERT.RSA/cert.rsa"); return null; } } - - // 核心:直接读取CERT.RSA原始字节(和客户端Signature.toByteArray()底层一致,适配PKCS7) - InputStream is = jarFile.getInputStream(sigEntry); - byte[] sigRawBytes = readStreamToBytes(is); + // 核心:直接读取CERT.RSA的原始字节流(不做证书解析,适配PKCS7签名块) + certIs = jarFile.getInputStream(certEntry); + byte[] sigRawBytes = readStreamToBytes(certIs); if (sigRawBytes == null || sigRawBytes.length == 0) { LogUtils.w("APKFileUtils", "读取CERT.RSA原始字节为空"); 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) - 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); + LogUtils.d("APKFileUtils", "APK签名解析成功(Base64):" + signBase64); return signBase64; - } catch (NoSuchAlgorithmException e) { - LogUtils.e("APKFileUtils", "解析签名失败:SHA1算法不存在", e); + LogUtils.e("APKFileUtils", "解析签名失败:" + SIGN_ALGORITHM + "算法不存在", e); return null; } catch (Exception e) { LogUtils.e("APKFileUtils", "解析APK签名异常", e); - e.printStackTrace(); return null; } 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 { - jarFile.close(); + fis.close(); } catch (IOException e) { - LogUtils.e("APKFileUtils", "关闭JarFile流失败", e); + LogUtils.e("APKFileUtils", "关闭APK文件流失败", e); } } } } /** - * 稳定的流转字节数组:适配各种输入流,无空指针/截断问题 + * 流转字节数组工具方法:稳定读取任意输入流,无截断/空指针问题 */ private byte[] readStreamToBytes(InputStream is) throws IOException { if (is == null) { @@ -165,16 +284,23 @@ public class APKFileUtils { return new byte[0]; } ByteArrayOutputStream bos = new ByteArrayOutputStream(); - byte[] buffer = new byte[4096]; // 加大缓冲区,适配大签名文件 + byte[] buffer = new byte[4096]; int len; while ((len = is.read(buffer)) != -1) { bos.write(buffer, 0, len); } byte[] result = bos.toByteArray(); - // 关闭流(顺序不可换) + // 按顺序关闭流 is.close(); bos.close(); return result; } + + /** + * 工具方法:判断参数是否为空(null/空字符串/全空格) + */ + private boolean isParamEmpty(String param) { + return param == null || param.trim().isEmpty(); + } } diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/APPUtils.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/APPUtils.java index 70e0b6f..c853eb4 100644 --- a/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/APPUtils.java +++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/APPUtils.java @@ -8,12 +8,16 @@ import android.content.pm.Signature; import android.os.Handler; import android.os.Looper; import android.util.Base64; + import cc.winboll.studio.libappbase.GlobalApplication; import cc.winboll.studio.libappbase.LogUtils; import cc.winboll.studio.libappbase.models.SignCheckResponse; + import com.google.gson.Gson; + import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.net.URLEncoder; @@ -21,6 +25,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.jar.JarEntry; import java.util.jar.JarFile; + import okhttp3.Call; import okhttp3.Callback; import okhttp3.OkHttpClient; @@ -29,89 +34,107 @@ import okhttp3.Response; /** * @Author 豆包&ZhanGSKen - * @Date 2026/01/20 19:17 - * @Describe APPUtils 应用包名、签名校验工具类(OKHTTP网络校验版,兼容Java7,含URL编码+APK包签名校验) + * @CreateTime 2026-01-20 19:17:00 + * @LastEditTime 2026-01-24 02:18:00 + * @Describe APPUtils 应用包名、签名校验工具类(OKHTTP网络校验版,兼容Java7,含URL编码+APK包签名+SHA256哈希校验) */ public class APPUtils { + // ===================================== 全局常量/属性 ===================================== public static final String TAG = "APPUtils"; // 网络校验接口地址 private static final String CHECK_API_URI = "api/app-signatures-check"; // OKHTTP客户端(单例复用) private static OkHttpClient sOkHttpClient = new OkHttpClient(); - // Gson解析实例 + // Gson解析实例(单例复用) private static Gson sGson = new Gson(); + // ===================================== 对外核心方法 ===================================== /** - * 检查应用合法性(包名校验+OKHTTP网络校验签名) - * @param context 上下文 - * @param projectName 项目名称(入参) - * @param apkFileName APK文件名(入参) - * @param callback 校验结果回调(主线程回调) + * 检查应用合法性(签名校验+APK哈希校验+网络接口校验) + * @param context 上下文 + * @param projectName 项目名称 + * @param versionName 应用版本名 + * @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) { - LogUtils.w(TAG, "checkAPKSignature: context为空,跳过校验"); + LogUtils.w(TAG, "checkAPKValidation: 入参context为空,跳过校验"); if (callback != null) { callback.onResult(false, "context为空"); } return; } - // 校验剩余入参非空 - if (projectName == null || projectName.trim().isEmpty() - || apkFileName == null || apkFileName.trim().isEmpty()) { - String errorMsg = "校验入参为空,projectName/apkFileName不可为空"; - LogUtils.e(TAG, "checkAPKSignature: " + errorMsg); + if (projectName == null || projectName.trim().isEmpty()) { + LogUtils.w(TAG, "checkAPKValidation: 入参projectName为空,跳过校验"); 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; } - // 方式1:从PackageManager获取签名(原逻辑,快速) - APKFileUtils.init(); - File apkFile = new File("/sdcard/WinBoLLStudio/APKs/WinBoLL/tag/WinBoLL_15.11.11.apk"); - String currentSign = APKFileUtils.getInstance().getAPKSignFingerprint(apkFile); - //String currentSign = APKFileUtils.getInstance().getAPKSignFingerprint(getCurrentAppApkFile(context)); - LogUtils.d(TAG, String.format("currentSign : %s", currentSign)); - // 方式2:从当前应用APK包文件解析签名(兜底,和服务端校验逻辑1:1对齐) -// if (currentSign == null) { -// LogUtils.w(TAG, "checkAPKSignature: 从PackageManager获取签名失败,尝试从APK包文件解析"); -// currentSign = getAPKSignFingerprint(getCurrentAppApkFile(context)); -// } + // 调用签名/哈希获取方法 + LogUtils.d(TAG, "checkAPKValidation: 开始获取应用官方签名与APK SHA256哈希"); +// String clientSign = getOfficialSignBase64(context); +// String clientHash = getApkSHA256Hash(context); + // 获取与服务端对齐的签名 + String clientSign = ApkSignUtils.getApkSignAlignedWithServer(context); + // 获取哈希(不变) + String clientHash = ApkSignUtils.getApkSHA256Hash(context); + // 传服务端校验 - if (currentSign == null) { - String errorMsg = "获取应用签名失败(PackageManager+APK包解析均失败)"; - LogUtils.e(TAG, "checkAPKSignature: " + errorMsg); + // 签名/哈希结果校验 + if (clientSign == null) { + LogUtils.e(TAG, "checkAPKValidation: 获取应用官方签名失败"); if (callback != null) { - callback.onResult(false, errorMsg); + callback.onResult(false, "获取应用签名失败"); } 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 encodeApkFileName = urlEncode(apkFileName); - String encodeSignature = urlEncode(currentSign); - LogUtils.d(TAG, "checkAPKSignature: URL编码后-项目名=" + encodeProjectName + ",APK名=" + encodeApkFileName + ",签名=" + encodeSignature); + String encodeVersionName = urlEncode(versionName); + String encodeClientSign = urlEncode(clientSign); + String encodeClientHash = urlEncode(clientHash); - // 构建请求URL - 拼接**编码后**的参数 - String requestUrl = String.format("%s?projectName=%s&apkFileName=%s&signature=%s", + // 构建请求URL + String requestUrl = String.format("%s?isDebug=%s&projectName=%s&versionName=%s&clientSign=%s&clientHash=%s", GlobalApplication.getWinbollHost() + CHECK_API_URI, + String.format("%s", GlobalApplication.isDebugging()), encodeProjectName, - encodeApkFileName, - encodeSignature); - LogUtils.d(TAG, "checkAPKSignature: 发起网络校验请求,URL=" + requestUrl); + encodeVersionName, + encodeClientSign, + encodeClientHash); + LogUtils.d(TAG, "checkAPKValidation: 构建校验请求URL=" + requestUrl); - // OKHTTP发起异步GET请求 + // 发起OKHTTP异步GET请求 + LogUtils.d(TAG, "checkAPKValidation: 发起网络校验异步请求"); Request request = new Request.Builder().url(requestUrl).build(); sOkHttpClient.newCall(request).enqueue(new Callback() { @Override public void onFailure(Call call, IOException e) { final String errorMsg = "网络校验请求失败:" + e.getMessage(); - LogUtils.e(TAG, "checkAPKSignature: " + errorMsg, e); + LogUtils.e(TAG, "checkAPKValidation: " + errorMsg, e); if (callback != null) { - // 切换到主线程回调(Java7 匿名Runnable) new Handler(Looper.getMainLooper()).post(new Runnable() { @Override public void run() { @@ -125,13 +148,13 @@ public class APPUtils { public void onResponse(Call call, Response response) throws IOException { if (response.isSuccessful() && response.body() != null) { String responseJson = response.body().string(); - LogUtils.d(TAG, "checkAPKSignature: 网络校验响应JSON=" + responseJson); - // 解析JSON响应 + LogUtils.d(TAG, "checkAPKValidation: 网络校验响应JSON=" + responseJson); + // 解析响应结果 final SignCheckResponse checkResponse = sGson.fromJson(responseJson, SignCheckResponse.class); final boolean isValid = checkResponse != null && checkResponse.isValid(); final String msg = checkResponse != null ? checkResponse.getMessage() : "响应解析失败"; + LogUtils.d(TAG, "checkAPKValidation: 校验结果解析完成,isValid=" + isValid + ", msg=" + msg); if (callback != null) { - // 切换到主线程回调(Java7 匿名Runnable) new Handler(Looper.getMainLooper()).post(new Runnable() { @Override public void run() { @@ -141,9 +164,8 @@ public class APPUtils { } } else { final String errorMsg = "网络校验响应失败,code=" + response.code(); - LogUtils.e(TAG, "checkAPKSignature: " + errorMsg); + LogUtils.e(TAG, "checkAPKValidation: " + errorMsg); if (callback != null) { - // 切换到主线程回调(Java7 匿名Runnable) new Handler(Looper.getMainLooper()).post(new Runnable() { @Override public void run() { @@ -156,19 +178,19 @@ public class APPUtils { }); } + + // ===================================== 内部工具方法 ===================================== /** - * 工具方法:获取当前应用的APK包文件对象 + * 获取当前应用的APK包文件对象 * @param context 上下文 - * @return 当前应用APK文件File,失败返回null + * @return APK文件File,失败返回null */ private File getCurrentAppApkFile(Context context) { try { - // 从PackageManager获取当前应用的APK安装路径 ApplicationInfo appInfo = context.getPackageManager().getApplicationInfo( context.getPackageName(), 0 ); String apkPath = appInfo.sourceDir; - LogUtils.d(TAG, "getCurrentAppApkFile: 当前应用APK路径=" + apkPath); File apkFile = new File(apkPath); return apkFile.exists() && apkFile.isFile() ? apkFile : null; } catch (PackageManager.NameNotFoundException e) { @@ -181,88 +203,28 @@ public class APPUtils { } /** - * 核心方法:复刻Android系统Signature解析逻辑 - * 从CERT.RSA提取与signatures[0].toByteArray()一致的字节,再走SHA1+Base64 - */ - private String getAPKSignFingerprint(File apkFile) { - // 先判空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()完全一致的签名字节 + * 复刻Android系统Signature的DER编码解析逻辑 + * 从CERT.RSA的DER字节中提取与signatures[0].toByteArray()一致的签名字节 + * @param derBytes CERT.RSA的DER编码字节 + * @return 标准签名字节,失败返回null */ private byte[] parseDerForAndroidSignature(byte[] derBytes) { try { int offset = 0; - // 跳过顶层SEQUENCE标签(0x30) if (derBytes == null || derBytes.length < 2 || derBytes[offset++] != 0x30) { LogUtils.w(TAG, "parseDerForAndroidSignature: DER编码非标准SEQUENCE格式"); return null; } - // 跳过顶层长度字段(处理短长度/长长度) + // 跳过长度字段 if (derBytes[offset] > 0x80) { int lenLen = derBytes[offset++] & 0x7F; offset += lenLen; } else { offset++; } - // 跳过证书主体字段,直到找到签名块的SEQUENCE标签,提取后续所有字节 + // 提取签名块字节 while (offset < derBytes.length) { if (derBytes[offset] == 0x30) { - // 提取签名块完整字节(与signatures[0].toByteArray()完全匹配) byte[] sigBytes = new byte[derBytes.length - offset]; System.arraycopy(derBytes, offset, sigBytes, 0, sigBytes.length); return sigBytes; @@ -272,13 +234,13 @@ public class APPUtils { LogUtils.w(TAG, "parseDerForAndroidSignature: DER编码中未找到签名块SEQUENCE"); return null; } catch (Exception e) { - LogUtils.e(TAG, "parseDerForAndroidSignature: 解析DER编码为Android Signature字节失败", e); + LogUtils.e(TAG, "parseDerForAndroidSignature: 解析DER编码失败", e); return null; } } /** - * 工具方法:将输入流转为字节数组(Java7适配,无第三方依赖) + * 输入流转字节数组(Java7适配,无第三方依赖) * @param is 输入流 * @return 字节数组,失败返回空数组 * @throws IOException 流读取异常 @@ -292,29 +254,29 @@ public class APPUtils { bos.write(buffer, 0, len); } byte[] result = bos.toByteArray(); - // 关闭流(倒序关闭) bos.close(); is.close(); return result; } /** - * Java7适配URL编码工具(UTF-8,处理所有特殊字符:/、=、&、+、空格等) + * URL编码工具(Java7适配,UTF-8编码,处理特殊字符) * @param content 待编码内容 * @return 编码后的字符串,失败返回原内容 */ private static String urlEncode(String content) { try { - // 用URLEncoder.encode,指定UTF-8(Java7必须显式指定,避免平台默认编码问题) return URLEncoder.encode(content, "UTF-8"); } catch (Exception 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) { try { @@ -338,12 +300,15 @@ public class APPUtils { return null; } - // ==================== 校验结果回调接口 ==================== + // ===================================== 回调接口 ===================================== + /** + * 校验结果回调接口(主线程调用) + */ public interface CheckResultCallback { /** - * 校验结果回调(主线程调用) + * 校验结果回调 * @param isValid 是否合法 - * @param message 校验信息 + * @param message 校验信息/错误信息 */ void onResult(boolean isValid, String message); } diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/ApkSignUtils.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/ApkSignUtils.java new file mode 100644 index 0000000..dfc163e --- /dev/null +++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/ApkSignUtils.java @@ -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 + * @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; + } +} + diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/views/AboutView.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/views/AboutView.java index 5f1852f..53d278b 100644 --- a/libappbase/src/main/java/cc/winboll/studio/libappbase/views/AboutView.java +++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/views/AboutView.java @@ -140,7 +140,7 @@ public class AboutView extends LinearLayout { @Override public void onClick(View v) { LogUtils.d(TAG, "签名获取按钮点击,弹出SignGetDialog"); - new SignGetDialog(mContext).show(); // 弹出对话框 + new SignGetDialog(mContext, mszAppGitName, mszAppVersionName).show(); // 弹出对话框 } }); ibWinBoLLHostDialog.setOnClickListener(new OnClickListener() {