在 Electron 中用 electron-store 实现加密存储与自定义格式文件


背景与目标

桌面端验配/业务软件常需要在本机保存结构化数据(客户、业务记录等),同时希望:

  1. 文件打开后无法直接阅读(至少挡掉随手改 JSON 的情况)
  2. 扩展名不是默认的 .json,降低用户误开、误传、误改的概率
  3. 只在主进程读写,渲染进程通过 IPC 访问,避免泄露文件系统能力
  4. 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;也可换成 Node crypto,思路相同

第二步:确定数据目录

业务数据若希望「随安装目录走、方便拷贝/备份」,不要只用默认 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/,运维定位文件更直观。


第三步:实现加解密管线

设计目标:

  1. 内存中始终是普通 JS 对象
  2. 落盘时:JSON.stringify → AES 加密 → Hex 文本 写入文件
  3. 读取时: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

把自定义扩展名、目录、序列化一次性绑死,业务侧只传 namedefaults

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
}

导入时:

  1. 解密外层备份包
  2. 再解密各子 store 的 hex → JSON
  3. 按业务规则合并(冲突决议、id 映射等)
  4. 写回本地 Store(仍走 createEncryptedStore 的序列化)

这样运行时库、导出包、导入包三套文件格式一致,维护成本最低。


从 0 到 1 的落地清单

可按下面顺序一次做完:

  1. 安装 electron-storecrypto-js
  2. 实现 resolveAppDataDir(),确认打包后目录正确
  3. 实现 encryptToHex / decryptFromHex,密钥走环境变量或安全配置
  4. 封装 createEncryptedStorecwd + 自定义扩展名 + serialize/deserialize)
  5. 迁移逻辑:旧 .json → 加密文件,只跑一次
  6. 业务 xxx-storedefaults + CRUD + 类型 Schema
  7. IPC 注册;preload 白名单;渲染层只调通道
  8. (可选)备份/导入复用 encryptStoreContent
  9. 手工验证:改几个字节文件应解密失败;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

常见坑

  1. 在渲染进程使用 electron-store

    安全隔离与打包路径都更乱;统一放主进程。

  2. 忘记 JSON.stringify 包在加密外层

    serialize 收到的是对象,必须先序列化再加密。

  3. Hex / Base64 搞混

    加密后 CryptoJS 默认是 Base64 字符串,本方案再转 Hex 落盘;解密必须严格逆序。

  4. 密钥更换

    一旦更换密钥,旧文件全部不可读。需要版本字段或迁移工具,上线前要计划好。

  5. 大对象频繁整文件 set

    electron-store 适合中小规模本机库;数据量很大时再考虑 SQLite 等。

  6. 把真实密钥写进示例博客或仓库

    本项目生产密钥应独立管理;文档与示例一律用占位符。


小结

electron-store 本身不是加密方案,但它把「改序列化管线」开放了出来。通过:

  • 自定义 fileExtension → 变成业务自有格式文件
  • serialize / deserialize + AES → 磁盘上不是明文 JSON
  • cwd 指向安装目录 data/ → 文件位置可控
  • 主进程 Store + IPC → 最小暴露面
  • 备份导入共用算法 → 端到端格式统一

就能在不明显牺牲开发体验的前提下,完成一套适合桌面业务软件的本地加密存储。

相关推荐
虚焦像素_1 小时前
海康 Web 插件版接入指南:用法与注意事项
前端
虚焦像素_1 小时前
海康 Web 无插件版接入指南:用法与注意事项
前端
snow@li2 小时前
Redis:数据库语法速查表 / 全景梳理
数据库·redis·缓存
Devlive 开源社区2 小时前
Nebula 1.6.0 发布:跨区域桶自动路由、秒传、文本/PDF 预览一次到位
数据库·sql
To_OC2 小时前
LC 22 括号生成:刷完这道题,我终于搞懂回溯剪枝了
javascript·算法·leetcode
aaron2 小时前
前端演进史:当你没留意时,前端发生了什么
前端
小二·2 小时前
Next.js 15 企业级实战:从项目搭建到CI/CD全链路(App Router + Server Components + Turbopack)
javascript·ci/cd·turbopack
不好听6132 小时前
前端路由完全指南(上篇):从原理到 6 种路由模式
前端·react.js
像我这样帅的人丶你还2 小时前
🚀大文件上传的那些事
前端·javascript·架构