作为前端开发的我们应该知道,所有的面向用户的客户端代码其实都不安全。Android 和 IOS 相比与前端代码可能还相对安全(当然,其实现在各种反编译工具都非常成熟了,要是没做任何安全措施也差不多相当于裸奔),不过前端就更裸奔了,而且前端的代码由于是在浏览器中执行的,所以一般来说大部分前端网页或者应用都是 JavaScript 文本(当然现代化编译工具默认都是压缩了一下代码以及一定程度上的会混淆一下代码)。

Election 应用作为前端客户端解决方案,其实就是由主进程(node)以及渲染进程(chromium)执行主要逻辑以及 UI 渲染的,一般来说其实我们的逻辑基本上都是使用 JavaScript 来写的,然后会构建之后编译完的 JavaScript 代码就会随 Election 客户端压缩分发给用户,其实对于想修改或者二次分发的人来说,我们的代码就是处于裸奔状态,解包修改以及二次分发都非常容易。

这对于商业软件来说其实是非常危险和不可靠的,当然个人开发者来说也不希望自己的软件被人乱改。我就更加对这方面敏感了(因为我开始的时候就是搞破解游戏开始入门开发的,哈哈哈哈哈哈)

其实我 19 年就开始了解 Election 了,但是一直没有做啥正式的应用的其中一个原因就是觉得代码很容易被逆,不过用来做跨平台的 GUI 应用还是很爽的,尤其是对于我这种以前端技术栈为主的开发者来说。最近在搞一个工具本来做的是 Web 版的,不过我想了一下还是觉得用 Election 套一个壳对用户来说会方便很多,后来逐步把后端也用 Election 搞了,于是就想着发布的时候还是得加固一下,经过了解后发现目前主要加固手段还是分为混淆代码以及通过编译为 v8 的字节码两中主流方案,至于通过混淆代码的方案对于我自己来说还是相当于没搞(自己就能逆了),字节码至少对于我这种菜鸟来说还是麻烦很多的。

虽然 node 支持将 JavaScript 代码编译为 v8 字节码,但是目前 node 还不支持直接运行 v8 字节码。

不过社区已经提供了一个 bytenode 的解决方案,项目地址: https://github.com/bytenode/bytenode

其实在 bytenode 的 examples 中有一个 electron-hello-world 项目,就已经体现了如何在 election 中使用 bytenode 来编译 v8 字节码。但是相对于实际项目还是有部分需要注意的,在演示项目中是将项目源代码文件放在 main-window.src.js 文件中了,并且 main.js 中直接就写的是 bytenode 编译代码。首先在实际项目中不可能说把全部的代码结构都这样搞,其次就是我实际项目是使用 Typescript 写的。那么我的解决方案就是在编译前肯定是需要先将 Typescript 编译为 JavaScript 后再写一个 bytenode 编译脚本用于构建 v8 bytecode 最后再打包进入 Election 项目中。

首先在项目中安装 bytenode

npm install bytenode

然后定义一个编译 jsc 文件的函数(这里解释一下由于 node 默认是不能直接运行 v8 bytecode 所以 bytenode 有提供一个 LoaderFile 用于加载 bytecode),函数接收一个 source 文件路径参数,调用 bytenode.compileFile 编译 jsc 代码,调用 bytenode.addLoaderFile 生成 loaderfile 替换源代码文件。

const bytenode = require('bytenode')
const path = require('path')

// 构建 bytecode 文件
function buildFile(source) {
    const fileNameInfo = path.parse(source)
    if (fileNameInfo.ext == '.jsc') {
        console.warn('[Build]', `${source} it has been compiled.`);
        return
    }
    bytenode.compileFile(source, `${fileNameInfo.dir}/${fileNameInfo.name}.jsc`);
    bytenode.addLoaderFile(`${fileNameInfo.dir}/${fileNameInfo.name}.jsc`, `${fileNameInfo.base}`)

    console.log('[Build] success', source);
}

接下来通过递归编译文件夹下的 JavaScript 源代码文件

const fs = require('fs')
const path = require('path')
const process = require('process')
const console = require('console')

const codePath = path.join(__dirname, './dist')
const ignorePath = 'ui'

// 递归编译指定路径
function buildDir(dir) {
    const ignorePathList = (ignorePath || '').split(',')
    const files = fs.readdirSync(dir)
    files.forEach((file) => {
        const filePath = path.join(dir, file)

        // 检查文件是否在忽略列表
        for (const key in ignorePathList) {
            const name = ignorePathList[key]
            if (path.parse(name).base === file) {
                console.warn(`[Skip] ${filePath} hit ignore rule`)
                return
            }
        }

        try {
            const fileInfo = fs.statSync(filePath)
            if (fileInfo.isDirectory()) {
                // 是文件夹
                buildDir(filePath)
            } else {
                // 是文件
                buildFile(filePath)
            }
        } catch (error) {
            console.warn(`[Skip Build] ${filePath}`, error)
        }
    })
}

完整 jsc 编译脚本代码

const fs = require('fs')
const path = require('path')
const process = require('process')
const console = require('console')

const v8 = require('v8')
const bytenode = require('bytenode')

v8.setFlagsFromString('--no-lazy');

// const codePath = process.argv[2] // 编译目录
// const ignorePath = process.argv[3] || '' // 编译忽略规则

const codePath = path.join(__dirname, './dist')
const ignorePath = 'ui'

console.log('electron 环境版本:', process.versions.electron || '未知');
console.log('构建目录:', codePath);
if (ignorePath) {
    console.log('忽略规则:', ignorePath);
}

buildDir(codePath)
// buildFile(path.join(codePath, './main.js'))

console.log('[Build]', 'Bytecode build success.', '\n');
process.exit()


// 遍历构建
function buildDir(dir) {
    const ignorePathList = (ignorePath || '').split(',')
    const files = fs.readdirSync(dir)
    files.forEach((file) => {
        const filePath = path.join(dir, file)

        // 检查文件是否在忽略列表
        for (const key in ignorePathList) {
            const name = ignorePathList[key]
            if (path.parse(name).base === file) {
                console.warn(`[Skip] ${filePath} hit ignore rule`)
                return
            }
        }

        try {
            const fileInfo = fs.statSync(filePath)
            if (fileInfo.isDirectory()) {
                // 是文件夹
                buildDir(filePath)
            } else {
                // 是文件
                buildFile(filePath)
            }
        } catch (error) {
            console.warn(`[Skip Build] ${filePath}`, error)
        }
    })
}

// 构建 bytecode 文件
function buildFile(source) {
    const fileNameInfo = path.parse(source)
    if (fileNameInfo.ext == '.jsc') {
        console.warn('[Build]', `${source} it has been compiled.`);
        return
    }

    bytenode.compileFile(source, `${fileNameInfo.dir}/${fileNameInfo.name}.jsc`);
    bytenode.addLoaderFile(`${fileNameInfo.dir}/${fileNameInfo.name}.jsc`, `${fileNameInfo.base}`)

    console.log('[Build] success', source);
}

最后使用 electron 执行编译脚本,对 dist 文件夹中的代码文件进行编译。

npx electron build.js

如果你使用 electron builder 来编译应用的话,如果打包后出现 Invalid or incompatible cached data ...

这是由于编译时使用的是调试用的 electron 版本,打包时候的 electron 版本不一致导致的,在 electron builder 编译配置中将 electronVersion 版本设置为一样的就好了(我在编译脚本里已经打印输出了编译时的 electron 版本)。

const options = {
    ……
    buildVersion: buildVersion,
    electronVersion: '23.3.4',
    asar: true,
    directories: {
        output: 'out/${version}',
    },
    compression: 'maximum',
    files: ['dist/**/*'],
    ……
}

今天就先写到这里吧,如果希望更安全的话可以参考一下字节的那篇文章,给 bytecode 做混淆。

参考链接

https://github.com/bytenode/bytenode

基于 Node.js Addon 和 v8 字节码的 Electron 代码保护解决方案