本页目录(44 节)

凭据与密钥

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 行排查表与事实边界。

阅读约 24 分钟最后核对 2026-10-02

凭据与密钥管理是 SparkWork 里最容易被误解的一块:官方文档、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. 四种落点:先建立正确的心理模型

代码里出现的「凭据」实际上落在四个地方。分清它们,后面所有疑问都能自解。

落点典型内容保护方式代码位置
① 共享 keystoreProvider API Key、连接器账号、自定义工具密钥、工具包 secret、快照库主密钥、平台模型令牌macOS:safeStorage 加密的集中 vault 文件(0600);其他平台:keytar 后端packages/shared/src/keystore/index.ts
② TokenStore(另一个 keytar 实现)Spark 账号 access / refresh token、userId、每个 MCP 服务器的 OAuth 令牌与 client secretkeytar + 一份 safeStorage 加密的备份文件 cloud-auth-session.encapps/desktop/src/main/services/Auth/TokenStore.ts
③ SQLite app_settings 明文IM 机器人凭据(bot token / app secret)、会话与项目环境变量、MCP 服务器 config_json无加密,与其它设置项同样以 JSON 文本入库packages/storage/src/repositories/settings.repository.ts
④ CLI 的 credentials.jsonCLI 登录态(token + refreshToken + userId)无加密,仅文件权限 0600 + 目录 0700spark-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 Agent 凭证存储模块 — 唯一合法的 keytar 调用入口。 macOS 敏感凭据集中存入一个 vault,并可由桌面端注入加密应用存储, 避免按 Provider 条目或每次启动重复请求 Keychain 授权。

「唯一合法的 keytar 调用入口」在模块自身的意义上成立(这个包是集中 vault 的唯一实现), 但它不是全应用唯一:TokenStore 用动态 import('keytar')绕开了它(第 6 节)。写文档时若照抄这句,会让读者以为「所有凭据都在这一个 vault 里」, 而实际上登录态和 MCP OAuth 令牌都不在。

2.1 常量与平台分支

常量值作用
SERVICE_PREFIXspark-agent非 macOS 平台 keytar 的 service 名
VAULT_ACCOUNTcredential-vault-v1集中 vault 在 Keychain 里的条目名(仅作一次性导入源)
VAULT_VERSION1vault JSON 的 version;不匹配直接抛错
USE_CONSOLIDATED_VAULTprocess.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 时发生:

  1. 启动时 preloadSecrets 只加载集中 vault,不探测旧条目;
  2. 某次 getSecret(ref) 在 vault 里没命中、且 legacyChecked 里也没有该 ref 时, 才去读旧 Keychain 条目;
  3. 读到就写进 vault,并把 ref 记入 legacyChecked(即使没读到也记,避免重复探测);
  4. 旧条目保留不删——注释说得很直白:「旧条目保留但不再访问,避免删除动作再次触发系统授权窗口」;
  5. 只有显式删除(deleteSecret)或用户退出登录时,才真正清掉旧条目。

这解释了两个常见现象:为什么升级后第一次用某个 Provider 会弹一次授权(迁移), 以及为什么「用了新版本之后旧条目还在钥匙串里」(设计如此)。

2.5 十三个消费者

共享 keystore 的 import 点共 13 处,覆盖了除登录态与 CLI 之外的全部密钥:

消费者用途
provider.service.tsProvider 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.tsAgent 侧 Provider 工具的密钥解析
SubAppNetworkGateway.ts子应用连接槽背后的凭据注入
computer-use/SnapshotVaultKeyProvider.ts截图快照库的加密主密钥
main/ipc/index.ts预读与集中访问入口

3. 引用与明文:SQLite 里到底存了什么

这一节回答一个非常具体的问题:数据库里有没有可能读到我的密钥?对走 keystore 的那批,答案是「没有,只有引用」;但有三处例外(第 9 节)。

3.1 keystore_ref 列清单

表列迁移文件里的原话
provider_profileskeystore_ref TEXT001_initial_schema.sql 注释:「API Key 不在此表,通过 keychain_ref 引用系统 Keychain」
connector_connectionskeystore_ref TEXT039_connector_connections.sql:3:「明文 secret 绝不入库,只保存 keystore_ref。」
plugin_runtime_accountscredential_ref TEXT069_plugin_runtime_accounts.sql:1-2:「Secrets remain in the OS keystore; SQLite stores only credential_ref.」
tool_package_configkeystore_ref TEXT / value_json TEXT090_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}`
平台受管 Providernewapi-spark-user-<userId>-api-key唯一非 uuid 形态;PlatformCredentialStore.ref(kind) 生成
平台账号其它字段newapi-spark-user-<userId>-access-tokenkind ∈ {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 的值
工具包 secrettool-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 工具、工具包内建能力都走它)。逻辑是:

  1. 有 ref 就 getSecret(ref)?.trim() || null,否则 null;
  2. 解析 config_json 判断是不是平台受管(managed + managedType:'newapi');
  3. 非受管:直接返回 currentSecret ?? ''——不抛错;
  4. 受管且有恢复器:调恢复器,拿到新值就回写 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 = 3(v2 起含 apiKey), v3 起额外导出 apiEndpointFullUrl、useSparkExecutor 与 modelSchedules 三项,保证导入端不丢字段。

「导出到文件」的 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 accountauth_token / refresh_token / user_id(三条独立条目,不是一条 JSON)
加密备份文件{userData}/cloud-auth-session.enc,safeStorage 加密
keytar 操作超时3_000 ms,超时按「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 才算命中。两者叠加产生两个后果:

  1. 互相覆盖:登录态的 TokenStore 与 MCP OAuth 的 TokenStore 写的是同一个文件,后写的赢;被覆盖的一方下次启动读回来是「service 不匹配 → null」, 静默退回 keytar。
  2. 删除会误伤: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 侧结构性地无法传明文,双重保证:

  1. tool_packages_request_secret 的 inputSchema 里根本没有 value 属性, 且 additionalProperties: false,工具描述写明「密钥由用户在应用内受保护表单填写, 绝不进入 Agent 参数或消息记录」;
  2. 即便走 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。 逐跳证据链是:

  1. 凭据原样保留:sanitizeConnection 里 credentials: isRecord(input.credentials) ? { ...input.credentials } : {},不做任何掩码;
  2. 保存时 credentials: { ...base.credentials, ...(patch.credentials ?? {}) };
  3. writeStore → settingsService.set('remote-connections', 'data', store);
  4. SettingsService 是纯转发到 SettingsRepository.set;
  5. 仓储层 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. Run spark login again.」存储的 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 → 孤儿密钥长期留存
显式删某个 refmacOS 下同时删 vault 条目与旧 Keychain 条目这是唯一会真正清掉旧条目的路径;自动迁移阶段故意保留旧条目以免反复弹授权

12. 备份、导出与同步:密钥会不会跟着走

这是最实际的一组问题——「我把配置分享/备份出去,会带上密钥吗?」

路径会带密钥吗说明
Provider 导出 JSON / 导出到文件会,明文从 keystore 读出并写入普通 JSON;无任何「含密钥」提示。分享配置请改用工作流包
工作流包 .sparkflowMCP 配置不会;技能与 Agent 提示词会MCP 的 headers.*/env.* 与敏感键名被替换为 {{secret:路径}},但技能文件与提示词原样打包、不扫描
团队包不会(同套脱敏)且比工作流包更好的一点:更新已存在 MCP 时会保留接收方已填的密钥(mergePreserveSecrets),不会用占位符覆盖回去
子应用分享包 .sparkappProvider Key 不会;源码/data 里的明文会设计如此:疑似明文密钥只做「位置提示」(secretHints 只含 location,不含值),包内保留原文以保证可用性
Agent / 团队资产导出hookConfig 原样写出没有扫描或清理;hook 动作类型本身只有 builtin.sound / tool.invoke,所以是否藏密钥取决于你写了什么
自定义工具导出不会只导出 secretNames(名字)。注意导出物里含 secretRefs 的规范化引用字符串,那不是密钥值
账号同步不会三重过滤,见下
整个 userData 目录 / 数据库备份会,且包含明文那几处升级前自动备份是整库拷贝,因此 app_settings 里的 IM 凭据、环境变量、MCP config_json 都会进备份目录;keystore 里的密钥不会(它们不在库里)
CLI credentials.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 服务器后钥匙串里还有 tokenmcp: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 里有明文 KeyexportProviders 会从 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 节的导出路径。

速查表

共享 keystore 模块
packages/shared/src/keystore/index.ts(244 行)
keytar service 前缀
spark-agent
macOS 集中 vault 账号名
credential-vault-v1
vault 落盘文件
{userData}/credential-vault-v1.enc(safeStorage 加密,0600)
非 macOS 落点
keytar 后端(Keychain / 凭据管理器 / libsecret)
平台账号 keytar service
SparkAgent.CloudAuth(可用 SPARK_AUTH_KEYTAR_SERVICE 覆盖)
MCP OAuth keytar service
spark-mcp-oauth:<serverId>
登录态加密备份
{userData}/cloud-auth-session.enc
Provider ref 形态
<providerType>-<uuid>
平台受管 Provider ref
newapi-spark-user-<userId>-api-key
连接器账号 ref
plugin-runtime-<pluginId>-<runtimeId>-<base64url>
自定义工具 ref
custom-tool:<工具 id>:<密钥名>
工具包 secret ref
tool-package:<包 id>:<变量名>:<sha256 前 20 位>
平台 GitHub 连接器 ref
github-connector-github-primary
团队注册表 ref
team-registry-nacos-password
快照库主密钥 ref
snapshot-vault-installation-key-v1(32 字节随机,AES-256-GCM)
CLI 凭据文件
~/.spark/credentials.json(明文,0600)
CLI 模型 Key
不存值,只存环境变量名 api_key_env
IM 机器人凭据落点
app_settings(remote-connections/data) 明文
环境变量落点
app_settings(runtime.env) 明文
工作流包密钥占位符
{{secret:<点分路径>}}
工具包 secret DB 约束
is_secret 与 keystore_ref/value_json 互斥 CHECK
日志脱敏覆盖
Bearer / sk- / key=value / 敏感键名(仅顶层,不递归)
Provider 日志掩码
maskSecret 保留前 4 个字符

常见问题

展开 6 条常见问题
  • 我的 API Key 到底存在哪里?

    看你是在哪填的:模型页添加的 Provider、扩展中心的连接器账号、自定义工具的密钥、工具包的 secret 环境变量,都走共享 keystore(在 macOS 上是一个由 safeStorage 加密的文件 credential-vault-v1.enc,其它平台走 keytar)。但设置页里「远程连接」的 Bot Token / App Secret 和会话、项目环境变量是明文存在 SQLite 的 app_settings 表里的,CLI 的登录态则是 ~/.spark/credentials.json 明文。第 1.2 节有一张「你在哪填的 → 落在哪」的对照表。

  • 为什么我在设置里能直接看到机器人的 Bot Token 明文?

    因为那条链路没有走 keystore:remote:list 会把 app_settings 里的 credentials 原样回传给渲染进程,而设置页用的是普通 Input 而不是密码框(整个 SettingsView 里没有 InputPassword)。官方网站连接器页与桌面端架构页原先写的「界面只做掩码显示」与代码不符,本轮已更正。

  • 我把 Provider 配置导出成 JSON 分享给同事,里面会有 Key 吗?

    会有,而且是明文。exportProviders 会从 keystore 读出明文 Key 并附带到导出 profile 里(导出格式从 v2 起就注明「含 apiKey」),导出到文件时也没有任何「包含密钥」的提示。要分享配置请改用工作流包 .sparkflow,它会把 MCP 配置里的密钥替换成 {{secret:路径}} 占位符。注意工作流包只脱敏 MCP 配置,技能文件与 Agent 提示词是原样打包的。

  • 退出登录会删掉我配的 Provider Key 吗?

    不会。全仓只有 PlatformModelService 注册了 logout hook,所以退出登录只清 Spark 账号 token 和平台模型凭据;Provider API Key、平台 GitHub 连接器 PAT、连接器 OAuth 令牌、MCP OAuth 令牌都保留。要清这些得逐个删除对应配置。

  • 我是 CLI 用户,~/.spark/credentials.json 里有明文 token 吗?

    有,access token 与 refresh token 都是明文 JSON,保护只有文件权限 0600(目录 0700)加原子写与权限自修复。spark-engine 里没有任何 keytar / safeStorage 代码,CLI 与桌面端不共用凭据。另外 spark logout 只删本地文件、不做服务端撤销,所以别把 ~/.spark 同步到云盘或打进 CI 缓存。

  • 取消某个 MCP 服务器的授权,会影响我的登录状态吗?

    会删掉登录会话的加密备份文件。TokenStore 的备份文件名是常量 cloud-auth-session.enc、不带 service,而删除时又只按这个固定路径 rm、不校验 service;MCP OAuth 用的正是同一个 TokenStore 类(service 是 spark-mcp-oauth:<serverId>),所以取消授权会连带删掉云端登录的备份。影响通常有限(登录态同时在 keytar 里),但如果 keytar 恰好不可用就会走到「重启即掉登录」。