bug: safeStorage 钥匙串项名未按区域派生,cn/global 共用 "Cindy Safe Storage" 导致切版首启弹系统密码框
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 395
- Avg merge
- 21h 48m
- Merged PRs (30d)
- 776
Description
## 问题描述 / What happened
macOS 上先运行过一个区域版本(如中国大陆版)、再首次启动另一个区域版本(如 Global 版)时,
系统会弹出钥匙串授权框:
> **Cindy** 想要使用你储存在钥匙串的 **“Cindy Safe Storage”** 中的机密信息。
> 若要给予许可,请输入“登录”钥匙串的密码。
对用户来说这是一个「Cindy 在要我的开机密码」的高惊吓弹窗,且:
- 点「允许」只授权一次,**下次启动继续弹**;只有点「始终允许」才会写入 ACL 永久放行。
- 点「拒绝」后 `safeStorage` 不可用,provider API key / 插件凭证 / 登录态的加解密会静默降级失败。
期望行为:两个区域版本各自使用独立的钥匙串条目,任何一版首启都不应触发系统密码框。
## 环境 / Environment
- Cindy 版本或 commit / version or commit: 0.1.20(cn 与 global 两个 macOS 包)
- 平台与版本 / platform & OS version: macOS 27.0 (arm64)
- 安装方式 / install method: 官方 DMG 安装包(Developer ID 签名 + 公证)
## 复现步骤 / Steps to reproduce
1. 在一台干净的 macOS 机器上安装并启动**中国大陆版**,完成登录(此时系统钥匙串中创建
`Cindy Safe Storage` 条目,ACL 只包含 cn 版的签名身份)。
2. 安装并首次启动 **Global 版**。
3. Global 版首次访问 `safeStorage` 时弹出上述钥匙串密码框。反向顺序(先 global 后 cn)同样复现。
## 日志与截图 / Logs & screenshots
`safeStorage` 这条链路目前**在日志里完全不可观测**——`main-*.log` 中没有任何
`safeStorage` / 钥匙串相关记录,出问题只能靠系统弹窗反推。
系统钥匙串条目与 ACL(路径已通用化,cdhash 已截断):
```
svce = "Cindy Safe Storage" acct = "Cindy Key"
cdat = 20260717210605Z
access: 5 entries
entry 1: authorizations (6): decrypt derive export_clear export_wrapped mac sign
applications (3):
0: /Applications/Cindy.app
requirement: identifier "com.xd.cindy" and anchor apple generic and ... subject.OU = SX9RG894L5
1: /Applications/Cindy.app
requirement: identifier "com.xd.cindycn" and anchor apple generic and ... subject.OU = NTC4BJ542G
2: /node_modules/electron/dist/Electron.app
requirement: cdhash H"fd5b0dd6…"
entry 3: authorizations (1): partition_id
description: cdhash:fd5b0dd6…, teamid:NTC4BJ542G, teamid:SX9RG894L5
```
三种身份挤在同一条目上,且 cn 与 global 由**两个不同的 Apple 开发者团队**签名:
| 构建 | CFBundleIdentifier | 签名 team |
|---|---|---|
| 中国大陆版 | `com.xd.cindycn` | `NTC4BJ542G`(X.D. Network Inc.) |
| Global 版 | `com.xd.cindy` | `SX9RG894L5`(XD Entertainment Pte Ltd) |
| 未打包 dev 运行 | —(stock Electron) | 无(按 cdhash 被信任) |
## 根因 / Root cause
`packages/maker-shared/src/brandIdentity.ts` 已经把三种身份分得很干净:
- `appIdByRegion`:`com.xd.cindycn` / `com.xd.cindy` / `com.xd.cindydev`
- `userDataDirNameByRegion`:`Cindy` / `CindyGlobal` / `CindyDev`
但 macOS 上 Electron `safeStorage` 的钥匙串条目名是从 **`app.name`** 派生的
(service = ` Safe Storage`,account = ` Key`),而 `app.name`
**从未做区域派生** —— `appName` / `productName` 对 cn 与 global 同值 `'Cindy'`
(2026-07-26 owner 决策:两版可见位置统一显示 Cindy),dev 未打包运行也由
`apps/desktop/package.json` 的 `productName` 落到同一个名字。三者因此共用
`Cindy Safe Storage`。
macOS 钥匙串条目的 ACL 绑定的是**创建它的那个代码签名身份**;`identifier` 与
`subject.OU` 都进入要求串,所以 cn 与 global 即使换成同一张证书也不满足对方的要求。
条目由先运行的一版创建,另一版首次访问必然落到「不在 ACL 内」→ 系统弹密码框。这是
Chromium/Electron 的既有行为,应用侧无法在无用户同意的情况下自我授权,**因此只要项名
共用,跨版首启的弹窗就无法避免**。
关于修复时机的一个关键事实(影响可选方案):Electron 41.2.0 在
`ElectronBrowserMainParts::PostCreateMainMessageLoop()` 里一次性定型项名:
```cc
std::string app_name = electron::Browser::Get()->GetName();
KeychainPassword::GetServiceName() = app_name + " Safe Storage";
```
该时点在主脚本同步段**之后**、`app` `ready` **之前**,所以:
- 在 main 入口顶部同步调用 `app.setName()` **可以**改变项名(现有 dev 运行的项名正是
Electron `lib/browser/init.ts` 用 JS 设 `productName` 得到的,可作为反证);
- 但**一个进程内只能定型一次**,无法在同一进程里先用旧名解密、再用新名加密。
另外 `app.name` 同时决定 `userData` 默认目录,改名前必须把三条路径的 `userData`
都显式 pin 住(目前只有 packaged global 在 `src/main/index.ts` 显式 `setPath`,
packaged cn 与非隔离 dev 都依赖 `productName` 默认派生)。
## 影响 / Impact
- 同机装过两个区域版本的用户(含所有做区域验证的开发者与内测用户),切版首启必现系统
密码框;不理解的用户点「拒绝」后凭证加解密静默失效。
- `safeStorage` 链路无任何日志,此类故障不可观测、难以远程支持。
- cn 与 global 共用同一把主密钥,与 `appIdByRegion` / `userDataDirNameByRegion`
刻意做出的双装隔离设计不一致。
## 修复方向候选 / Candidate fixes
**A. 按区域派生钥匙串项名 + 一次性子进程迁移(彻底修)**
在 main 入口最早期按 region 设置 `app.name`(并先把 `userData` 显式 pin 住),让
cn / global / dev 各用独立条目,此后任一版首启都不再弹窗。存量数据不能丢,所以需要
一次性迁移:首次以新名启动时,拉起一个自身二进制的子进程、让它以**旧名**启动完成解密,
明文经匿名管道回传父进程后用新名重新加密。父子是同一签名身份,本来就在旧条目 ACL 内,
迁移本身不弹窗。
代价与风险:工作量最大;迁移过程经手明文凭证(不得落盘、不得进日志);必须设计失败
回退(最坏情况用户需重新登录并重填 provider key);属于存量凭证迁移,按
`docs/dev-rules/credentials-and-local-storage.md` 需要单独的兼容 / 回滚 / 验证方案。
**B. 只隔离未打包 dev 运行**
dev 运行改用独立条目,不再与正式包共用。零线上影响、无需迁移。代价:dev 实例读不到
共享 profile 中已存的密钥,共享 profile 的开发流程需重填一次。
**C. 不改项名,只补诊断与文档**
保持现状项名(零迁移风险),为 `safeStorage` 不可用 / 被拒绝补明确日志与用户可读提示,
并把「项名跨区域共用」「遇到弹窗应点始终允许」写进 `docs/dev-rules/`。代价:跨版首启的
系统弹窗仍会出现一次。
B 可与 A 或 C 组合。
## 备注 / Notes
- 这不是终端用户侧的安全漏洞:发布包的 ACL 只包含其自身签名身份,弹窗是 macOS 在
按预期征求用户同意。因此按 `SECURITY.md` 判断适合公开 issue 跟踪;若维护者认为其中
与开发机 ACL 相关的部分需要私下处理,可转为私密 advisory。
- 排查期间另发现一个独立问题:有文件被写入已安装的 `.app` bundle
(`Contents/package.json`,内容为 `apps/desktop/package.json`),导致
`codesign --verify` 报 `a sealed resource is missing or invalid`。签名被破坏时同样会
触发本 issue 的弹窗(ACL 要求串校验失败)。写入方尚未定位——热更包内不含该文件,
打包脚本也不写它。若能稳定复现将另开 issue。
Contributor guide
Research direction
Start with packages/maker-shared/src/brandIdentity.ts, apps/desktop/src/main/index.ts, and apps/desktop/package.json to trace region identity, app naming, and userData paths. Read Electron’s safeStorage initialization timing and docs/dev-rules/credentials-and-local-storage.md before choosing among the proposed fixes. Done means the approved behavior is implemented, existing credentials have a documented safe migration or fallback, and the affected macOS startup paths are observable and verified.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- electron, macos, typescript
- Domain
- desktop, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100