类似于APICloud的H5应用服务端加密客户端动态解密的增强版H5应用加固方案

1 篇文章 0 订阅
1 篇文章 0 订阅

核心流程

客户端随机生成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);
    }

...

参考资料

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值