核心流程
客户端随机生成RSA密钥对 -> 发生RSA公钥从服务端获取加密的H5应用包 -> 客户端解压H5包到本地 -> WebView#loadUrl(index.html) -> WebViewClient#shouldInterceptRequest -> html/js/css等资源文件需要解密 -> 拦截解密返回新的WebResourceResponse。
注:RSA公钥用于加密H5资源文件所需的对称加密算法密码,RSA私钥则用于解密该密码。对于Android,RSA密钥对通过AndroidKeyStore进行管理;对于IOS,则通过KeyChainService进行管理。
效果图
- 已加密的H5应用包内的JS文件(RC4+BASE64)
- 客户端运行时动态解密后的JS内容
服务端加密代码片段
根据用户提供的RSA公钥及对称加密算法,随机产生密码对H5进行加密并打包。本文旨在提供一种思路,故只贴出部分关键代码。
<?php
/**
* -------------------------------------------------
* Copyright (C) 2019-2020 贵州纳雍穿青人李裕江
* -------
* File Created: 2020/11/20 20:25:44
* Author: liyujiang (1032694760@qq.com)
* Github: https://github.com/liyujiang-gzu
* -------
* HISTORY:
* Date By Comments
* -------------------------------------------------
*/
namespace Adm\Api;
...
$version = intval($this->version);
$publicKey = $this->publicKey;
$algorithm = strtolower($this->algorithm);
try {
$publicKey = file_get_contents(API_ROOT . '/public.key');
$sourceDir = API_ROOT . '/h5';
if (!is_dir($sourceDir)) {
throw new \Com\ApiException('H5应用目录不存在');
}
$metaJson = file_get_contents($sourceDir . '/metadata.json');
if (false === $metaJson) {
throw new \Com\ApiException('元数据读取失败');
}
$metadata = json_decode($metaJson, TRUE);
if (!isset($metadata['code'])) {
throw new \Com\ApiException('元数据解析失败');
}
if ($version >= intval($metadata['code'])) {
throw new \Com\ApiException('已经是最新版本');
}
$targetDir = API_ROOT . '/zip/' . md5($publicKey);
$extension = array('.js', '.css', '.html'/*, '.png', '.jpg', '.ico', '.woff', '.woff2'*/);
$password = \Com\Tool::createRandStr(10);
$cypher = \Com\RSA::encrypt($password, $publicKey);
if (false === $cypher) {
throw new \Com\ApiException('随机密码加密失败');
}
$this->encryptRecursion($sourceDir, $targetDir, $algorithm, $password, $extension);
$zipPath = API_ROOT . '/zip/' . $metadata['code'] . '.zip';
$archive = new \Com\PhpZip();
$archive->zip($zipPath, $targetDir);
$metadata['link'] = str_replace(API_ROOT, \Com\baseUrl(), $zipPath);
$metadata['algorithm'] = $algorithm;
$metadata['cypher'] = $cypher;
$metadata['extension'] = $extension;
if (false === file_put_contents($targetDir . '/metadata.json', json_encode($metadata))) {
throw new \Com\ApiException('元数据写入失败');
}
return $metadata;
} catch (\Exception $e) {
throw new \Com\ApiException($e);
}
}
private function encryptRecursion($sourceDir, $targetDir, $algorithm, $password, $extension)
{
$sourceDir = rtrim($sourceDir, '/');
$targetDir = rtrim($targetDir, '/');
$sourceDirRelative = str_replace(API_ROOT . '/h5', '', $sourceDir);
$targetDirRelative = str_replace(API_ROOT . '/zip', '', $targetDir);
if (!is_dir($targetDir)) {
if (!mkdir($targetDir)) {
throw new \Com\ApiException('临时目录创建失败:' . $targetDirRelative);
}
}
$dirPointer = opendir($sourceDir);
if (false === $dirPointer) {
throw new \Com\ApiException('H5应用目录打开失败:' . $sourceDirRelative);
}
while (false !== ($file = readdir($dirPointer))) {
if ($file != '.' && $file != '..' && $file != 'metadata.json') {
if (is_dir($sourceDir . '/' . $file)) {
$this->encryptRecursion($sourceDir . '/' . $file, $targetDir . '/' . $file, $algorithm, $password, $extension);
} else {
$data = \Com\readBinaryFile($sourceDir . '/' . $file);
if (false === $data) {
throw new \Com\ApiException('H5应用文件读取失败:' . $sourceDirRelative . '/' . $file);
}
$needEncrypt = false;
foreach ($extension as $value) {
if (substr_compare($file, $value, -1 * strlen($value)) === 0) {
$needEncrypt = true;
break;
}
}
$cryptData = false;
if ($needEncrypt) {
switch ($algorithm) {
case 'rc4':
$cryptData = \Com\RC4::convert($data, $password);
break;
case 'aes':
$cryptData = \Com\AES::encrypt($data, $password);
break;
case 'xxtea':
$cryptData = \Com\XXTEA::encrypt($data, $password);
break;
default:
throw new \Com\ApiException('不支持的对称加密算法:' . $algorithm);
break;
}
if (false === $cryptData) {
throw new \Com\ApiException('H5应用文件加密失败:' . $targetDirRelative . '/' . $file);
}
} else {
$cryptData = $data;
}
if (false === file_put_contents($targetDir . '/' . $file, $cryptData)) {
throw new \Com\ApiException('H5应用文件重写失败:' . $targetDirRelative . '/' . $file);
}
}
}
}
closedir($dirPointer);
}
...
客户端解密代码片段
以Android为例,根据用户自己的RSA私钥及H5包内元数据指定的对称加密算法及加密后的密码,对H5资源文件进行动态解密。本文旨在提供一种思路,故只贴出部分关键代码。
/*
* Copyright (c) 2019-present, 贵州纳雍穿青人李裕江<1032694760@qq.com>, All Right Reserved.
*/
package com.github.gzuliyujiang.app.fragment;
...
/**
* @author 贵州山魈羡民 (1032694760@qq.com)
* @since 2020/10/21 21:45
*/
public class HybridFragment extends WebFragment {
...
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
if (!AppConfig.isUseLocalH5InsteadRemote()) {
Logger.print("未开启使用本地资源,无需拦截处理:" + url);
return null;
}
Uri uri = Uri.parse(url);
String host = uri.getHost();
if (host == null) {
host = "";
}
String resPath = uri.getPath();
Logger.print("资源待拦截处理:url=" + url + ", host=" + host + ", path=" + resPath);
if (resPath == null) {
Logger.print("没有资源路径,不拦截处理:" + url);
return null;
}
if (!TextUtils.isEmpty(host) && !AppConfig.getIndexUrl().contains(host)) {
Logger.print("非指定的主机名(" + host + ", but " + AppConfig.getIndexUrl() + "),不拦截处理:" + url);
return null;
}
if (!url.toLowerCase().startsWith("file")) {
//非文件协议的资源路径是相对路径
if (resPath.startsWith("/")) {
resPath = resPath.substring(1);
}
}
String mimeType = FileUtils.getMimeType(resPath);
if (TextUtils.isEmpty(mimeType)) {
Logger.print("非指定的MIME类型(HTML/JS/CSS/PNG等),不拦截处理:" + url);
return null;
}
String localPath;
if (!url.toLowerCase().startsWith("file")) {
localPath = AppConfig.getHybridEncryptPath() + resPath;
} else {
//文件协议的资源路径已经是绝对路径
localPath = resPath;
}
if (FileUtils.exist(localPath)) {
Logger.print("拦截处理,加载本地资源:" + localPath);
boolean needEncrypt = false;
List<String> extensions = AppConfig.getEncryptExtensions();
for (String extension: extensions) {
if (resPath.endsWith(extension)) {
needEncrypt = true;
break;
}
}
return createLocalResourceResponse(needDecrypt, localPath, mimeType);
}
Logger.print("拦截处理,本地资源不存在:url=" + url + ", path=" + localPath);
return createNetworkResourceResponse(url, mimeType);
}
private WebResourceResponse createLocalResourceResponse(boolean needDecrypt, String path, String mimeType) {
try {
if (needDecrypt) {
String content = IOUtils.readStringThrown(path, "utf-8");
if (TextUtils.isEmpty(content)) {
Logger.print("本地资源读取失败,不拦截:" + path);
return null;
}
content = H5AppUtils.decryptDataThrown(AppConfig.getEncryptAlgorithm(), content);
if (TextUtils.isEmpty(content)) {
Logger.print("本地资源解出失败,不拦截:" + path);
return null;
}
Logger.print("本地资源已解出:" + path);
//noinspection CharsetObjectCanBeUsed
return new WebResourceResponse(mimeType, "utf-8", new ByteArrayInputStream(
content.getBytes(Charset.forName("utf-8"))
));
} else {
Logger.print("本地资源已加载:" + path);
return new WebResourceResponse(mimeType, "utf-8", new ByteArrayInputStream(
IOUtils.readBytesThrown(path))
);
}
} catch (Throwable e) {
Logger.print("本地资源处理出错,不拦截:" + path + ", " + ThrowableUtils.getFullStackTrace(e));
return null;
}
}
private WebResourceResponse createNetworkResourceResponse(String url, String mimeType) {
try {
return new WebResourceResponse(mimeType, "utf-8", new URL(url).openStream());
} catch (Throwable e) {
Logger.print("网络资源处理出错:" + url + ", " + ThrowableUtils.getFullStackTrace(e));
return null;
}
}
...
/*
* Copyright (c) 2019-present, 贵州纳雍穿青人李裕江<1032694760@qq.com>, All Right Reserved.
*/
package com.github.gzuliyujiang.app.util;
...
/**
* @author 贵州山魈羡民 (1032694760@qq.com)
* @since 2020/10/21 21:10
*/
public final class H5AppUtils {
...
public static String decryptDataThrown(String algorithm, String data) {
algorithm = algorithm.toLowerCase();
byte[] base64decode = Base64Utils.decode(data);
//从Android密钥库系统获取加密存储的密码
String password = getPasswordFromAndroidKeystore();
byte[] decode = null;
switch (algorithm) {
case "aes":
decode = AESUtils.decrypt(base64decode, password);
break;
case "rc4":
decode = RC4Utils.convert(base64decode, password);
break;
case "xxtea":
decode = XXTEAUtils.decrypt(base64decode, password);
break;
default:
throw new UnsupportedOperationException("不支持的对称加密算法:" + algorithm);
}
return new String(decode, CHARSET);
}
...