ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Actual Budget 架构决策记录解读:银行同步凭据为何明文存储在同步服务器

2026/9/12 6:14:17 拓冰建站 浏览量
Actual Budget 架构决策记录解读:银行同步凭据为何明文存储在同步服务器 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 RecordADRActual 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, };可以看到该枚举覆盖了当前项目接入的所有银行同步 ProviderGoCardless、SimpleFIN、PluggyAI、Akahu、Enable Banking。四、源码落地二凭据的两级作用域全局 vs 预算文件级从源码结构看secrets 并非只有一份全局凭据而是支持两级作用域。关键实现在 secrets-service.js 的getSecretKeyfunction getSecretKey(name, fileId) { return fileId null ? name : ${name}:${fileId}; }全局凭据不传fileId时直接以name作为表主键例如管理员在服务器级配置的 GoCardless secretId预算文件级凭据传入fileId时以${name}:${fileId}作为主键即每个预算文件可以拥有自己独立的凭据。服务层secretsService提供get/set/reset/exists四个操作其中get在键不存在时返回nullexists即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 }200GET /:name检查凭据是否存在204存在/ 404不存在DELETE /:name删除凭据200接口通过请求头X-Actual-File-Id区分作用域携带该头则为预算文件级凭据否则为全局凭据。权限模型与 ADR 的管理员可访问凭据论断直接对应全局凭据只能由管理员管理canManageGlobalSecrets调用isAdmin(userId)见 app-secrets.js L19-L22预算文件级凭据可由管理员或该文件的所有者管理canManagePerBudgetFileSecretsL32-L34未知的凭据名称直接被拒绝POST返回 400invalid-secret-nameGET/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与secretKeyAkahu在 app-akahu.ts 中读取userToken与appTokenPluggyAI在 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),仅供参考