凭据与密钥
SparkWork 凭据与密钥管理完整指南:密钥实际有四种落点(共享 keystore 的 macOS 集中 vault、第二个 keytar 实现 TokenStore、SQLite app_settings 明文、CLI 的 credentials.json),逐层讲清各自的读写路径与保护边界。覆盖 keystore 的常量与五个 API、critical 的 legacyChecked 惰性迁移与 deleteSecret 语义、keystore_ref 列清单与 tool_package_config 的 DB 级互斥约束、ref 命名规则总表、两份同名 maskSecret 的差异、Provider 凭据从表单到真实请求的全链路(空串写库的语义坑、provider:get-api-key 的明文回显与受管 Provider 硬拒、导出会把明文 Key 写盘、测试连接会真的发请求)、平台账号与 MCP OAuth 的 TokenStore 细节(含备份文件被两个命名空间共用导致取消 MCP 授权会删掉登录备份)、三处明文落点(IM 机器人凭据还会明文回显到普通输入框、会话与项目环境变量、MCP config_json)、声明与填值分离的三条面(自定义工具 secretRefs 全生命周期、工具包 secret 环境变量与一次性安全输入、子应用连接槽)、脱敏覆盖面、删除与清理边界、备份导出与账号同步各路径的密钥行为,附 24 行排查表与事实边界。
凭据与密钥管理是 Spark Work 里最容易被误解的一块:官方文档、MCP 工具描述、界面文案 三处都在说「密钥进系统 Keychain」,但真实代码里一共有四种互不相同的落点, 其中两种是明文的。这一页按代码把每个密钥「谁写的、写到哪、谁能读、什么时候删」 逐个讲清楚。
先给四条最容易被写错的结论:
- keystore 不是唯一的 keytar 入口。包注释写着「唯一合法的 keytar 调用入口」, 但登录态用的是另一个独立实现
TokenStore,它自己直连 keytar, service 名是SparkAgent.CloudAuth,与集中 vault 完全不相干。 - macOS 上「系统 Keychain」的说法不准确。macOS 独有一条集中 vault 路径: 所有 ref 合并成一个 JSON,由 Electron
safeStorage加密后写成credential-vault-v1.enc(权限 0600)。Keychain 只在首次导入时被读一次。 - 有两处密钥是明文存进 SQLite 的:IM 机器人凭据(存
app_settings)和会话/项目环境变量(也存app_settings)。 前者还会通过 IPC 明文回传给渲染进程,并在设置页用普通输入框展示。 - Spark CLI 与桌面端完全不共用凭据。CLI 把账号 token 与 refresh token 以明文 JSON 写在
~/.spark/credentials.json,只靠 POSIX 0600 保护;spark-engine里没有任何 keytar / safeStorage 代码。
如果你只想知道「我的某一个密钥在哪」,直接跳第 2 节的落点总表; 想知道「为什么删了工具密钥还在」,看第 12 节;想排查具体故障,看第 14 节的 24 行排查表。
1. 四种落点:先建立正确的心理模型
代码里出现的「凭据」实际上落在四个地方。分清它们,后面所有疑问都能自解。
| 落点 | 典型内容 | 保护方式 | 代码位置 |
|---|---|---|---|
| ① 共享 keystore | Provider API Key、连接器账号、自定义工具密钥、工具包 secret、快照库主密钥、平台模型令牌 | macOS:safeStorage 加密的集中 vault 文件(0600);其他平台:keytar 后端 | packages/shared/src/keystore/index.ts |
| ② TokenStore(另一个 keytar 实现) | Spark 账号 access / refresh token、userId、每个 MCP 服务器的 OAuth 令牌与 client secret | keytar + 一份 safeStorage 加密的备份文件cloud-auth-session.enc | apps/desktop/src/main/services/Auth/TokenStore.ts |
③ SQLiteapp_settings明文 | IM 机器人凭据(bot token / app secret)、会话与项目环境变量、MCP 服务器 config_json | 无加密,与其它设置项同样以 JSON 文本入库 | packages/storage/src/repositories/settings.repository.ts |
| ④ CLI 的 credentials.json | CLI 登录态(token + refreshToken + userId) | 无加密,仅文件权限 0600 + 目录 0700 | spark-engine/src/platform/credentials.ts |
1.1 为什么会有四种,而不是一种
这不是疏漏叠加,而是三个真实约束的产物:
- 凭据形态不同。Provider 是「一个渠道一个 Key」,适合按 ref 分片; 账号登录态是「一次登录、一个会话」,天然是一个整体,所以走独立的 TokenStore。
- 进程边界不同。CLI 在终端里跑,没有 Electron,也没有 keytar 的可用保证 (
spark-engine全目录无 keytar 引用),所以只能退到 0600 的明文文件。 - 历史选择不同。IM 机器人凭据是跟着「设置项」这套机制长出来的, 而设置项统一明文入库;后来接入的连接器才改用 keystore 引用。
这三条不是「设计就这样」的托词——第 9 节会把明文落点的具体风险面写清楚, 你可以据此决定要不要把环境变量里的密钥迁到别处。
1.2 我怎么知道自己某个密钥在哪
按「你在哪填的」反查最快:
| 你在哪里填的 | 落在哪 |
|---|---|
| 模型页 → 添加 Provider → API Key | ① 共享 keystore,ref 形如openai-<uuid> |
| 扩展中心 → 连接器 → 连接账号 | ① 共享 keystore,ref 形如 plugin-runtime-<pluginId>-<runtimeId>-<base64url> |
| 自定义工具 Studio → 本机密钥 | ① 共享 keystore,ref 形如custom-tool:<工具 id>:<密钥名> |
| 工具包密钥 → 「配置工具密钥」弹窗 | ① 共享 keystore,ref 形如 tool-package:<包 id>:<变量名>:<sha256 前 20 位> |
| 设置 → 生态 → 远程连接 → Bot Token / App Secret | ③app_settings明文 |
| 会话配置面板 / 项目配置 → 环境变量 | ③app_settings明文 |
| MCP 服务器配置里的 headers / env(明文写) | ③mcp_servers.config_json明文 |
终端里spark login | ④~/.spark/credentials.json明文 |
2. 共享 keystore:唯一入口与它的真实边界
packages/shared/src/keystore/index.ts只有 244 行,但它是绝大多数密钥的落点。 它的模块头注释写着一句需要修正的话:
Spark Work 凭证存储模块 — 唯一合法的 keytar 调用入口。 macOS 敏感凭据集中存入一个 vault,并可由桌面端注入加密应用存储, 避免按 Provider 条目或每次启动重复请求 Keychain 授权。
「唯一合法的 keytar 调用入口」在模块自身的意义上成立(这个包是集中 vault 的唯一实现), 但它不是全应用唯一:TokenStore用动态 import('keytar')绕开了它(第 6 节)。写文档时若照抄这句,会让读者以为「所有凭据都在这一个 vault 里」, 而实际上登录态和 MCP OAuth 令牌都不在。
2.1 常量与平台分支
| 常量 | 值 | 作用 |
|---|---|---|
SERVICE_PREFIX | spark-agent | 非 macOS 平台 keytar 的 service 名 |
VAULT_ACCOUNT | credential-vault-v1 | 集中 vault 在 Keychain 里的条目名(仅作一次性导入源) |
VAULT_VERSION | 1 | vault JSON 的version;不匹配直接抛错 |
USE_CONSOLIDATED_VAULT | process.platform === 'darwin' | 只有 macOS 走集中 vault |
所以「Windows 上密钥在凭据管理器、macOS 上在一个加密文件里」这个区分是从USE_CONSOLIDATED_VAULT这一行直接推出来的,不是平台惯例的猜测。
2.2 五个函数与它们的语义差异
| 函数 | macOS 行为 | 非 macOS 行为 |
|---|---|---|
setSecret(ref, secret) | 在副本上修改 vault,持久化成功后才替换内存缓存;值与旧值相同且已迁移过则直接返回 | 直写 keytar,并写进程内directSecretCache |
getSecret(ref) | 先读 vault;未命中且该 ref 未探测过旧条目时,按需从旧 Keychain 条目迁移一次 | 读缓存 → 未命中读 keytar,并对并发同 ref 读取做去重 |
deleteSecret(ref) | 删 vault 条目,同时真的调keytar.deletePassword清理旧条目 | 删 keytar 条目并清缓存 |
hasSecret(ref) | (await getSecret(ref)) !== null—— 注意这会触发迁移逻辑 | |
preloadSecrets(refs) | 只加载一次集中 vault,绝不逐条探测旧 Keychain | 并发预读所有 ref |
mutateVault有个容易被忽略的细节:注释明确写了「始终在副本上修改。 只有 persistVault成功后才会替换vaultCache, 避免持久化失败时内存暴露未提交的凭据或意外丢失旧值」。 这意味着写失败不会污染内存态,但反过来也意味着你必须检查setSecret 是否抛错—— 它是会抛的。
2.3 集中 vault 的文件格式与写入协议
vault 本体是一个极简 JSON:
{ "version": 1, "secrets": { "<ref>": "<明文密钥>" }, "legacyChecked": ["<已探测过的旧 ref>"] }
legacyChecked是一个容易被忽略但很关键的设计:它记录「这个 ref 已经探测过旧 Keychain 条目了」, 避免每次启动都去读不存在的条目、反复触发 macOS 授权弹窗。
落盘实现是CredentialVaultPersistence.ts,它的门控条件写得非常显式:
if (process.platform !== 'darwin' || !safeStorage.isEncryptionAvailable()) return null
返回null意味着退回纯 Keychain(即每个 ref 一条 keytar 条目), 也就是旧布局。写入过程是:
- 文件路径:
{userData}/credential-vault-v1.enc; - 先写
<file>.<pid>.tmp,mode: 0o600,再rename覆盖 —— 原子替换; finally里无条件rm临时文件(force: true+catch吞错);- 读不到文件时只在
ENOENT返回null,其他错误照抛(不会静默把一个损坏的 vault 当空的)。
注入点是registerAuthIpc.ts,注释写明「应在第一次凭据访问前调用」。 另外安装版首次会弹一次KEYCHAIN_DISCLOSURE_VERSION = 1的说明框—— 这就是你可能见过的那次「为什么会访问钥匙串」的解释。
2.4 从「一个 Provider 一条 Keychain 项」迁移
迁移是惰性的,只在真正读某个 ref 时发生:
- 启动时
preloadSecrets只加载集中 vault,不探测旧条目; - 某次
getSecret(ref)在 vault 里没命中、且legacyChecked里也没有该 ref 时, 才去读旧 Keychain 条目; - 读到就写进 vault,并把 ref 记入
legacyChecked(即使没读到也记,避免重复探测); - 旧条目保留不删——注释说得很直白:「旧条目保留但不再访问,避免删除动作再次触发系统授权窗口」;
- 只有显式删除(
deleteSecret)或用户退出登录时,才真正清掉旧条目。
这解释了两个常见现象:为什么升级后第一次用某个 Provider 会弹一次授权(迁移), 以及为什么「用了新版本之后旧条目还在钥匙串里」(设计如此)。
2.5 十三个消费者
共享 keystore 的 import 点共 13 处,覆盖了除登录态与 CLI 之外的全部密钥:
| 消费者 | 用途 |
|---|---|
provider.service.ts | Provider API Key 的增删改查、健康检查、导出 |
provider-credential-resolver.ts | 运行时统一取 Key(含受管 Provider 的恢复) |
PlatformModel/PlatformCredentialStore.ts | 平台账号 access token / api key / 待支付态 |
custom-tool.service.ts | 自定义工具密钥位的读写删 |
tool-package.service.ts | 工具包 secret 环境变量、一次性安全输入 |
plugin-runtime/token-service.ts | 连接器账号 OAuth 令牌包 |
plugins/plugin-manager.service.ts | 卸载插件时清理密钥 |
github-connector.service.ts | 平台 GitHub 连接器的 PAT |
team-registry-config.ts | 团队注册表凭据 |
platform-bridge.service.ts | Agent 侧 Provider 工具的密钥解析 |
SubAppNetworkGateway.ts | 子应用连接槽背后的凭据注入 |
computer-use/SnapshotVaultKeyProvider.ts | 截图快照库的加密主密钥 |
main/ipc/index.ts | 预读与集中访问入口 |
3. 引用与明文:SQLite 里到底存了什么
这一节回答一个非常具体的问题:**数据库里有没有可能读到我的密钥?**对走 keystore 的那批,答案是「没有,只有引用」;但有三处例外(第 9 节)。
3.1 keystore_ref 列清单
| 表 | 列 | 迁移文件里的原话 |
|---|---|---|
provider_profiles | keystore_ref TEXT | 001_initial_schema.sql注释:「API Key 不在此表,通过 keychain_ref 引用系统 Keychain」 |
connector_connections | keystore_ref TEXT | 039_connector_connections.sql:3:「明文 secret 绝不入库,只保存 keystore_ref。」 |
plugin_runtime_accounts | credential_ref TEXT | 069_plugin_runtime_accounts.sql:1-2:「Secrets remain in the OS keystore; SQLite stores only credential_ref.」 |
tool_package_config | keystore_ref TEXT/value_json TEXT | 090_tool_packages.sql用 CHECK 约束强制二者互斥(见 3.2) |
custom_tools | 存secretRefs声明(JSON) | 密钥值不落库;表注释与secretNames协议注释都写明「值永不外出」 |
注意provider_profiles.keystore_ref本身是会下发给渲染进程的:rowToProfile里keystoreRef: row.keystore_ref ?? '', 协议字段注释是「Keychain 引用 ID(非明文 Key)」。 渲染端把它当「有没有配 Key」的判据用(例如快速创建面板)。 这不是泄漏——ref 只是 <providerType>-<uuid>这类字符串,猜不出密钥。
3.2 唯一一处数据库级强制:tool_package_config
整个 schema 里只有这张表用 CHECK 约束把「引用」与「明文」钉死:
CHECK ( (is_secret = 0 AND value_json IS NOT NULL AND keystore_ref IS NULL) OR (is_secret = 1 AND value_json IS NULL AND keystore_ref IS NOT NULL) )
读法:非密钥行只能有value_json,密钥行只能有keystore_ref, 两者不可能同时存在,也不可能都为空。这是全仓最强的一道「密钥不入库」保证—— 它不依赖应用层自觉,写错直接 INSERT 失败。 对照之下,provider_profiles 的「不入库」只是靠代码不放字段,没有 DB 兜底。
3.3 ref 命名规则总表
| 来源 | ref 形态 | 构造代码 |
|---|---|---|
| 普通 Provider | <providerType>-<uuid> | makeKeystoreRef(providerType, id),即 '${provider}-${profileId}' |
| 平台受管 Provider | newapi-spark-user-<userId>-api-key | 唯一非 uuid 形态;PlatformCredentialStore.ref(kind)生成 |
| 平台账号其它字段 | newapi-spark-user-<userId>-access-token | kind ∈ {access-token, api-key, pending-payment} |
| 连接器账号 | plugin-runtime-<pluginId>-<runtimeId>-<base64url> | token-service.createRef(),外部账号 id 先 base64url 再截 80 字符 |
| 自定义工具 | custom-tool:<工具 id>:<密钥名> | secretKeystoreRef(id, name);用 id+name 重新拼,不读secretRefs的值 |
| 工具包 secret | tool-package:<包 id>:<变量名>:<sha256 前 20 位> | 哈希输入含scope/scopeId/toolName,所以不同作用域是不同槽位 |
两个值得记住的推论:
- 自定义工具的
secretRefs值只是「声明留痕」—— 它用于 Agent 侧校验(必须是 canonical 形态)和界面展示,运行期解析走的是重新拼出来的 ref。 所以改secretRefs里的值不会改变真实读取位置。 - 工具包的 ref 带
scope哈希,意味着同一个密钥名在不同作用域是不同密钥。 「我在 package 作用域填了,为什么没生效」通常是因为配置写在 session 作用域。
4. 脱敏:两份同名函数,和它们各自泄露了什么
仓里有两个都叫maskSecret的函数,行为不同,用途也不同。混淆它们会写错文档。
| 实现 | 规则 | 用途 |
|---|---|---|
packages/shared/src/keystore/index.ts | 长度 ≤ 4 →****;否则前 4 个字符原样+ 最多 12 个星号 | 只用在日志:Provider 创建/更新时各一行 info 日志 |
packages/agent-runtime/src/services/runtime-composition.service.ts | 空 →(空);长度 < 4 →****;否则 首字***尾字 (N 字符) | 构建注入系统提示词的「环境变量」段 |
两者都会泄露一部分明文:前者泄露前 4 位(sk-a****这种), 后者泄露首尾各 1 位以及长度。写进日志的那份意味着日志文件里能读到密钥前缀—— 如果日志被收集或分享,要考虑这一点。
4.1 日志器本身还有一层脱敏
packages/shared/src/logger/index.ts在写入前对消息文本与结构化参数做两层处理:
- 文本清洗:
Bearer <8+位>→Bearer [redacted];sk-<8+位>→sk-[redacted]; 以及authorization / api_key / secret / token / password / pwd后跟: 或 =的值 →[redacted]。 - 结构化参数:对象里键名命中
/^(authorization|api[_-]?key|secret|token|password|pwd|bearer)$/i的值替换为'****'。
代码里自己写了边界:「仅覆盖最常见场景,不做递归深扫」—— 只处理顶层键,嵌套对象里的密钥不会被这一层拦住。 另外spark-engine自己的 CLI 日志器(observability/logger.ts)完全不做脱敏,终端里看到的就是原文。
5. Provider 凭据:从表单到真实请求
这是最完整的一条链路,看懂它就理解了其余所有 keystore 用法。
5.1 写入:什么进 keystore,什么进数据库
在「模型」页添加或编辑 Provider 时,API Key 的落点是:
ref = keystore.makeKeystoreRef(providerType, id) // 形如 openai-<uuid> await keystore.setSecret(ref, apiKey) // 明文唯一落点 // provider_profiles 只写 config_json / keystore_ref / enabled…
三条容易踩错的语义:
- 空串不等于「不传」。
createProvider用apiKey != null && apiKey.length > 0判断,空串走「没配 Key」分支 (日志是Created provider without API key (local CLI / pending key)); 但updateProvider只判断apiKey !== undefined, 所以传''会把空串写进 keystore 并把keystore_ref落库, 造成「界面显示有 Key、实际发请求时无凭据」的状态。渲染端靠apiKeyDirty避免误传空串,但 MCP 桥路径没有这层保护。 - 更新时复用旧 ref:
existing.keystore_ref || makeKeystoreRef(...), 所以轮换 Key 不会换 ref,旧值被覆盖。 - 本地 CLI 类型的 Provider 不写 keystore:
keystoreRef留空字符串, 走本机已登录的 Claude Code / Codex。
5.2 读取:resolver 的真实返回值
provider-credential-resolver.ts只有 73 行,但它是所有运行期取 Key 的唯一入口 (会话执行、媒体路由、标题抽取、provider-vision 工具、工具包内建能力都走它)。逻辑是:
- 有 ref 就
getSecret(ref)?.trim() || null,否则null; - 解析
config_json判断是不是平台受管(managed+managedType:'newapi'); - 非受管:直接返回
currentSecret ?? ''——不抛错; - 受管且有恢复器:调恢复器,拿到新值就回写 keystore,最后返回
recovered?.trim() ?? ''。
第 3 步的「不抛错」很关键:缺 Key 不会在 resolver 报错,而是层层向上, 由各调用方自己决定怎么处理,而它们的行为并不统一:
| 调用方 | 缺 Key 时的行为 |
|---|---|
| 会话执行器 | 抛错:API key not found for provider <id>(ref 都缺时更早抛 has no keystore ref) |
| 媒体路由 | catch 后跳过该 Provider并 warn,不中断其它渠道 |
| 会话内图片工具上下文 | 只判空返回null,不 try/catch |
第 4 步有个实现细节值得知道:返回的是recovered ?? '',不是recovered ?? currentSecret ?? ''。所以平台受管 Provider 在恢复器返回 null 时, 即使 keystore 里有一份可用值也会被当成空——这解释了「平台模型偶发报凭据不可用,重试又好」的一部分现象。
5.3 明文回显:provider:get-api-key 是刻意的
这是最需要准确表述的一点。渲染进程确实能拿到明文 Key:
typedIpcHandle('provider:get-api-key', async (req) => { const apiKey = await getProviderService().getProviderApiKey(req.id) return { apiKey } })
服务层注释写明了用途与约束: 「按需返回单个可编辑 Provider 的明文 Key,供受信任的编辑界面回显。调用方不得记录、持久化或批量请求返回值。」 协议层注释同样限定:「仅供 Provider 编辑界面按需回显当前凭据。不得并入provider:list」。
也就是说:provider:list只回keystoreRef(引用,不是密钥), 只有打开某个 Provider 的编辑抽屉时才会单独拉一次明文,用来回填输入框 (渲染端把它存在 React state 里,靠apiKeyDirty决定是否回写, 模块注释写明「明文只作为返回值短暂进入编辑表单状态,不在本模块持久化」)。
两条边界:
- 受管 Provider 被硬拒:
if (isManagedProviderRow(row)) throw new Error('平台官方 Provider 由系统管理,不能读取凭据'), 且受管 Provider 也禁止手动编辑与删除。测试把这三条都锁死了。 - 但渲染端不是唯一能调到它的地方。V1 子应用默认
trusted, 其 IPC 桥在checkPermission里对 trusted 直接放行、并把任意通道转发给宿主window.spark.invoke;仓里的测试用例正是拿provider:get-api-key做的示例。 换言之,一个 V1 子应用跑起来就能读到 Provider 明文 Key—— 这条链路两段都有代码证据,是否算问题取决于你怎么看「V1 子应用 = 一等内部应用」这个前提。
5.4 导出会把明文 Key 写进普通文件
exportProviders的文档注释直说:「apiKey 从 Keychain 读取后附带到导出 profile 中」。 导出 schema 里apiKey是合法字段,导出格式版本 PROVIDER_EXPORT_VERSION = 2, 注释标注「v2(含 apiKey)」。
「导出到文件」的 IPC 处理是:弹保存对话框(默认文件名spark-agent-providers-YYYY-MM-DD.json,标题「导出 Provider 配置」)→JSON.stringify(payload, null, 2)→fs.writeFile。整个过程没有任何「该文件包含明文密钥」的提示,界面上只 toast 一句「已导出 N 个 Provider」。
所以:把 Provider 配置导出成 JSON 再发给别人 / 提交进仓库 / 放进网盘,等于直接泄露 API Key。 要分享配置,用第 13 节的.sparkflow工作流包(它会把密钥替换成占位符), 而不是 Provider 导出。受管 Provider 会被导出逻辑跳过,不会进文件。
5.5 测试连接会真的发请求
testConnection会从 keystore 取真实 Key 并发一次最小请求:
- Anthropic 系:
POST {endpoint}/v1/messages,头带x-api-key与anthropic-version: 2023-06-01,body 是{model, max_tokens:1, messages:[{role:'user',content:'ping'}]}; - OpenAI 系:
Authorization: Bearer <key>,按codexApiKind走/chat/completions//responses//embeddings; - 超时
PROVIDER_CONNECTION_TIMEOUT_MS = 15_000,超时文案「连接测试超时(>15s)…」,其余错误经describeNetworkError归一; - 受管 Provider不发请求,只判断解析出的 Key 是否非空,失败文案是「平台模型凭据尚未就绪」。
「获取模型」走同一套凭据解析(无 Key 抛API Key is required to fetch models), 失败时会把响应体里出现的 Key 替换成[REDACTED]。 IPC 层日志只打 provider / id / model / healthy / latency,不打 Key。
6. TokenStore:第二个 keytar 实现
Auth/TokenStore.ts是「唯一 keytar 入口」这句话的实际例外。 它用 import('keytar')动态引入,自己定义 service 与三个条目,不走共享 keystore。
| 项 | 值 |
|---|---|
| keytar service(登录态) | SparkAgent.CloudAuth,可用环境变量SPARK_AUTH_KEYTAR_SERVICE 覆盖(E2E 实例借此隔离) |
| keytar service(MCP OAuth) | spark-mcp-oauth:<serverId> |
| keytar account | auth_token/refresh_token/user_id(三条独立条目,不是一条 JSON) |
| 加密备份文件 | {userData}/cloud-auth-session.enc,safeStorage 加密 |
| keytar 操作超时 | 3_000ms,超时按「keytar 不可用」处理 |
6.1 读写顺序与文件头注释相反
文件头注释说「Primary storage is keytar」,但实现是:
load()先读加密备份,命中即返回;只有备份不存在/校验失败才回落 keytar;save()先写备份,再写 keytar;keytar 不可用时直接 return;clear()先删备份,再删 keytar 三条。
这个顺序的实际后果是好的(keytar 出问题时仍能保住登录态),但要知道keytar 与备份可能不一致,而读优先级站在备份这边。 另外全新安装会传 allowLegacyKeytarFallback: databaseExistedBeforeInitialization, 避免为不存在的旧条目弹一次钥匙串授权。
6.2 备份文件被两个命名空间共用(含一个可复现的缺陷)
备份文件名是常量cloud-auth-session.enc,getEncryptedBackupPersistence()只用它拼路径,不带 service。而load()又要求payload.service === this.service才算命中。两者叠加产生两个后果:
- 互相覆盖:登录态的 TokenStore 与 MCP OAuth 的 TokenStore 写的是同一个文件,后写的赢;被覆盖的一方下次启动读回来是「service 不匹配 → null」, 静默退回 keytar。
- 删除会误伤:
deleteEncryptedBackup()直接rm(join(userData, 'cloud-auth-session.enc')),不校验 service。 而mcp:deauthorize→McpOAuthService.deauthorize→DesktopMcpOAuthStore.clearAll→TokenStore.clear()。 也就是取消授权一个 MCP 服务器,会连带删掉云端登录会话的加密备份。
影响范围有限(登录态通常同时在 keytar 里,且下次save()会重建备份), 但如果恰好叠加 keytar 不可用,就会走到「登录态只剩内存、重启即丢」。 另外这个备份文件的写入没有 mode、也没有 tmp+rename 原子替换—— 与同目录的 vault 文件(0o600+ rename)标准不一致。
6.3 续期与「一条没人收的明文广播」
客户端没有过期时间判定(会话结构里就没有 expiresAt),续期完全由 401 驱动: 401 → 刷新并重试 → 再 401 允许再刷一次;刷新有模块级单飞锁避免并发。
刷新成功后handleTokenRefreshed会发一条流式事件:
this.emitStream('stream:auth:token-refreshed', { token: session.token, refreshToken: session.refreshToken, userId: session.userId, })
这条广播携带完整明文 token 与 refreshToken,而渲染端与 preload 里没有任何订阅者(全仓检索该通道名,只有主进程的定义与发射点)。 目前它不构成泄露(没人接收),但它的存在意味着任何后续添加的订阅者都会直接拿到明文凭据—— 要接这条流的话,应当先把它改成只发「已刷新」信号。
6.4 退出登录删了什么、没删什么
AuthService.logout()会先尝试 POST/auth/logout(失败继续), 再跑 logout hooks,最后tokenStore.clear()。 但全仓addLogoutHook 只有一个调用方:PlatformModelService。
| 密钥 | 退出登录时 |
|---|---|
| Spark 账号 access / refresh token | 删除(备份 + keytar 三条) |
平台模型凭据(newapi-spark-user-<id>-access-token/ -api-key) | 删除(唯一的 logout hook) |
| Provider API Key | 保留——没有对应 hook |
| 平台 GitHub 连接器 PAT | 保留 |
| 连接器账号 OAuth 令牌包 | 保留 |
| MCP OAuth 令牌 | 保留 |
还有两个边界情况:如果 keytar 在本进程曾被判定为不可用,clear()会在删完备份后提前 return,把 keytar 里那三条留在系统中; 另外keytarUnavailable一旦置位,本进程内不会再重试写 keytar。
7. 账号类凭据:连接器、平台连接器、团队注册表
7.1 连接器账号(plugin-runtime)
在扩展中心 →「连接器」里连接 GitHub / Notion / Google / Obsidian 账号时, 凭据的写法与 Provider 不同:渲染端把明文 secret 通过 IPC 交给主进程(plugin-runtime:accounts:connect的request.secrets), 主进程侧再校验长度上限(最多 32 条、名 ≤120、值 ≤16_000)后写 keystore。 唯一的例外是 OAuth 路径(accounts:authorize)——它在主进程里跑 PKCE + loopback,请求结构里根本没有 secrets 字段,token 不经过渲染进程。
落库的只有一个引用:plugin_runtime_accounts.credential_ref。 连接成功后返回的账号对象不含任何密钥字段(toAccount()只映射token_expires_at等非敏感列,完全不碰credential_ref)。
存储内容是一个 JSON 字符串(StoredCredentialBundle:accessToken必填,refreshToken/tokenType/expiresAt/clientId/scopes可选)。 取用时若 expiresAt距现在不足 30 秒会先刷新,刷新有同 ref 单飞锁; JSON 损坏会被当成「需要重新授权」(AUTH_REQUIRED)。
两个边界:断开账号只删本地密文,不做 provider 侧撤销(四个内置 adapter 都没有实现可选的disconnect), 所以「断开」后你仍应去 GitHub 侧手动吊销那个 token; 另外如果 adapter 的 disconnect 抛错,删除密文这一步不会执行, 会出现「界面提示断开失败但账号行还在、密文也还在」的状态。
7.2 平台 GitHub 连接器
这是与 7.1 同名但完全不同的另一套(第 3 轮「连接器」页讲过两套实现并存)。 它的 ref 是固定形态 github-connector-<id>(id 默认github-primary,即常态是github-connector-github-primary), 已存在则复用旧 ref,所以轮换 PAT 不换 ref。
- 写入:
connect()先setSecret再 upsert DB; - 更新不碰 token:
updateConnection的参数类型里根本没有 token 字段, 换 PAT 走的是connect(界面文案「更新 PAT 并重新验证」与实现一致); - 读取:每次工具调用前取一次,错误分三档——ref 缺失 →
KEYSTORE_KEY_NOT_FOUND(「缺少凭证引用」)、 读抛错 →KEYSTORE_UNAVAILABLE(「系统凭证库不可用」)、读到空 → 「GitHub PAT 不存在,请重新连接」; - 没有掩码字段:协议里没有 token / mask 字段,界面就是一个空白的密码输入框 + 提示文案, 不存在「回显已保存 PAT」的路径。
7.3 团队注册表(Nacos)
存的是团队注册中心的账号密码,ref 是字面量形态team-registry-nacos-password(服务器地址 / namespace / username 走 settings,只有密码进 keystore)。 保存语义有个细节:password仅在显式提供时才动—— 传空串等于「清除密码」(调deleteSecret),不传则保持原值。 界面侧只能拿到一个布尔hasPassword, 「测试连接」用的是未保存的表单值直接试连,不会把密码写进去。
8. 「只声明引用、不填值」的三条面
这三处是官方设计里最讲究的部分:Agent 只能声明需要一个密钥, 值必须由人在受保护的界面里输入。它们各自用不同的机制实现同一目标。
8.1 自定义工具:secretRefs
协议层的声明约束是:
const CustomToolSecretRefsSchema = z .record(z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), z.string().min(1).max(512)) .refine((refs) => Object.keys(refs).length <= 16, '密钥引用数量超过上限 16')
拆开读:键(逻辑密钥名)受^[A-Za-z0-9_-]{1,64}$ 约束;值只要求 1–512 字符、无正则——因为规范化后的值形如custom-tool:<id>:<name>,含冒号,作为「值」合法但作为敏感头的secretRef就非法(那里同样要求^[A-Za-z0-9_-]{1,64}$)。
Agent 侧只接受规范化引用:authoring facade 会逐条比对ref !== canonicalSecretRef(id, name),不符即报 「Agent 接口只接受规范化密钥引用,不接受密钥值或自定义存储位置」。 它的模块注释写明了设计意图:「It deliberately exposes no secret-write API. Agents may declare secretRef slots, while the actual values must still be entered through the trusted desktop Keychain form.」
类型专属封禁两条:code工具不允许secretRefs(「代码工具不接收 Keychain 明文;请通过受管 HTTP 工具组合外部能力」),provider-vision同样不允许(「图像处理工具复用 Provider Keychain 凭据,不允许另存工具密钥」)。 另外「声明了必须用上」也被强制:声明了却没有任何请求头引用的密钥名会报 「密钥 <name> 未被任何请求头引用」。
还有一道明文形态黑名单:spec 模板里出现sk-…/AKIA…/ghp_…/gho_…/xox[baprs]-…/ 硬编码Bearer …/ PEM 私钥,直接拒绝, 提示「模板中检测到疑似密钥明文,请改用密钥库(secretRefs)存储」。 敏感请求头(键名命中 token/secret/password/authorization/cookie/credential/api_key/private_key这类) 必须绑定secretRef,且不能与valueTemplate 同时出现。
生命周期门控(都基于hasSecret()判断):
| 时机 | 行为 |
|---|---|
| 创建 | 声明了密钥的工具默认禁用(enabled: !hasToolSecrets),等界面写完密钥再开 |
| 发布 | 缺密钥直接抛SECRET_MISSING:「工具 <id> 缺少密钥:<名字>」 |
| 启用 | 同样拒绝;判据用的是已发布版本的声明,未发布草稿新增的密钥不影响当前版本启停 |
| 回滚 | 目标历史版本缺密钥则拒绝:「工具 <id> 的历史版本 v<n> 缺少密钥:…」 |
| 草稿引入新密钥 | 若工具当前已启用,自动停用,防止未填值的版本进入工具面 |
| 执行期 | 取不到值报「工具 <id> 的密钥 <name> 尚未写入密钥库」 |
写入的唯一入口是custom-tools:write-secret,服务层用if (!(name in refs))把可写范围钉死在该工具已声明的密钥位上—— 它不能被当成通用 keystore 写接口滥用。 界面在 Studio 的「本机密钥」区块,用InputPassword, 旁注文案是「密钥写入系统 Keychain,不进入 SQLite、导出文件或工具描述」, 已保存时 placeholder 变成「留空则保持原值」。 密钥名不是单独添加的,而是从请求头声明里倒推(secretNamesFromHeaders)。
一个值得知道的角落:发布新版本时移除了某个密钥位 X, 孤儿清理会把它从 keystore 删掉。此后你要回滚到旧版本会先被missingSecrets拦住(报SECRET_MISSING), 而重填 X 的入口又要求「X 必须在当前声明里」——于是报 「工具 <id> 未声明密钥位 <X>」。**解法是先在草稿里把 X 重新声明出来,再补值。**另外孤儿清理失败只记 warn,不阻断已完成的发布/回滚,所以 keystore 里可能残留不再被引用的密钥位。
8.2 工具包:环境变量与一次性安全输入
工具包没有secretRefs字段——它的声明面是spark-tool.json的environment[]:
{ "name": "EXTERNAL_API_TOKEN", // 必须 ^[A-Z_][A-Z0-9_]{0,127}$ "title": "外部接口令牌", "type": "string", "required": true, "secret": true, // ← 关键位 "agentConfigurable": false }
schema 对 secret 变量有两条额外约束:不能声明default(「Secret environment variables cannot declare defaults」)、只能用 string 类型(「must use string type」)。
值落在tool_package_config,受第 3.2 节那条 CHECK 约束保护。 Agent 侧结构性地无法传明文,双重保证:
tool_packages_request_secret的 inputSchema 里根本没有 value 属性, 且additionalProperties: false,工具描述写明「密钥由用户在应用内受保护表单填写, 绝不进入 Agent 参数或消息记录」;- 即便走
tool_packages_configure_environment的value通道, 服务层硬拒:if (variable.secret) throw new Error('... is secret and requires secure input'), 并且对actor === 'agent'还要求agentConfigurable为真。
一次性安全输入的完整机制:
| 项 | 值 |
|---|---|
| 状态枚举 | pending/completed/cancelled/ expired |
| TTL | 默认 15 分钟,钳制在 1 分钟 ~ 1 小时之间 |
| 过期判定 | 惰性——枚举时把过期行置为expired并跳过,没有定时器 |
| 同目标重复请求 | 复用同一条 pending 请求,不重复创建 |
| 完成时的写入顺序 | 先写 keystore → 再写 DB;DB 失败则把 keystore 回滚到旧值(或删除) |
| 并发完成 | 单飞去重,重复完成报Secure input request is already being fulfilled |
界面是ToolPackageSecretRequestHost(挂在 App 根部),弹窗标题 「配置工具密钥」,按钮「保存到 Keychain」/「取消」,输入用InputPassword, 说明文案:「密钥不会发送给 Agent,也不会写入会话记录或 SQLite; 主进程收到后会直接保存到系统 Keychain。请求将在 <到期时间> 失效。」 它有一个值得点赞的细节:切换请求时会清空输入框, 注释写着*「Never carry a secret typed for one request into another request.」*刷新走事件流而非轮询。工具包面板里也有入口,secret 变量带Keychain标签, 按钮文案是「安全配置」/「更新密钥」。
卸载时会清理密钥,但顺序与边界要知道:先停用 → 收集 ref(在删库之前)→ 删包目录 → 删 DB 行 → 逐个删 keystore,返回removedSecrets计数; 删除失败是静默的(.catch(() => false),只影响计数,卸载仍报成功)。 另外删单个版本不会删密钥——只有整体卸载才删, 所以delete_version 之后 package 级的 secret 会留在 keystore 里。
子进程侧还有一层保护:注入 secret 变量后,工具进程的 stderr 会把已知明文替换成[REDACTED](按值做split/join替换,不是靠正则猜)。 检查器也会警告「敏感环境变量不会从工具包读取,必须由安全配置流程写入 Keychain」。
8.3 子应用连接槽:只能声明槽,不能声明值
V2 子应用 manifest 里的连接声明是:
export interface SubAppConnectionDeclaration { kind: 'http-api' | 'provider' displayName: string allowedOrigins: string[] allowPrivateNetwork?: boolean }
没有 secret / token / value 字段,而且 schema 是.strict()—— 往连接槽里塞"secret": "..."会被 zod 拒绝,转成 level:'error'的MANIFEST_INVALID诊断,并阻断发布与预览。
绑定工具spark_app_connections_bind只接受引用 id(bindingKind+bindingId,没有 secret 参数), 相邻的 spark_app_connections_list描述写着「不返回密钥」。 落库的 sub_app_connection_bindings表也只有引用列。 委托的 origin 必须落在槽声明的 allowedOrigins内,服务端会校验 「授权 origin 超出 manifest 声明范围」。allowPrivateNetwork是AND 语义:声明为真且请求也为真才生效——声明能否决请求,请求不能放宽声明。
运行时真正的密钥注入在主进程:SubAppNetworkGateway按绑定取出凭据, 从连接行或 Provider 解析,然后在宿主侧注入请求头。 子应用提交的头会先过黑名单(authorization/x-api-key/x-goog-api-key/ api-key/cookie/host… 一律丢弃), 允许注入的凭据头被限制在四项, 响应体还会做按值替换脱敏、响应头只回传少数几个白名单项。 也就是说受管请求路径本身不会把密钥交给子应用—— 除非子应用是 V1 trusted(那就是另一回事,见第 14 节)。
9. 明文落点:三处密钥真的没加密
前面所有机制都建立在 keystore 之上。这一节讲清楚三处不走 keystore的落点, 以及它们各自的真实暴露面。
9.1 IM 机器人凭据:明文入库,且明文回显
设置 → 生态 → 远程连接里填的 Bot Token / App Secret,落点是app_settings表的 (category='remote-connections', key='data'), 值是一整棵 { global, connections }的 JSON。 逐跳证据链是:
- 凭据原样保留:
sanitizeConnection里credentials: isRecord(input.credentials) ? { ...input.credentials } : {},不做任何掩码; - 保存时
credentials: { ...base.credentials, ...(patch.credentials ?? {}) }; writeStore→settingsService.set('remote-connections', 'data', store);SettingsService是纯转发到SettingsRepository.set;- 仓储层
this.toJson(value)后写进app_settings.value文本列——没有任何加密。
涉及字段(RemoteConnectionCredentials):botToken、appId、appSecret、webhookUrl、qqBotAppId、qqBotToken、qqBotSecret、clawEndpoint、clawAccessToken。
它与 Provider 的两点差别,比「加密与否」更值得注意:
- 明文会回传到渲染进程:
remote:list直接返回store.connections(就是readStore(),只做形状归一),credentials原样带出去。渲染端把它塞进draft。 - 界面用的是普通输入框,不是密码框:
RemoteCredentialFields里 Bot Token / App Secret / 机器人 AppSecret / Access Token 用的都是Input,整个SettingsView.tsx里InputPassword与type="password"的出现次数是0。 所以已保存的 bot token 会以明文显示在设置页输入框里。
需要更正既有文档:connectors、desktop-guide与 remote-connections三页原先都写了 「远程连接凭据是app_settings 里的明文 JSON,界面只做掩码显示」 (其中remote-connections 出现了两处)。 前半句正确,后半句与代码不符——本轮已把这四处措辞统一改为如实描述。
怎么降低风险:这个通道本来就是给本机 bot 用的,建议用权限最小化的 bot(只给必要的群/命令),并注意userData目录与数据库备份会被完整复制 (第 13 节)——那不是「只有密钥文件」的问题,而是整库带走。
9.2 环境变量:明文入库 + 脱敏后进系统提示词
会话配置面板与项目配置里的「环境变量」是另一处明文落点。 存储走 RuntimeCompositionService→settingsRepo:
const ENV_CATEGORY = 'runtime.env' settingsRepo.set(ENV_CATEGORY, `${scope}:${scopeRef}`, layer)
即app_settings里project:<workspaceId>与session:<sessionId>两行,值含真实明文。 合并规则是「会话级覆盖项目级,同名键后者胜」。
这些真实值有两个去向:
- 注入子进程 env(
result.customEnv = effectiveEnv)—— 这是它存在的目的,也意味着你在这里放的 API Key 会进入 Agent 起的所有工具子进程; - 脱敏后进系统提示词:
buildEnvSystemPrompt把键名、描述与maskSecret后的值(首字***尾字 (N 字符))拼成[Environment Variables]段落,并明确要求模型 「通过变量名引用真实值……不要在回复或日志中打印真实值,也不要要求用户重新提供这些敏感信息」。
所以模型的系统提示词里能看到「存在 KEY_A,长度 40」,但看不到值。 这比「什么都不告诉模型」更好用,但长度与首尾字符确实进了上下文—— 如果你介意,就别把密钥放在这里,改用自定义工具的 secretRefs 或 Provider。
9.3 MCP 配置:明文入库,并随工作流包被脱敏
MCP 服务器的config_json在mcp_servers表里是明文 TEXT, 你手写的 headers/env原样入库。 这一处是唯一有自动脱敏保护的明文落点:导出工作流包时,redactMcpConfig会把
headers.*与env.*下的所有字符串值,以及- 任意层级里键名命中敏感模式的字符串值 (
/(token|secret|password|credential|authorization|(?:api|access|private)[_.-]?key)/i)
替换成{{secret:<路径>}}占位符,并把路径登记进requiredSecrets。注释写着「安全优先,宁多勿漏」。 但要注意它只覆盖 MCP 配置:同一份工作流包里的技能文件与 Agent 提示词是原样打包的, 没有扫描——把 Key 写进SKILL.md或脚本里,导出时会一起带走。
9.4 Spark CLI:完全独立的一套(明文)
CLI 与桌面端不共用任何凭据存储。两个方向都能证明:spark-engine全目录没有keytar/safeStorage/credential-vault的任何引用(CLI 打不开桌面端 vault), 而 credentials.json这个名字在apps/desktop里零命中 (桌面端从不读 CLI 文件)。
CLI 的账号会话落在~/.spark/credentials.json(SPARK_HOME可改):
{ "version": 1, "serverUrl": "https://…", "session": { "token": "…", "refreshToken": "…", // 注意:refresh token 也是明文 "userId": "…" }, "account": { "id": 1, "account": "…", "nickname": "…", "role": "…" }, "updatedAt": "2026-…" }
保护措施只有文件系统层面,但做得比较扎实:
- 文件
0o600、目录0o700; - 原子写:先写
.credentials.json.<pid>.tmp再 rename,写入失败不会截断一个可用会话; - 读时修复权限:发现组/其他可读会 chmod 回 0600 并 warn 「platform credentials at <path> were group/world readable; tightening to 0600」;
- 首次写入失败抛
PlatformCredentialError(不会静默丢会话)。
三个子命令的行为边界:
| 命令 | 行为 | 注意 |
|---|---|---|
spark login | 浏览器 PKCE 登录 → 存文件;--json只回 {authenticated, userId, serverUrl, loginUrl, openedInBrowser, credentialPath, account} | 不打印 token;切换服务器会提示「Note: replacing the stored session for <old> with a session for <new>」 |
spark logout | 只删本地文件 | 不做服务端撤销——CLI 的 server client 里没有任何 logout / revoke 端点。所以被复制走的 refresh token 在 logout 后仍然有效,需要去账号侧吊销 |
spark whoami | 显示身份;会话过期则删除本地文件并提示「Your Spark account session expired. Runspark loginagain.」 | 存储的 serverUrl 与当前配置不一致会提示归属错位 |
CLI 不存模型 API Key。它只记「该去哪个环境变量取 Key」:~/.spark/config.toml里写api_key_env = "MY_KEY_VAR", 变量名必须匹配^[A-Z_][A-Z0-9_]*$,否则报 「Invalid credential environment variable …: use UPPER_SNAKE_CASE (the key itself is never stored)」。 运行时从process.env取,缺失时报 「Provider <x> requires credential environment variable <VAR>」。 这一条与桌面端的差异值得记住:桌面端把 Key 存起来,CLI 要求你自己 export 出来。
当 CLI 用桌面端托管的渠道时,它的「api key」是桌面 bridge 的一次性 token: 32 字节随机 base64url,写在~/.spark/hosts/sparkwork/bridge-<实例 id>.json(0600,目录 0700,原子写,bridge 停止时删除)。 它不会被复制进config.toml,也被测试锁定不得出现在配置快照里。
10. 脱敏覆盖面:哪些地方会打码,哪些不会
「会不会被脱敏」这个问题没有统一答案,下表是逐处核实的结果。
| 位置 | 是否脱敏 | 机制 |
|---|---|---|
| 桌面端控制台 / 文件日志 | 是 | logger 的sanitizeLogText+sanitizeArgs(Bearer / sk- / key=value/ 敏感键名) |
| Provider 创建与更新的日志 | 部分 | maskSecret,保留前 4 个字符 |
| 会话环境变量进系统提示词 | 是 | 保留首尾各 1 字 + 长度 |
| Hooks 运行记录 | 是 | hook-redaction.ts:敏感键名的值替换为***,字符串截断到 512 字符,深度 6 |
| 媒体调试日志 | 是 | 键名正则 + URL 只留?redacted=1+ 请求体变成 [REDACTED content chars=N] |
| 工具包子进程 stderr | 是 | 按已知明文值做split/join替换为[REDACTED] |
| 子应用网络网关的响应体 | 是 | 按已知明文值替换为[REDACTED] |
| 自定义工具的测试输出 | 是 | redactTestOutput:先按 keystore 真值替换,再叠加Bearer/sk-等正则 |
| 媒体渠道诊断返回值 | 是 | sanitizeDiagnosticValue+redactHeaders+ redactUrl+redactKnownSecrets |
| 会话记录里的工具调用入参 | 未发现脱敏层 | 会话/事件持久化路径上没有对应的脱敏包装 |
| CLI 终端日志 | 否 | observability/logger.ts直接写 [spark:<ns>] <level>: <message>,无任何替换 |
| Provider 错误信息(CLI 侧) | 部分 | error-detail.ts按键名脱敏且只保留白名单字段,但不对字符串内的sk-…做子串清洗 |
最后两行的写法是刻意的:「未发现脱敏层」不等于「已被证明不脱敏」。 我们的检索覆盖了会话持久化、事件写入与 logger,没有找到包装点; 但如果你要把密钥打印进工具输出,最安全的假设仍然是「它会原样留在会话记录里」。
11. 删除与清理:谁能删掉密钥
| 操作 | 会删什么 | 边界 |
|---|---|---|
| 删除 Provider | 先deleteSecret(row.keystore_ref),再删 DB 行 | 受管 Provider 与内置 Provider / Auto Router 直接拒绝删除 |
| 退出登录 | 账号 token(keytar 三条 + 备份)、平台模型凭据 | 不动Provider Key / GitHub PAT / 连接器 OAuth / MCP OAuth;keytar 曾不可用时会提前 return,把 keytar 里的条目留下 |
| 删除自定义工具 | 先禁用(跨 SQLite/Keychain 边界前的保护),再删该工具声明的全部密钥 | 删除失败时报「工具已停用,但 Keychain 密钥清理失败;请稍后重试删除」——工具仍可见可重试 |
| 自定义工具移除密钥位 | 孤儿清理删掉不再被引用的 ref | 失败静默(只 warn),且会造成第 8.1 节那个「无法直接回滚」的角落 |
| 卸载工具包 | 逐条删tool-package:*,返回删除计数 | 必须先停用;删除失败静默;删单个版本不删密钥 |
| 断开连接器账号 | 删connect_accounts.credential_ref指向的密文 | 不做 provider 侧撤销;adapter 抛错时删除不执行 |
| 平台 GitHub 连接器断开 | deleteSecret(keystore_ref)后删行 | 同样不做 GitHub 侧吊销 |
| 卸载插件 | 遍历该插件的账号删各自credential_ref,再删资源行与安装目录 | 无事务:删密钥抛错会中断卸载;rm 失败则密钥已不可恢复。且不清理spark-mcp-oauth:*与 connector_connections.keystore_ref |
| 删除 MCP 服务器 | 只删 DB 行 | 不清理spark-mcp-oauth:<serverId>的 token → 孤儿密钥长期留存 |
| 显式删某个 ref | macOS 下同时删 vault 条目与旧 Keychain 条目 | 这是唯一会真正清掉旧条目的路径;自动迁移阶段故意保留旧条目以免反复弹授权 |
12. 备份、导出与同步:密钥会不会跟着走
这是最实际的一组问题——「我把配置分享/备份出去,会带上密钥吗?」
| 路径 | 会带密钥吗 | 说明 |
|---|---|---|
| Provider 导出 JSON / 导出到文件 | 会,明文 | 从 keystore 读出并写入普通 JSON;无任何「含密钥」提示。分享配置请改用工作流包 |
工作流包.sparkflow | MCP 配置不会;技能与 Agent 提示词会 | MCP 的headers.*/env.*与敏感键名被替换为 {{secret:路径}},但技能文件与提示词原样打包、不扫描 |
| 团队包 | 不会(同套脱敏) | 且比工作流包更好的一点:更新已存在 MCP 时会保留接收方已填的密钥(mergePreserveSecrets),不会用占位符覆盖回去 |
子应用分享包.sparkapp | Provider Key 不会;源码/data 里的明文会 | 设计如此:疑似明文密钥只做「位置提示」(secretHints只含 location,不含值),包内保留原文以保证可用性 |
| Agent / 团队资产导出 | hookConfig原样写出 | 没有扫描或清理;hook 动作类型本身只有builtin.sound/ tool.invoke,所以是否藏密钥取决于你写了什么 |
| 自定义工具导出 | 不会 | 只导出secretNames(名字)。注意导出物里含secretRefs的规范化引用字符串,那不是密钥值 |
| 账号同步 | 不会 | 三重过滤,见下 |
整个userData目录 / 数据库备份 | 会,且包含明文那几处 | 升级前自动备份是整库拷贝,因此app_settings里的 IM 凭据、环境变量、MCPconfig_json都会进备份目录;keystore 里的密钥不会(它们不在库里) |
CLIcredentials.json | 会,明文 | 含 refresh token;备份/同步~/.spark等于带走登录态 |
账号同步的过滤值得单独说,因为它是全仓最严格的一处。三重机制:
- 分类白名单:只同步
customCommands、prompts、memory、assistants、workflows、appearance、promptLibrary七类; - 字段白名单:每个分类只允许列出的顶层字段(
assistants明确排除providerProfileId/modelId/agentAdapter/mcpServerIds/hookConfig等本地字段),metadata只允许avatar一个键; - 键名与值双重扫描:键名命中
/(api.?key|access.?token|refresh.?token|token|password|secret|credential|authorization|cookie|headers?|env…|keystore|provider.?profile|mcp|hooks?)/i即拒;值命中 PEM 私钥 /Bearer <16+>/ JWT /sk-/rk-/pk-/ghp_/xox…/https://user:pass@host即拒。 违规项整条跳过而不是上传。
一个副作用值得知道:那条键名正则包含裸mcp, 而工作流图的节点可以合法携带 mcpServerIds,图又是整体上传的—— 于是绑定了 MCP 服务器的工作流会被静默跳过同步(原因码 SYNC_FORBIDDEN_FIELD), 并被加入保护列表以免云端版本覆盖本地。这是安全过滤的连带效果,不是 bug,但你会觉得「这个工作流怎么没同步」。
13. 排查表
| 现象 | 最可能的原因 | 怎么确认 / 怎么办 |
|---|---|---|
| 保存 Provider 后提示「有 Key」但调用报无凭据 | 更新时传了空串:updateProvider({apiKey: ''})会把空串写进 keystore 并把keystore_ref落库 | 重新填一次 Key 并保存;判断依据是hasApiKey = Boolean(keystoreRef),它只看 ref 存在与否 |
| 平台官方 Provider 读取 Key 报「不能读取凭据」 | 受管 Provider 被硬拒——这是设计 | 受管 Provider 也不能手改、不能删除,只能在偏好弹窗里调模型 |
| 平台模型偶发「凭据尚未就绪」 | 受管恢复器返回 null 时会丢弃 keystore 里已有的值(返回recovered ?? '') | 触发一次平台模型 bootstrap / 重新验证即可回写 keystore |
| macOS 上每次启动都弹钥匙串授权 | vault 文件写入失败(vault 无法缓存),于是每次都回落到读 Keychain | 检查{userData}/credential-vault-v1.enc 是否存在、可写;日志里会有failed to cache credential vault in app storage |
| 升级后第一次用某个 Provider 弹了一次授权 | 旧 Keychain 条目的惰性迁移(正常行为) | 迁移后该 ref 记入legacyChecked,不会重复弹;旧条目会在显式删除时清掉 |
钥匙串里还能看到旧的spark-agent条目 | 迁移策略故意保留旧条目,避免删除动作反复触发授权 | 要清就显式删除该 ref,或退出登录 |
自定义工具发布报SECRET_MISSING | 声明的密钥位还没写值(hasSecret为假) | 在 Studio 的「本机密钥」里填;判据用的是已发布版本的声明 |
| 回滚自定义工具报「未声明密钥位 <X>」 | 旧版本用过的密钥位 X 已被孤儿清理删掉,而当前声明里没有 X | 先在草稿里重新声明 X 并补值,再回滚 |
| 新建的自定义工具默认是禁用的 | 声明了密钥的工具创建时enabled: !hasToolSecrets | 写完密钥后手动启用,这是刻意的安全默认 |
| 工具包密钥「填了但没生效」 | ref 带作用域哈希,不同 scope 是不同槽位 | 确认你填的是哪个 scope(package / tool / project / agent / workflow / session) |
| Agent 试图用工具包传密钥值被拒 | 服务层硬拒:is secret and requires secure input | 正确路径是 Agent 调tool_packages_request_secret发起请求,你在弹窗里填 |
| 工具包密钥弹窗一直不出现 | 请求可能已过期(TTL 默认 15 分钟,惰性判定)或已被完成/取消 | 重新发起请求;列表只在secret-requested/configured 事件时刷新 |
| 卸载工具包后密钥还在 | 删除失败是静默的(只影响removedSecrets计数) | 看卸载返回的removedSecrets是否符合预期 |
| 只删了工具包的一个版本,密钥还在钥匙串 | 设计如此:只有整体卸载才删密钥 | 要清就走完整卸载 |
| 断开连接器账号后,GitHub 侧的授权仍然有效 | 断开只删本地密文,四个内置 adapter 都没实现 provider 侧 revoke | 去 GitHub / Notion 控制台手动吊销那个 token |
| 点「断开」提示失败但账号还在 | adapter.disconnect抛错时,删除密文这一步不会执行 | 重试断开;必要时先修好网络/权限 |
| 删除 MCP 服务器后钥匙串里还有 token | mcp:delete只删 DB 行,不清理spark-mcp-oauth:<serverId> | 先mcp:deauthorize再删除,可避免孤儿密钥 |
| 重启后要求重新登录,但钥匙串里明明有凭据 | 可能踩到备份文件的跨命名空间问题(取消 MCP 授权删掉了登录态备份)+ keytar 同时不可用 | 重新登录即可;若频繁出现,检查是否有 MCP OAuth 的取消授权操作 |
| 退出登录后 Provider Key 还在 | 只有平台模型注册了 logout hook,Provider 密钥不随退出登录清除 | 这是现状;要清就逐个删除 Provider |
| IM 机器人 token 在设置页明文显示 | 该表单用的是普通Input,且remote:list明文回传 | 现状如此(本轮已更正官网旧文案);不要在有旁观者/录屏时打开该页面 |
| 导出的 Provider JSON 里有明文 Key | exportProviders会从 keystore 读出并附带 | 不要分享该文件;分享配置用工作流包 |
| 工作流包导入后 MCP 连不上 | 占位符{{secret:…}}还没补;导入时 MCP 是禁用状态 | 在工作流包面板点「激活」,弹窗里逐项补密钥,按钮是「补齐并激活」;未补全时激活会被拒 |
spark logout后旧 token 仍能用 | CLI 的 logout 只删本地文件,不做服务端撤销 | 去账号侧吊销;并注意credentials.json是明文,别同步到云盘 |
| CLI 报「requires credential environment variable <VAR>」 | CLI 不存模型 Key,只记环境变量名 | 在 shell 里 export 该变量,或在config.toml改api_key_env 指向已 export 的变量 |
14. 事实边界与本次未核实项
按「实事求是」的要求,以下内容本轮没有在代码里找到证据,已从结论中排除:
- 不存在
apiKeyMask字段或函数。 全仓检索apiKeyMask/apiKey_masked/apiKeyPreview零命中。 外部资料里「列表接口只返回apiKeyMask」的说法与当前代码不符—— Provider 链路给渲染端的要么是明文(编辑回显)、要么是布尔(hasApiKey)、 要么是引用(keystoreRef)。 - macOS 上的「系统 Keychain」不是最终落点,而是安全存储加密后的 vault 文件; 只有 Windows / Linux 才直达 keytar 后端。多处工具描述(例如「API Key 进入系统 Keychain」) 在 macOS 上是不准确的简化说法,本文按代码描述。
- 「不进消息记录」这类承诺不成立: Provider / 媒体渠道的
apiKey是 MCP 工具的入参, 它会随 tool call 参数进入会话与模型上下文。成立的说法只有「不出现在返回值与日志中」。 - 无法验证服务端是否回传 token:微信轮询接口的响应类型里允许
token/refreshToken字段且主进程原样透传, 但服务端实现不在本仓,因此不能断言它一定会或一定不会返回凭据。 - 「会话记录一定不脱敏」不是已被证明的结论: 我们是在会话持久化路径上没有找到脱敏包装,而不是验证了它不可能存在。 涉及具体合规判断时请按更保守的假设处理。
- 快照库主密钥的丢失语义:
getSecret返回 null 时会直接生成一把新钥并覆写 ref (旧.svb全部解不开,且解密失败统一报Snapshot blob authentication failed,无法与「被篡改」区分)。 该密钥是 32 字节随机值、非派生,全程只在 keytar / vault 里以 base64 保存。
关于「哪些密钥算敏感、要不要迁移」这类判断,本文只陈述代码事实与影响面,不替你决定。 如果你的威胁模型包含「同机其它进程」或「userData会被备份到别处」, 优先关注第 9 节的三处明文落点与第 12 节的导出路径。