Actual Budget 架构决策记录解读:银行同步凭据为何明文存储在同步服务器
【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual
本文解读 Actual Budget(本地优先的个人财务管理应用)在 architecture-decision-records.md 中记录的首条正式架构决策:银行同步(bank sync)凭据以明文存储在同步服务器上,不在客户端加密,也不写入预算文件。读者将理解这条决策的完整理由、后果边界,并通过 sync-server 的源码与测试验证其在代码中的真实落地形态(secrets 表结构、两级作用域、REST 接口与权限模型),为自托管部署者评估安全边界、为贡献者理解后续改动提供依据。
一、什么是 Architecture Decision Record(ADR)
Actual Budget 的核心维护者偶尔会做出"不直观或有争议"的决定,因此项目采用轻量的ADR(架构决策记录)实践:将决策与决策背后的 rationale 一并记录在packages/docs/docs/contributing/leadership/目录下,使贡献者和用户在遇到相似问题时能回溯原始动机,而不是只看到结果。
文档同时强调一个重要的开放性:"如果某人凭借更多经验或知识提出更好的方案,团队愿意重新审视这些决策"(原文:"We are open to revisiting these decisions if someone with more experience or knowledge proposes a better approach")。因此 ADR 不是不可变更的"圣旨",而是可被挑战、可被修订的决策快照。目前在 architecture-decision-records.md 中正式记录的是银行同步凭据存储方案,下文以该决策为主体展开。
二、决策原文:Bank sync 凭据存储
该 ADR 以经典的Decision / Rationale / Consequences三段式记录:
决策(Decision)
银行同步凭据以明文存储在同步服务器上,不在客户端加密,也不存储在预算文件中。
即:当你通过 GoCardless、SimpleFIN、Enable Banking 等服务同步银行数据时,使用的凭据(如 secretId、token、clientSecret 等)存放在同步服务器端的存储中,而不是躺在客户端的预算数据库里。
理由(Rationale)
- 客户端加密并不会实质性提升安全性:即使客户端加密(或把加密做成可选项),只要服务器在正常操作期间仍需解密这些凭据去调用银行接口,服务器一旦被攻破,攻击者在解密时刻依然能拿到明文凭据;
- 避免扩大攻击面:凭据只保留在服务器上,就不会暴露给扩展(extensions)和插件(plugins),从而减少被第三方代码接触的机会;
- 隔离边界明确:Actual Budget 在共享实例上不提供不可信用户之间的强隔离;需要强隔离的用户应自行运行独立实例。
后果(Consequences)
- 正向后果:设计更简单、安全保证更清晰、维护成本更低;
- 代价:服务器管理员可以访问这些凭据;服务器被攻破时,凭据不受加密保护。
这段记录的诚实之处在于:它明确承认管理员可读、服务器沦陷即暴露这两个代价,而不是用"加密"包装出虚假的安全感。下文结合 sync-server 源码,逐项验证这些论断在实现中的体现。
三、源码落地(一):secrets 表与凭据命名规范
决策中"凭据存储在同步服务器"在代码中的载体是account-db中的secrets表,由迁移 1694362247011-create-secret-table.js 创建:
CREATE TABLE IF NOT EXISTS secrets ( name TEXT PRIMARY KEY, value BLOB );一个极简的name -> value键值表,没有任何加密字段或密钥管理逻辑——与"明文存储"的决策完全一致。
后续迁移 1702667624000-rename-nordigen-secrets.js 展示了凭据命名的演化:Nordigen 被 GoCardless 收购后,存量nordigen_secretId/nordigen_secretKey被原地重命名为gocardless_secretId/gocardless_secretKey,说明该表长期承载银行同步凭据。
合法的凭据名称被集中定义在 secrets-service.js 的SecretName枚举中:
export const SecretName = { gocardless_secretId: 'gocardless_secretId', gocardless_secretKey: 'gocardless_secretKey', simplefin_token: 'simplefin_token', simplefin_accessKey: 'simplefin_accessKey', pluggyai_clientId: 'pluggyai_clientId', pluggyai_clientSecret: 'pluggyai_clientSecret', pluggyai_itemIds: 'pluggyai_itemIds', akahu_userToken: 'akahu_userToken', akahu_appToken: 'akahu_appToken', enablebanking_applicationId: 'enablebanking_applicationId', enablebanking_secretKey: 'enablebanking_secretKey', };可以看到该枚举覆盖了当前项目接入的所有银行同步 Provider:GoCardless、SimpleFIN、PluggyAI、Akahu、Enable Banking。
四、源码落地(二):凭据的两级作用域(全局 vs 预算文件级)
从源码结构看,secrets 并非只有一份全局凭据,而是支持两级作用域。关键实现在 secrets-service.js 的getSecretKey:
function getSecretKey(name, fileId) { return fileId == null ? name : `${name}:${fileId}`; }- 全局凭据:不传
fileId时,直接以name作为表主键,例如管理员在服务器级配置的 GoCardless secretId; - 预算文件级凭据:传入
fileId时,以${name}:${fileId}作为主键,即每个预算文件可以拥有自己独立的凭据。
服务层secretsService提供get/set/reset/exists四个操作,其中get在键不存在时返回null,exists即get(name, fileId) !== null。测试 secrets.test.js 的two-tier credentials描述块精确刻画了这级语义:
- 文件级凭据缺失时,不会回退到全局凭据(
get(name, fileAId)返回null,即使全局存在); - 文件级与全局凭据互不覆盖,先存文件级再存全局,前者依然有效;
reset(name, fileId)只删除指定作用域,返回deletedFrom: 'per-budget-file'或'global'便于审计。
这一设计是对 ADR 中"凭据只保留在服务器上"的细化:不仅按服务器维度集中,还能按预算文件维度隔离,天然支撑多用户/多预算文件的自托管场景。
五、源码落地(三):REST 接口与权限模型
凭据的读写对外暴露为 REST 接口,实现在 app-secrets.js,由secretsService支撑:
| 方法 | 路径 | 语义 | 成功状态码 |
|---|---|---|---|
POST / | 设置凭据(body 为{ name, value }) | 200 | |
GET /:name | 检查凭据是否存在 | 204(存在)/ 404(不存在) | |
DELETE /:name | 删除凭据 | 200 |
接口通过请求头X-Actual-File-Id区分作用域:携带该头则为预算文件级凭据,否则为全局凭据。权限模型与 ADR 的"管理员可访问凭据"论断直接对应:
- 全局凭据只能由管理员管理(
canManageGlobalSecrets调用isAdmin(userId),见 app-secrets.js L19-L22); - 预算文件级凭据可由管理员或该文件的所有者管理(
canManagePerBudgetFileSecrets,L32-L34); - 未知的凭据名称直接被拒绝(
POST返回 400invalid-secret-name,GET/DELETE返回 404),且未知名称的 404 与"凭据不存在"的 404 表现一致,避免探测存在的键。
测试 secrets.test.js 覆盖了完整的状态码矩阵:未认证 401、非文件 owner 操作他人文件 403、未知名称 400/404、全局 vs 文件级作用域互不可见,以及 OpenID 认证模式下非管理员 owner 可写文件级凭据、但写全局凭据被 403 拒绝。这些测试是 ADR"安全保证更清晰"论断的直接证据——权限边界有明确测试锁定。
六、凭据在银行同步流程中的消费方式
ADR 决策的最终目的是让银行同步正常工作。各 Provider 模块在运行时通过secretsService.get(...)读取凭据,例如:
- GoCardless:在 gocardless-service.ts L46-L61 中,
getGocardlessClient用secretsService.get(SecretName.gocardless_secretId)与secretsService.get(SecretName.gocardless_secretKey)组装客户端,并以凭据 JSON 序列化后的哈希作为 client 缓存键——凭据变化会自动产生新客户端实例; - Enable Banking:在 enablebanking-service.ts 中读取
applicationId与secretKey; - Akahu:在 app-akahu.ts 中读取
userToken与appToken; - PluggyAI:在 app-pluggyai.js 中甚至以文件级作用域读取
pluggyai_itemIds(即每个预算文件持有自己的 item 关联凭据)。
这些调用点共同说明:凭据的生命周期完全收敛在服务器端,客户端/扩展/插件不持有任何密钥材料——这正是 ADR 中"避免暴露给扩展和插件,以缩小攻击面"的工程落地。
七、安全模型解读:为什么"明文存储"是深思熟虑而非疏漏
从 ADR 的理由可以提炼出 Actual Budget 对银行同步凭据的安全模型假设:
- 威胁模型以"服务器可用性"为前提:服务端在每次与银行 API 交互时都必须使用明文凭据,因此"静态加密但运行时可解密"对抵御服务器完全沦陷没有本质帮助——攻击者总能在解密点拿到明文;
- 信任边界收敛到服务器:与其把凭据分散到客户端、扩展、插件等多个不可控位置,不如集中在一处,让信任边界清晰;
- 强隔离靠实例而非租户:共享实例被明确视为可信用户集合,不可信用户之间需要隔离时,官方建议各自运行独立实例。这与 sync-server 的自托管定位一致。
因此,把凭据明文放在服务器上,换来的是更简单的设计、更低的维护成本,以及可预期的、明确的安全边界。作为自托管管理员,部署前应认识到:你的同步服务器管理员等同于凭据的保管人;若服务器遭入侵,银行同步凭据会随之泄露,应尽快在对应银行侧吊销并轮换。这一预期在 ADR 的 Consequences 中被明确声明,属于项目官方承认的风险边界,而非隐藏缺陷。
八、对贡献者与使用者的实践启示
- 贡献者视角:在修改凭据相关逻辑前,先阅读 architecture-decision-records.md 理解既定边界;若要挑战"明文存储"决策,ADR 要求你提出在"服务器正常运行期间仍需可解密"前提下真正优于现状的方案,例如引入托管密钥管理系统(KMS)并说明解密时点的防护;
- 自托管管理员视角:凭据可全局配置(管理员)或按预算文件配置(文件 owner),通过
POST /写入、DELETE /:name撤销;全局凭据与文件级凭据互不回退,配置前应确认作用域意图; - 测试视角:secrets.test.js 是理解该功能行为规范的"可执行文档",任何改动都应保持其覆盖的状态码与两级作用域语义不变。
这条 ADR 的价值不仅在于"做了什么决定",更在于它示范了如何把安全权衡透明化:明确承认代价、划定信任边界、并鼓励更好的方案提出。这正是 Actual Budget 将架构决策文档化的初衷。
【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考