makecindy / makecindy/cindy

bug: safeStorage 钥匙串项名未按区域派生,cn/global 共用 "Cindy Safe Storage" 导致切版首启弹系统密码框

Open
#871 2 comments 0 reactions 0 assignees View on GitHub
bug done
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.