深入 SNJS:Standard Notes 端到端加密客户端共享库的架构、集成与测试指南

原创2026-10-07 16:04:311,921 阅读
文章标签:前端桌面应用移动开发

深入 SNJS:Standard Notes 端到端加密客户端共享库的架构、集成与测试指南

SNJS(@standardnotes/snjs)是 Standard Notes 全家桶(桌面端、Web 端、移动端)共享的客户端侧 JavaScript 核心库,负责把所有与业务、数据、加密相关的逻辑从各客户端 UI 代码中剥离出来统一实现。本文以仓库内 packages/snjs/README.md 为骨架,结合 Application 源码、Webpack 构建配置 与 协议规范,完整讲解 SNJS 的架构定位、SNApplication 入口、DeviceInterface 抽象层、安装集成、构建流程以及 E2E/单元测试的落地方法,读完即可在自有 Standard Notes 客户端或自托管环境中正确引入并验证 SNJS。

SNJS 是什么:一套跨平台共享的客户端逻辑库

SNJS 是面向所有 Standard Notes 客户端(桌面、Web、移动)的共享库。它的核心职责是把业务逻辑与数据逻辑从客户端代码中完全抽出,让各客户端主要只负责 UI 层代码,而不必关心:

  • 加密与密钥管理(key management);
  • 身份认证(authentication)细节;
  • 本地存储(storage)的具体实现差异。

将关键代码收敛到单一共享库中,还避免了在多平台上重复编写同一份敏感逻辑——这正是端到端加密应用最容易出错的地方。

围绕 SNJS 的两大外部协作对象是:

  1. sncrypto 系列密码学包:SNJS 在 Web 平台通过 @standardnotes/sncrypto-common / @standardnotes/sncrypto-web 执行密钥生成、数据加密等操作(见 packages/sncrypto-common),其行为约定记录在 协议规范 中;
  2. Standard Notes 同步服务器(syncing server):一个零知识(zero-knowledge)的数据与同步存储,只处理加密数据,永远无法获知客户端密钥或敏感信息。

从仓库包依赖看,SNJS 还通过 workspace 依赖串联了 @standardnotes/api、@standardnotes/encryption、@standardnotes/features、@standardnotes/files、@standardnotes/models、@standardnotes/responses、@standardnotes/services 等一组领域包(见 packages/snjs/package.json),并以 dist/snjs.js 为最终发布产物。

核心入口 SNApplication:一个完整的应用功能单元

SNJS 的入口是 SNApplication 类(见 lib/Application/Application.ts)。这个应用类是一个完整的应用功能单元:理论上可以创建多个应用实例,每个实例拥有独立的存储命名空间(storage namespace)和内存状态(memory state),从而让客户端能够支持多个用户账户同时存在。

构造:必填项校验

SNApplication 的构造函数会合并默认选项(ApplicationOptionsDefaults)并对必填项做严格校验,缺少任意一项都会直接抛出 Error。从源码可见(Application.ts)必填项包括:

选项 说明
deviceInterface 客户端实现的设备抽象层(见下文)
environment 运行环境(如 Environment.Desktop / Environment.Mobile)
platform 平台标识(如 Platform.MacWeb)
crypto 密码学实现(SNWebCrypto 等)
alertService 弹窗提示服务
identifier 应用标识符(持久化于存储中)
defaultHost 默认服务器地址
appVersion 应用版本号
apiVersion API 版本号

此外,构造前必须设置全局日志钩子:SNLog.onLog 与 SNLog.onError 未赋值同样会抛错(Application.ts)。

生命周期:prepareForLaunch → launch → deinit

  • prepareForLaunch(callback):客户端启动后首先调用的方法。它会按正确顺序装载全部服务:初始化 crypto → 打开本地数据库(device.openDatabase)→ 初始化迁移(migrations)→ 从磁盘初始化存储 → 初始化加密 → 触发 Started 事件(Application.ts)。
  • launch(awaitDatabaseLoad = false):处理设备认证与解锁,可能弹出本地口令(passcode)或生物识别挑战;随后解密存储、设置服务器主机、加载数据库 payload 并启动自动同步定时器。awaitDatabaseLoad 为 true 时会等待本地数据加载完成再返回(Application.ts)。
  • deinit(mode, source) / lock():销毁应用实例。lock() 会先以最多 500ms 的时限 prepareForDeinit,再执行 deinit(Application.ts)。

服务访问面:getter 一族

SNApplication 通过依赖注入容器(Dependencies,见 lib/Application/Dependencies/Types.ts)暴露了大量只读 getter,客户端 UI 层直接通过这些面调用能力,常见的有:

  • app.items(ItemManager)、app.sync(SyncService)、app.user(UserService);
  • app.encryption(EncryptionService)、app.storage(DiskStorageService)、app.protections(ProtectionService);
  • app.mutator、app.sessions、app.features、app.files、app.vaults、app.history 等(Application.ts)。

同时提供注册、登录、改密等直接方法:register(email, password, hvmToken, ephemeral, mergeLocal)、signIn(email, password, strict, ephemeral, mergeLocal, awaitSync, hvmToken)、changePassword、changeEmail、addPasscode/removePasscode、importData 等(Application.ts)。

事件机制

客户端通过 addEventObserver(callback, singleEvent?) 订阅 ApplicationEvent(如 Started、Launched、SignedIn、SignedOut、PreferencesChanged 等),返回的移除函数可用于注销观察者(Application.ts)。内部各服务事件(SyncEvent、SessionEvent、FeaturesEvent、ProtectionEvent 等)会在 注册服务事件 时被转换为应用级事件。

DeviceInterface:客户端能力抽象层

每个应用实例都必须提供一个 DeviceInterface 的自定义子类。该接口把客户端在正常运行中所需的全部设备级行为泛化,包括:

  • 保存数据到本地数据库存储(database store);
  • 读写 key/value;
  • 访问系统安全钥匙串(keychain)。

这样 SNJS 便与具体的存储实现(IndexedDB、SQLite、文件系统、Keychain)解耦。在依赖注入容器中,DeviceInterface 以 Symbol.for('DeviceInterface') 注册(见 lib/Application/Dependencies/Types.ts)。

Web 端实际实现参考 WebApplication.spec.ts:测试中传入的 device mock 至少需提供 environment、appVersion、openDatabase、getRawStorageValue、setRawStorageValue、setApplication 等方法,随后即可 new WebApplication(device, platform, identifier, syncHost, socketHost) 并调用 prepareForLaunch({ receiveChallenge })——这给出了集成 SNJS 时实现设备层的最小契约。

多账户支持:SNApplicationGroup

仓库中还有 SNApplicationGroup(见 lib/ApplicationGroup/ApplicationGroup.ts),负责在单个设备上编排多个 SNApplication 实例:通过持久化的 DescriptorRecord(描述符记录)管理应用描述符、选定主应用(primaryApplication)、并在 DeviceWillRestart、DescriptorsDataChanged 等事件间协调。这正是 README 所述“每个应用拥有独立存储命名空间与内存状态、支持多账户”的具体实现载体。

安装与集成

在项目中使用 SNJS:

yarn add snjs

模块环境(ESM/打包器)

import { SNApplication } from 'snjs';

随后按上文构造参数创建应用实例、调用 prepareForLaunch 与 launch 即可。

非模块化 Web 环境(script 标签)

SNJS 的 Webpack 产物以 UMD 库 SNLibrary 暴露(见 webpack.config.js),因此在浏览器中可这样挂载:

<script src="snjs.js"></script>
Object.assign(window, SNLibrary);

挂载后即可通过 window 上的全局对象访问 SNApplication 等导出。

构建

在仓库 packages/snjs 目录下按步骤构建:

  1. yarn install --pure-lockfile —— 安装依赖(保持 lockfile 纯净);
  2. yarn start —— 以 Webpack development 模式启动(监听文件变更、热更新,见 webpack.dev.js);
  3. yarn build —— 生成 dist 产物。该命令内部先执行 yarn tsc(生成类型声明到 dist/@types)再执行 webpack --config webpack.prod.js(见 packages/snjs/package.json)。

Webpack 配置要点(webpack.config.js):入口为 ./lib/index.ts,输出文件名 snjs.js、UMD 库名 SNLibrary;内置 @Lib、@Services、@Payloads 路径别名;通过 __VERSION__ 注入包版本,并挂载 CircularDependencyPlugin 检测循环依赖。SNJS 的公共导出聚合了 Application、ApplicationGroup、Migrations、Services、Url、Version 以及多个 @standardnotes/* 领域包(见 lib/index.ts)。

测试

E2E 测试

SNJS 的 E2E 套件运行在真实的自托管服务器环境之上。

前置条件

  1. 搭建与生产环境一致的本地自托管服务器(参考 Standard Notes 官方的自托管 Docker 方案)。
  2. 在 .env 中设置以下环境变量——务必使用很短的 token TTL,这是测试套件的硬性要求:
# .env
...
AUTH_SERVER_ACCESS_TOKEN_AGE=4
AUTH_SERVER_REFRESH_TOKEN_AGE=10
AUTH_SERVER_EPHEMERAL_SESSION_AGE=300
SYNCING_SERVER_REVISIONS_FREQUENCY=5
变量 建议值 作用
AUTH_SERVER_ACCESS_TOKEN_AGE 4 访问令牌有效期(秒),短 TTL 用于覆盖会话失效场景
AUTH_SERVER_REFRESH_TOKEN_AGE 10 刷新令牌有效期(秒)
AUTH_SERVER_EPHEMERAL_SESSION_AGE 300 临时会话有效期(秒)
SYNCING_SERVER_REVISIONS_FREQUENCY 5 同步服务器修订频率,用于历史/修订相关用例
  1. 编辑 docker-compose.yml,将 keypath services.server.ports[0] 从端口 3000 改为 3123(避免与测试使用的其他端口冲突)。
  2. 若服务器不以 Docker 方式运行、而是以独立 Node 进程启动,并且某些测试(如上传文件)需要有效订阅,则需要克隆 mock-event-publisher 并在本地 3124 端口运行;同时在其 Container.ts 文件中注释掉所有 SNS_ENDPOINT 相关行(供本地运行)。

启动测试服务器

在 self-hosted 目录执行:

docker compose pull && docker compose up

等待各服务就绪。

运行测试套件(APP)

服务器基础设施就绪且所有包构建完成后,在 app 目录执行:

yarn e2e

该命令在根 package.json 中定义为 lerna run start:test-server --scope=@standardnotes/snjs,即在浏览器中启动 SNJS 的 E2E 测试服务端。

故障排查

运行 E2E 前建议先在 app 目录执行:

yarn build:snjs

根 package.json 中 build:snjs 对应 yarn workspaces foreach -p --topological-dev --verbose -R --from @standardnotes/snjs run build,即按拓扑顺序重建 @standardnotes/snjs 及其依赖,确保测试针对的是本地最新改动而非陈旧构建产物。

单元测试

在仓库根目录运行:

yarn run test

SNJS 内部以 Jest 驱动,相关测试文件分布在 packages/snjs/lib 下(如 ItemManager.spec.ts、MutatorService.spec.ts、PayloadManager.spec.ts、SyncFrequencyGuard.spec.ts、ProtectionService.spec.ts、DiskStorageService.spec.ts、Version.spec.ts 等),覆盖同步、变更、载荷管理、保护会话、磁盘存储与版本号等核心逻辑。

幕后原理:SNJS 与 v004 加密协议如何协同

SNJS 的加密行为以 specification.md(Protocol Specification v004)为蓝本,理解它有助于正确集成与排障:

三级密钥体系

  • Root key(根密钥):由账户密码经 KDF 派生,每账户唯一;派生后分为两半——masterKey(本地保存、绝不发给服务器)与 serverPassword(仅用于注册/认证时提交给服务器);
  • Root key wrapper(根密钥包装层):对应“应用口令(passcode)”特性,用本地口令派生的 rootKeyWrappingKey 加密 root key 后落盘,使无钥匙串环境(如浏览器)也能安全存放密钥;
  • Items keys(条目密钥):随机生成、用于加密笔记/标签等同步数据;条目密钥本身用 master key 加密后随账户同步。改密或协议升级时只需用新 master key 重新加密少数条目密钥,而无需重加密海量数据。

加密流程:每条笔记生成随机 item_key → 用 item_key 加密内容得 content → 用默认 itemsKey 加密 item_key 得 enc_item_key → 携带 items_key_id 上传。

密码学参数(v004):密钥派生采用 Argon2id(内存 64 MiB、迭代 5、并行 1、盐 128 bit、输出 512 bit);加密采用 XChaCha20+Poly1305(256-bit 密钥、192-bit nonce)。

存储场景:有无账户、有无口令组合出四种存储模式(见 specification.md)——无账户无口令时值/载荷均明文;有账户时值/载荷均以 root key 加密;口令(wrapper)存在时 root key 本身也加密落盘。这正是 SNJS 中 DiskStorageService 与 EncryptionService 协作所实现的行为。

迁移机制

SNJS 在检测到旧版数据状态时会执行数据迁移(如为存量 003 账户创建基于 rootKey.masterKey 的默认 itemsKey),迁移版本按 SNJS 发布版本命名。若需新增迁移,参考 lib/Migrations/Versions/README.md:在 versions 目录下创建以目标 SNJS 版本命名的新文件(例如当前版本为 1.0.0 时,迁移版本取 1.0.1 以覆盖更低版本用户),并在同目录 index.ts 中导出该迁移。

小结

SNJS 通过 SNApplication 单一入口 + DeviceInterface 设备抽象 + 依赖注入服务容器,把加密、密钥管理、认证、同步与存储完整收敛为一份跨端共享代码;配合自托管同步服务器与 sncrypto 密码学包,实现了“服务器零知识、客户端完全掌控密钥”的安全模型。无论是新客户端集成(安装、构造、生命周期调用)、构建发布(webpack UMD 产物),还是通过 E2E + 单元测试验证端到端正确性,均可依据本文与 packages/snjs/README.md、lib/Application/Application.ts、specification.md 逐步落地。

登录后查看全文
app