背景与目标
桌面端验配/业务软件常需要在本机保存结构化数据(客户、业务记录等),同时希望:
- 文件打开后无法直接阅读(至少挡掉随手改 JSON 的情况)
- 扩展名不是默认的
.json,降低用户误开、误传、误改的概率 - 只在主进程读写,渲染进程通过 IPC 访问,避免泄露文件系统能力
- API 尽量像操作内存对象 :
get/set,而不是手写大量fs.readFile/JSON.parse
默认的 electron-store 会把明文 JSON 写到 userData 下的 .json 文件里,不满足 1、2。好在它支持:
fileExtension:自定义后缀serialize/deserialize:自定义序列化管线cwd:自定义落盘目录
这两条钩子就是整套方案的核心。
最终效果示意
kotlin
安装目录/
├── YourApp.exe
└── data/
├── app-records.dat ← 加密后的 hex 文本,扩展名自定(这里假设为dat)
└── app-entities.dat
用记事本打开 .dat 只会看到一长串十六进制字符,而不是明文 JSON。
总体架构
scss
┌─────────────────┐ IPC ┌──────────────────┐
│ Renderer (Vue) │ ──────────► │ Main Process │
│ 只调业务 API │ │ entity-store.ts │
└─────────────────┘ │ │ │
│ ▼ │
│ createEncryptedStore()
│ serialize → AES → hex
│ deserialize ← 解密
│ │ │
│ ▼ │
│ data/*.dat 文件 │
└──────────────────┘
分层建议:
| 层 | 职责 |
|---|---|
encrypted-store 工具 |
加解密 + 创建加密 Store |
xxx-store 业务模块 |
Schema、CRUD、软删除等 |
xxx-ipc |
暴露给渲染进程的通道 |
| (可选)backup / import | 复用同一套加解密做备份包 |
第一步:安装依赖
在 Electron 主进程侧使用(不要放进渲染进程打包依赖的滥用路径):
bash
npm install electron-store crypto-js
npm install -D @types/crypto-js # 若使用 TypeScript
说明:
electron-store:键值存取、原子写入、默认值crypto-js:在主进程做 AES;也可换成 Nodecrypto,思路相同
第二步:确定数据目录
业务数据若希望「随安装目录走、方便拷贝/备份」,不要只用默认 app.getPath('userData')。可按打包态分支:
ts
// electron/utils/app-paths.ts(示意)
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import { app } from 'electron'
/** 打包后:安装目录;开发时:项目根 */
export function resolveAppInstallDir(): string {
return app.isPackaged
? path.dirname(app.getPath('exe'))
: path.join(process.env.APP_ROOT ?? process.cwd())
}
/** 确保 <install>/data 存在并返回 */
export function resolveAppDataDir(): string {
const dir = path.join(resolveAppInstallDir(), 'data')
mkdirSync(dir, { recursive: true })
return dir
}
这样开发时数据在仓库旁的 data/,发布后在 .exe 同级的 data/,运维定位文件更直观。
第三步:实现加解密管线
设计目标:
- 内存中始终是普通 JS 对象
- 落盘时:
JSON.stringify→ AES 加密 → Hex 文本 写入文件 - 读取时:Hex → AES 解密 →
JSON.parse
Hex 的好处:文件全程是可打印 ASCII,复制、日志截断、跨平台换行更稳;代价是体积大约翻倍,对本机业务库通常可接受。
ts
// electron/utils/encrypted-store.ts(示意,密钥请勿写死在仓库)
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import CryptoJS from 'crypto-js'
import Store, { type Options as StoreOptions } from 'electron-store'
import { resolveAppDataDir } from './app-paths'
/**
* 密钥读取示意:生产环境应来自安全配置 / 构建注入 / 系统凭据,
* 切勿把真实密钥提交到 Git。
*/
function getStoreSecret(): string {
const secret = process.env.APP_STORE_SECRET
if (!secret) {
throw new Error('缺少 APP_STORE_SECRET')
}
return secret
}
/** 明文 → AES → Base64 密文 → 再编码为 Hex 字符串落盘 */
function encryptToHex(plainText: string): string {
const encrypted = CryptoJS.AES.encrypt(plainText, getStoreSecret()).toString()
return CryptoJS.enc.Hex.stringify(CryptoJS.enc.Utf8.parse(encrypted))
}
/** Hex 文件内容 → 解密得到明文 JSON 字符串 */
function decryptFromHex(hex: string): string {
const encrypted = CryptoJS.enc.Hex.parse(hex).toString(CryptoJS.enc.Utf8)
const decrypted = CryptoJS.AES.decrypt(encrypted, getStoreSecret())
const result = decrypted.toString(CryptoJS.enc.Utf8)
if (!result) {
throw new Error('解密 store 数据失败(密钥错误或文件损坏)')
}
return result
}
/** 供备份导出等「不经过 Store」场景复用同一算法 */
export function encryptStoreContent(plainText: string): string {
return encryptToHex(plainText)
}
export function decryptStoreContent(hex: string): string {
return decryptFromHex(hex)
}
安全说明(必读)
客户端侧加密属于「降低裸奔明文风险」,不能等价于服务端机密存储。密钥若内嵌在安装包里,懂逆向的人仍可能解出。对本项目这类「防普通用户直接改库」场景是合理工程折中;若威胁模型更高,需结合 OS 凭据保险箱、硬件绑定等方案。
第四步:封装 createEncryptedStore
把自定义扩展名、目录、序列化一次性绑死,业务侧只传 name 和 defaults:
ts
const CUSTOM_EXT = 'dat' // 示例后缀;本项目可使用自有扩展名
/** 旧版明文 .json → 新版加密文件(一次性迁移) */
function migrateLegacyJsonStore(dataDir: string, name: string): void {
const encryptedPath = path.join(dataDir, `${name}.${CUSTOM_EXT}`)
const jsonPath = path.join(dataDir, `${name}.json`)
if (existsSync(encryptedPath) || !existsSync(jsonPath)) return
const raw = readFileSync(jsonPath, 'utf8')
writeFileSync(encryptedPath, encryptToHex(raw), 'utf8')
unlinkSync(jsonPath)
}
/** 创建主进程专用加密 Store */
export function createEncryptedStore<T extends Record<string, unknown>>(
options: Omit<StoreOptions<T>, 'serialize' | 'deserialize' | 'fileExtension' | 'cwd'>
): Store<T> {
const dataDir = resolveAppDataDir()
const name = options.name ?? 'config'
migrateLegacyJsonStore(dataDir, name)
return new Store<T>({
...options,
cwd: dataDir,
fileExtension: CUSTOM_EXT,
serialize: value => encryptToHex(JSON.stringify(value)),
deserialize: text => JSON.parse(decryptFromHex(text)) as T,
})
}
关键点:
| 选项 | 作用 |
|---|---|
cwd |
落到 data/,而不是默认 userData |
fileExtension |
例如 dat / 项目自定义后缀 |
serialize |
写盘前加密 |
deserialize |
读盘后解密 |
| 迁移函数 | 老用户仍有明文 .json 时平滑升级 |
业务代码从此禁止再绕过这层直接读文件,否则加解密格式容易分裂。
第五步:业务 Store 怎么写
以「实体列表」为例(字段已抽象):
ts
// electron/hooks/entity-store.ts(示意)
import { randomUUID } from 'node:crypto'
import { createEncryptedStore } from '../utils/encrypted-store'
interface EntityRecord {
id: string
title: string
updatedAt: string
deletedAt?: string | null
}
interface EntityStoreSchema {
entities: EntityRecord[]
}
const store = createEncryptedStore<EntityStoreSchema>({
name: 'app-entities',
defaults: {
entities: [],
},
})
export function listEntities(): EntityRecord[] {
return store
.get('entities')
.filter(item => !item.deletedAt)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
}
export function addEntity(title: string): EntityRecord {
const now = new Date().toISOString()
const record: EntityRecord = {
id: randomUUID(),
title,
updatedAt: now,
}
const entities = store.get('entities')
store.set('entities', [record, ...entities])
return record
}
/** 软删除:保留痕迹便于审计 / 导入合并 */
export function softDeleteEntity(id: string): void {
const entities = store.get('entities')
const index = entities.findIndex(item => item.id === id)
if (index < 0) throw new Error('记录不存在')
const next = [...entities]
next[index] = {
...entities[index],
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
store.set('entities', next)
}
实践建议:
- Schema 显式声明 :
Store<EntityStoreSchema>,避免any蔓延 - 列表整体读写 :小规模本机库够用;字段更新时拷贝再
set,勿原地 mutate 后期望自动落盘 - 软删除:导入合并、审计时常比物理删除更合适
落盘结果类似:
bash
data/app-entities.dat # 一整文件都是 hex
第六步:只通过 IPC 暴露给渲染进程
加密文件应仅主进程接触。渲染层只拿「业务结果」:
ts
// electron/ipc/entity-ipc.ts(示意)
import { ipcMain } from 'electron'
import { addEntity, listEntities, softDeleteEntity } from '../hooks/entity-store'
export function registerEntityIpc() {
ipcMain.handle('entity:list', () => listEntities())
ipcMain.handle('entity:add', (_e, title: string) => addEntity(title))
ipcMain.handle('entity:delete', (_e, id: string) => softDeleteEntity(id))
}
渲染进程:
ts
// 渲染侧示意
const list = await window.api.invoke('entity:list')
配合 contextIsolation + preload 白名单通道,避免渲染进程直接 require('fs') 或拿到 Store 实例。
第七步:备份 / 导入复用同一套算法
有了统一的 encryptStoreContent / decryptStoreContent,备份包可以做成「外层再包一层加密」的结构:
ts
// 备份包结构示意(明文逻辑;落盘前整体再加密)
interface DataBackupBundle {
version: 1
exportedAt: string
files: Record<string, string> // key: 文件名,value: 单库已加密的 hex
}
function writeBackup(targetDir: string, bundle: DataBackupBundle): string {
const fileName = `backup_${Date.now()}_all.dat`
const outputPath = path.join(targetDir, fileName)
// 注意:bundle.files 里已经是各 store 的密文;整包 JSON 再加密一层
writeFileSync(outputPath, encryptStoreContent(JSON.stringify(bundle)), 'utf8')
return outputPath
}
function readBackup(filePath: string): DataBackupBundle {
const raw = readFileSync(filePath, 'utf8')
const bundle = JSON.parse(decryptStoreContent(raw)) as DataBackupBundle
if (!bundle?.files || typeof bundle.files !== 'object') {
throw new Error('备份文件格式无效')
}
return bundle
}
导入时:
- 解密外层备份包
- 再解密各子 store 的 hex → JSON
- 按业务规则合并(冲突决议、id 映射等)
- 写回本地 Store(仍走
createEncryptedStore的序列化)
这样运行时库、导出包、导入包三套文件格式一致,维护成本最低。
从 0 到 1 的落地清单
可按下面顺序一次做完:
- 安装
electron-store、crypto-js - 实现
resolveAppDataDir(),确认打包后目录正确 - 实现
encryptToHex/decryptFromHex,密钥走环境变量或安全配置 - 封装
createEncryptedStore(cwd+ 自定义扩展名 + serialize/deserialize) - 迁移逻辑:旧
.json→ 加密文件,只跑一次 - 业务
xxx-store:defaults+ CRUD + 类型 Schema - IPC 注册;preload 白名单;渲染层只调通道
- (可选)备份/导入复用
encryptStoreContent - 手工验证:改几个字节文件应解密失败;
get/set往返数据一致
验证片段:
ts
const store = createEncryptedStore<{ count: number }>({
name: 'smoke-test',
defaults: { count: 0 },
})
store.set('count', 42)
console.log(store.get('count')) // 42
// 打开 data/smoke-test.dat,应看不到明文 42
常见坑
-
在渲染进程使用 electron-store
安全隔离与打包路径都更乱;统一放主进程。
-
忘记
JSON.stringify包在加密外层serialize收到的是对象,必须先序列化再加密。 -
Hex / Base64 搞混
加密后 CryptoJS 默认是 Base64 字符串,本方案再转 Hex 落盘;解密必须严格逆序。
-
密钥更换
一旦更换密钥,旧文件全部不可读。需要版本字段或迁移工具,上线前要计划好。
-
大对象频繁整文件
setelectron-store适合中小规模本机库;数据量很大时再考虑 SQLite 等。 -
把真实密钥写进示例博客或仓库
本项目生产密钥应独立管理;文档与示例一律用占位符。
小结
electron-store 本身不是加密方案,但它把「改序列化管线」开放了出来。通过:
- 自定义
fileExtension→ 变成业务自有格式文件 serialize/deserialize+ AES → 磁盘上不是明文 JSONcwd指向安装目录data/→ 文件位置可控- 主进程 Store + IPC → 最小暴露面
- 备份导入共用算法 → 端到端格式统一
就能在不明显牺牲开发体验的前提下,完成一套适合桌面业务软件的本地加密存储。
