# electron-asar-encrypt-demo **Repository Path**: xiondun/electron-asar-encrypt-demo ## Basic Information - **Project Name**: electron-asar-encrypt-demo - **Description**: No description available - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2024-03-15 - **Last Updated**: 2024-08-13 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 给 Electron 应用加密源码 The solution described in this demo repositry has been integrated into [asarmor](https://github.com/sleeyax/asarmor), if you would like to encrypt your electron project JavaScript source code like this demo, give it a try! ## Translations Great thanks to those who translated this README to other languages. * English - [sleeyax/electron-asar-encrypt-demo](https://github.com/sleeyax/electron-asar-encrypt-demo) * Portuguese - [maxwellcc/electron-asar-encrypt-demo](https://github.com/maxwellcc/electron-asar-encrypt-demo) ## 为什么会有这个仓库? 众所周知,[Electron](https://electronjs.org) 官方没有提供保护源码的方法。打包一个 Electron 应用,说白了就是[把源码拷到固定的一个地方](http://electronjs.org/docs/tutorial/application-distribution),比如在 Windows / Linux 下是 `resources/app` 这个目录。运行 Electron 应用时,Electron 就把这个目录当作一个 Node.js 项目去跑里面的 JS 代码。虽然 Electron 认识 ASAR 格式的代码包,即可以把所有的源码打包成一个 `app.asar` 文件放到 `resources` 目录,Electron 把 `app.asar` 当成一个文件夹,跑里面的代码,但是 ASAR 包中的文件是没有加密的,仅仅是把所有的文件拼接成了一个文件再加上了文件头信息,使用官方提供的 `asar` 库很容易把所有的源码从 ASAR 包提取出来,所以起不到加密的效果,只是对于初心者来说想接触到源码多了一点小门槛,稍微懂行一点的完全无压力。 所以我就在思考如何对 ASAR 包进行加密,防止商业源码被一些有心人士轻易篡改或注入一些恶意的代码后再分发。这里提供了一种不需要重新编译 Electron 即可完成加密的思路。 ## 跑起来 ``` bash git clone https://github.com/toyobayashi/electron-asar-encrypt-demo.git cd ./electron-asar-encrypt-demo npm install # 复制 electron release 到 test 目录 npm start # 编译然后启动应用 npm test # 编译然后跑测试 ``` ## 加密 以 AES-256-CBC 为例,先生成密钥保存在本地的文件中,方便 JS 打包脚本导入和 C++ include 内联。 ``` js // 这个脚本不会被打包进客户端,本地开发用 const fs = require('fs') const path = require('path') const crypto = require('crypto') fs.writeFileSync(path.join(__dirname, 'src/key.txt'), Array.prototype.map.call(crypto.randomBytes(32), (v => ('0x' + ('0' + v.toString(16)).slice(-2))))) ``` 这样就会在 `src` 生成一个 `key.txt` 文件,里面的内容是这样的: ``` 0x87,0xdb,0x34,0xc6,0x73,0xab,0xae,0xad,0x4b,0xbe,0x38,0x4b,0xf5,0xd4,0xb5,0x43,0xfe,0x65,0x1c,0xf5,0x35,0xbb,0x4a,0x78,0x0a,0x78,0x61,0x65,0x99,0x2a,0xf1,0xbb ``` 打包时做加密,利用 `asar` 库的 `asar.createPackageWithOptions()` 这个 API: ``` ts /// declare namespace asar { // ... export function createPackageWithOptions( src: string, dest: string, options: { // ... transform?: (filePath: string) => NodeJS.ReadWriteStream | void; } ): Promise } export = asar; ``` 在第三个参数中传入 `transform` 选项,它是一个函数,返回一个 `ReadWriteStream` 可读写流去处理文件,返回 `undefined` 则不对文件做处理。这一步对所有的 JS 文件加密后打进 ASAR 包中。 ``` js // 这个脚本不会被打包进客户端,本地开发用 const crypto = require('crypto') const path = require('path') const fs = require('fs') const asar = require('asar') // 读取密钥,弄成一个 Buffer const key = Buffer.from(fs.readFileSync(path.join(__dirname, 'src/key.txt'), 'utf8').trim().split(',').map(v => Number(v.trim()))) asar.createPackageWithOptions( path.join(__dirname, './app'), path.join(__dirname, './test/resources/app.asar'), { unpack: '*.node', // C++ 模块不打包 transform (filename) { if (path.extname(filename) === '.js') { // 生成随机的 16 字节初始化向量 IV const iv = crypto.randomBytes(16) // 是否已把 IV 拼在了加密后数据的 let append = false const cipher = crypto.createCipheriv( 'aes-256-cbc', key, iv ) cipher.setAutoPadding(true) cipher.setEncoding('base64') // 重写 Readable.prototype.push 把 IV 拼在加密后数据的最前面 const _p = cipher.push cipher.push = function (chunk, enc) { if (!append && chunk != null) { append = true return _p.call(this, Buffer.concat([iv, chunk]), enc) } else { return _p.call(this, chunk, enc) } } return cipher } } } ) ``` ## 主进程解密 解密在客户端运行时做,因为 V8 引擎没法运行加密后的 JS,所以必须先解密后再丢给 V8 跑。这里就有讲究了,客户端代码是可以被任何人蹂躏的,所以密钥不能明着写,也不能放配置文件,所以只能下沉到 C++ 里。用 C++ 写一个原生模块实现解密,而且这个模块不能导出解密方法,否则没有意义。另外在 C++ 源码中密钥也不能写死成字符串,因为字符串在编译后的二进制文件中是可以直接找到的。 什么?不导出不是没法用吗?很简单,Hack 掉 Node.js 的 API,保证外部拿不到就 OK,然后直接把这个原生模块当作入口模块,在原生模块里面再 require 一下真正的入口 JS。以下是等价的 JS 逻辑: ``` js // 用 C++ 写以下逻辑,可以使密钥被编译进动态库 // 只有反编译动态库才有可能分析出来 // 禁止调试 for (let i = 0; i < process.argv.length; i++) { if (process.argv[i].startsWith('--inspect') || process.argv[i].startsWith('--remote-debugging-port')) { throw new Error('Not allow debugging this program.') } } const { app, dialog } = require('electron') const moduleParent = module.parent; if (module !== process.mainModule || (moduleParent !== Module && moduleParent !== undefined && moduleParent !== null)) { // 如果该原生模块不是入口,就报错退出 dialog.showErrorBox('Error', 'This program has been changed by others.') app.quit() } const Module = require('module') function getKey () { // 在这里内联由 JS 脚本生成的密钥 // const unsigned char key[32] = { // #include "key.txt" // }; return KEY } function decrypt (body) { // body 是 Buffer const iv = body.slice(0, 16) // 前 16 字节是 IV const data = body.slice(16) // 16 字节以后是加密后的代码 // 最好使用原生库来做解密,Node API 存在被拦截的风险 // const clearEncoding = 'utf8' // 输出是字符串 // const cipherEncoding = 'binary' // 输入是二进制 // const chunks = [] // 保存分段的字符串 // const decipher = require('crypto').createDecipheriv( // 'aes-256-cbc', // getKey(), // iv // ) // decipher.setAutoPadding(true) // chunks.push(decipher.update(data, cipherEncoding, clearEncoding)) // chunks.push(decipher.final(clearEncoding)) // const code = chunks.join('') // return code // [native code] } const oldCompile = Module.prototype._compile // 重写 Module.prototype._compile // 原因就不多写了,看下 Node 的源码就知道 Object.defineProperty(Module.prototype, '_compile', { enumerable: true, value: function (content, filename) { if (filename.indexOf('app.asar') !== -1) { // 如果这个 JS 是在 app.asar 里面,就先解密 return oldCompile.call(this, decrypt(Buffer.from(content, 'base64')), filename) } return oldCompile.call(this, content, filename) } }) try { // 主进程创建窗口在这里面,如果需要的话把密钥传给 JS,最好不要传 require('./main.js')(getKey()) } catch (err) { // 防止 Electron 报错后不退出 dialog.showErrorBox('Error', err.stack) app.quit() } ``` 要使用 C++ 写出上面的代码,有一个问题,如何在 C++ 里拿到 JS 的 `require` 函数呢? 看下 Node 源码就知道,调用 `require` 就是相当于调用 `Module.prototype.require`,所以只要能拿到 `module` 对象,也就能够拿到 `require` 函数。不幸的是,NAPI 没有在模块初始化的回调中暴露 `module` 对象,有人提了 PR 但是官方似乎考虑到某些原因(向 ES Module 标准看齐)并不想暴露 `module`,只暴露了 `exports` 对象,不像 Node CommonJS 模块中 JS 代码被一层函数包裹: ``` js function (exports, require, module, __filename, __dirname) { // 自己写的代码在这里 } ``` 仔细翻阅 Node.js 文档,可以在 process 章节里看到有 `global.process.mainModule` 这么个东西,也就是说入口模块是可以从全局拿到的,只要遍历模块的 `children` 数组往下找,对比 `module.exports` 等不等于 `exports`,就可以找到当前原生模块的 `module` 对象。 先封装一下运行脚本的方法。 ``` cpp #include #include "napi.h" // 先封装一下脚本运行方法 Napi::Value RunScript(Napi::Env& env, const Napi::String& script) { napi_value res; NAPI_THROW_IF_FAILED(env, napi_run_script(env, script, &res), env.Undefined()); return Napi::Value(env, res); // env.RunScript(script); } Napi::Value RunScript(Napi::Env& env, const std::string& script) { return RunScript(env, Napi::String::New(env, script)); // env.RunScript(script); } Napi::Value RunScript(Napi::Env& env, const char* script) { return RunScript(env, Napi::String::New(env, script)); // env.RunScript(script); } ``` `node-addon-api` v3 以上可以直接使用: ``` cpp Napi::Value Napi::Env::RunScript(const char* utf8script); Napi::Value Napi::Env::RunScript(const std::string& utf8script); Napi::Value Napi::Env::RunScript(Napi::String script); ``` 然后就可以愉快地 JS in C++ 了。 ``` cpp Napi::Value GetModuleObject(Napi::Env& env, const Napi::Object& main_module, const Napi::Object& exports) { std::string script = "(function (mainModule, exports) {\n" "function findModule(start, target) {\n" " if (start.exports === target) {\n" " return start;\n" " }\n" " for (var i = 0; i < start.children.length; i++) {\n" " var res = findModule(start.children[i], target);\n" " if (res) {\n" " return res;\n" " }\n" " }\n" " return null;\n" "}\n" "return findModule(mainModule, exports);\n" "});"; Napi::Function find_function = RunScript(env, script).As(); Napi::Value res = find_function({ main_module, exports }); if (res.IsNull()) { Napi::Error::New(env, "Cannot find module object.").ThrowAsJavaScriptException(); } return res; } Napi::Function MakeRequireFunction(Napi::Env& env, const Napi::Object& mod) { std::string script = "(function makeRequireFunction(mod) {\n" "const Module = mod.constructor;\n" "function validateString (value, name) { if (typeof value !== 'string') throw new TypeError('The \"' + name + '\" argument must be of type string. Received type ' + typeof value); }\n" "const require = function require(path) {\n" " return mod.require(path);\n" "};\n" "function resolve(request, options) {\n" "validateString(request, 'request');\n" "return Module._resolveFilename(request, mod, false, options);\n" "}\n" "require.resolve = resolve;\n" "function paths(request) {\n" "validateString(request, 'request');\n" "return Module._resolveLookupPaths(request, mod);\n" "}\n" "resolve.paths = paths;\n" "require.main = process.mainModule;\n" "require.extensions = Module._extensions;\n" "require.cache = Module._cache;\n" "return require;\n" "});"; Napi::Function make_require = RunScript(env, script).As(); return make_require({ mod }).As(); } ``` ``` cpp #include struct AddonData { // 存 Node 模块引用 // std::unordered_map modules; // 存函数引用 std::unordered_map functions; }; Napi::Value ModulePrototypeCompile(const Napi::CallbackInfo& info) { AddonData* addon_data = static_cast(info.Data()); Napi::Function old_compile = addon_data->functions["Module.prototype._compile"].Value(); // 这里推荐使用 C/C++ 的库来做解密 // ... } Napi::Object Init(Napi::Env env, Napi::Object exports) { #ifdef _TARGET_ELECTRON_RENDERER_ // const mainModule = window.module Napi::Object main_module = env.Global().Get("module").As(); #else Napi::Object process = env.Global().Get("process").As(); Napi::Array argv = process.Get("argv").As(); for (uint32_t i = 0; i < argv.Length(); ++i) { std::string arg = argv.Get(i).As().Utf8Value(); if (arg.find("--inspect") == 0 || arg.find("--remote-debugging-port") == 0) { Napi::Error::New(env, "Not allow debugging this program.") .ThrowAsJavaScriptException(); return exports; } } // const mainModule = process.mainModule Napi::Object main_module = process.Get("mainModule").As(); #endif Napi::Object this_module = GetModuleObject(&env, main_module, exports).As(); Napi::Function require = MakeRequireFunction(env, this_module); // const mainModule = process.mainModule Napi::Object main_module = env.Global().As().Get("process").As().Get("mainModule").As(); // const electron = require('electron') Napi::Object electron = require({ Napi::String::New(env, "electron") }).As(); // require('module') Napi::Object module_constructor = require({ Napi::String::New(env, "module") }).As(); // module.parent Napi::Value module_parent = this_module.Get("parent"); if (this_module != main_module || (module_parent != module_constructor && module_parent != env.Undefined() && module_parent != env.Null())) { // 入口模块不是当前的原生模块,可能会被拦截 API 导致泄露密钥 // 弹窗警告后退出 } AddonData* addon_data = env.GetInstanceData(); if (addon_data == nullptr) { addon_data = new AddonData(); env.SetInstanceData(addon_data); } // require('crypto') // addon_data->modules["crypto"] = Napi::Persistent(require({ Napi::String::New(env, "crypto") }).As()); Napi::Object module_prototype = module_constructor.Get("prototype").As(); addon_data->functions["Module.prototype._compile"] = Napi::Persistent(module_prototype.Get("_compile").As()); module_prototype["_compile"] = Napi::Function::New(env, ModulePrototypeCompile, "_compile", addon_data); try { require({ Napi::String::New(env, "./main.js") }).Call({ getKey() }); } catch (const Napi::Error& e) { // 弹窗后退出 // ... } return exports; } // 不要分号,NODE_API_MODULE 是个宏 NODE_API_MODULE(NODE_GYP_MODULE_NAME, Init) ``` 看到这里可能会问了搞了大半天为什么还要用 C++ 来写 JS 啊,不是明明可以 `RunScript()` 吗? 前面也已经提到过,直接 runScript 需要把 JS 写死成字符串,在编译后的二进制文件中是原样存在的,密钥会被泄露,用 C++ 来写这些逻辑可以增加反向的难度。 总结下就是这样的: 1. `main.node` (已编译) 里面 require `main.js` (已加密) 2. `main.js` (已加密)里面再 require 其它加密的 JS,创建窗口等等 特别注意,入口必须是 main.node,如果不是,则很有可能被攻击者在加载 main.node 之前的 JS 中 hack 掉 Node API 导致密钥泄露。比如这样的入口文件: ``` js const crypto = require('crypto') const old = crypto.createDecipheriv crypto.createDecipheriv = function (...args) { console.log(...args) // 密钥被输出 return old.call(crypto, ...args) } const Module = require('module') const oldCompile = Module.prototype._compile Module.prototype._compile = function (content, filename) { console.log(content) // JS 源码被输出 return oldCompile.call(this, content, filename) } process.argv.length = 1 require('./main.node') // 或 Module._load('./main.node', module, true) ``` ## 渲染进程解密 和主进程的逻辑类似,可以利用预定义宏在 C++ 中区分开主进程和渲染进程。为渲染进程再编译出一个 `renderer.node`。渲染进程加载的原生模块必须是 `上下文感知模块`,用 NAPI 写的模块已经是上下文感知的,所以没有问题,如果用 V8 的 API 去写就是不行的。 这里有个限制,不能在 HTML 中直接引用 `