Skip to content

[RFC] Aevatar 线上零 secret material — 从 Day One 演化到 capability-broker 边界 #375

@eanzhao

Description

@eanzhao

背景

本 issue 记录 2026-04 Day One ship 过程中,Aevatar ↔ NyxID 在 secret / credential 处理上的思路演化,以及我(eanzhao)对双方边界的判断。目的是把决策上下文沉淀下来,后续 CI 守卫 / 类型迁移 / IAevatarSecretsStore 定位的 PR 都基于这个 RFC 对齐。

NyxID 侧对应 issue:ChronoAIProject/NyxID#505

演化路径(按时间顺序)

阶段 1:Aevatar 存 HMAC signing secret(破)

ChannelRuntime provisioning 从 NyxID API key full_key 计算 hash,通过 _secretsStore.Set(...) 写入 IAevatarSecretsStore。callback 认证时再从 registration → credential_ref → secrets store 读出来校验 X-NyxID-Signature

失败模式(多 pod 生产直接坏):

  • AevatarSecretsStore 是节点本地实现(macOS Keychain 或 ~/.aevatar/masterkey.bin + secrets.json
  • provisioning 命中 pod A,secret 只写到 pod A
  • callback 命中 pod B,读不到 → invalid_signature
  • 除了运行时故障,架构上也违反约束:Aevatar 生产环境不应持有 secret material

阶段 2:reply_token 随 event 流转(破)

NyxID callback payload 带 reply_token(短期、单次使用的 reply credential),Aevatar 把它塞进 ChatActivity.OutboundDelivery.ReplyAccessToken,clone 到 NeedsLlmReplyEvent,进入 conversation actor 的 event-sourced pending state。

问题:

  • 从"短暂内存态"变成"持久化 secret"
  • token 过期、重放、actor 恢复后复用都会造成 reply 失败或安全边界不清
  • 跨 event store → read model → actor state 三层持久化都会看到这个 token

阶段 3:#366 提出的修复(立)

两条收紧:

  1. Callback 认证切到专用 JWT + JWKS(NyxID 现有 jwt_keys 复用)

    • 非对称签名:NyxID 签发,Aevatar 通过 JWKS 验签
    • aud = channel-relay/callback,TTL 5min,60s clock skew
    • body_sha256 覆盖原始 bytes,防篡改
    • X-NyxID-User-Token 不再作为 callback 主认证
    • 结果:Aevatar ↔ NyxID 之间不再需要任何 shared secret
  2. reply_token 放 actor-owned runtime state

    • 不持久化,不进 event store
    • 生命周期 = 一次 turn
    • key = correlation_id(等于 callback JWT jti
    • actor runtime 丢失时 fail-fast 返回 reply_token_missing_or_expired

配套:

  • 删除 BuildRelayCredentialRef、relay CredentialRef 写入、NyxIdRelayRegistrationCredentialResolver
  • chat_activity.protoreply_access_token = 2 显式 reserved
  • _secretsStore.Set(...) 从 provisioning 路径移除
  • CI 守卫:src/Aevatar.*Host* + agents/**/ServiceCollectionExtensions.cs 禁止 AddSingleton<IAevatarSecretsStore> / TryAddSingleton<IAevatarSecretsStore>

阶段 4:本 issue 要讨论的问题 —— 除了 callback signing,其它 secret 怎么办

#366 解决了"callback signing secret"这一类,但没有回答更基础的架构问题:Aevatar 线上还有任何 secret 需要持有吗?

我的判断:不应该,而且可以做到零持有。理由如下。

核心判断:Aevatar 线上不应存任何 secret material

"只存 grain state 是安全的"不成立

Aevatar 是 event-sourced 架构(src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs):

  • grain state 由 event stream 重放得来
  • 每次写入都是一条永久 event,落到 Chrono-Storage
  • projection 是事件流水线的天然出口,不是可选项(参见现有 ChannelBotRegistrationGAgent → Projector → Document 链)
  • "只放 state 不 project" 需要在每个字段上手动标注"此字段不投影" → 反 event sourcing 哲学

envelope encryption 也不够

加密只是权限分层,不是结构化保证:

  • 元数据仍泄露(哪个用户绑了哪些 service、更新节奏、使用频率)
  • KMS 权限层 = DB 权限层的人,保护只退半步
  • 不是"secret 不存在"这个 structural guarantee

正确的不变量

Grain state / event / projection document / read model / proto transport 永远只含 credential_ref,不含 secret material(明文或密文)。

这条是架构不变量,不是最佳实践。

Aevatar / NyxID 的职责边界

根据产品定位(用户绑自己的 agent/channel,平台不运营 channel;multi-tenant = NyxID 层事)和 NyxID 产品侧的 scope 声明(NyxID 不做 general vault,只做 credential 相关):

类别 Aevatar 线上 NyxID
Agent runtime / 对话状态 / tool orchestration ✅ 这里 -
业务 event sourcing / projection ✅ 这里 -
User identity(邀请码、session、auth) - ✅ 这里
User 配置的 service credential(LLM / 未来 OAuth apps) ❌ 不持有 ✅ 这里
NyxID ↔ Aevatar callback 签名 ❌ 不需要(用 JWT + JWKS) ✅ 签发
turn-scoped 短期 token(reply_token, OAuth access) ✅ actor runtime,不持久化 -
Local dev 的 IAevatarSecretsStore(文件 + Keychain) ✅ 限定 tools/ + demos/ + LocalNet host -

NyxID 已经做了什么(不需要重新发明)

这些是向 NyxID pitch 扩大 scope 时的锚点 —— 本 issue 对应的 NyxID 侧 issue 不是要他们从零做 vault,而是把下列已有能力连成一个 capability broker 抽象:

  • jwt_keys 签发 + JWKS 发布(Fix NyxID Lark relay authentication and keep relay tokens out of persisted state #366 callback JWT 已复用)
  • /api/v1/keys — 用户 API key 管理
  • LLM proxy — 实质上是 "代用户调用 upstream service,NyxID 注入 credential",这已经是 capability broker 的特化形式
  • AI Services discovery — 下游客户端(Aexon 等)已在消费
  • user services 概念(registration 层已有)

Aevatar 侧需要的抽象(未来 PR,非本 issue scope)

public interface INyxIdCapabilityBroker
{
    Task<IReadOnlyList<CapabilityDescriptor>> ListAsync(UserRef user, AgentRef agent, CancellationToken ct);
    Task<CapabilityHandle> IssueShortLivedAsync(string credentialRef, IReadOnlyList<string> scopes, TimeSpan ttl, CancellationToken ct);
}

关键约束:

  • CapabilityHandle 返回值不得跨 event sourcing 边界 — arch test 强制
  • grain state 只存 credentialRef 字符串,不存 CapabilityHandle
  • runtime 调用时才 IssueShortLivedAsync,用完即弃

架构约束 / CI 三层防线

独立 PR 推进,与 #366 解耦:

Layer 1 — Assembly boundary(结构性)

  • IAevatarSecretsStoresrc/Aevatar.Configuration 迁到 src/Aevatar.LocalNet.Secrets
  • 当前 6 个 src/* ProjectReference 审计后按白名单收紧:
    • 允许:tools/Aevatar.Tools.*, demos/Aevatar.Demos.*, LocalNet host
    • 禁止:Aevatar.Foundation.*, Aevatar.Bootstrap.*, Aevatar.*Host*, agents/**
  • 结果:线上 csproj 编译期就访问不到类型名

Layer 2 — Roslyn BannedApiAnalyzers

  • BannedSymbols.txt per-directory 启用
  • T:Aevatar.LocalNet.Secrets.IAevatarSecretsStore 在 host / agents / Foundation 目录下 banned

Layer 3 — Architecture test(NetArchTest)

  • 任何 IGAgent<TState> / IEventSourcedState / IProjectionDocument 实现里,字段树不得出现名含 Secret|Token|Credential|Password|ApiKey|PrivateKey|HmacKeystring / byte[] 字段(白名单:*Ref, *Id
  • 未来加:INyxIdCapabilityBroker 返回类型不得作为这些类型的字段
  • Aevatar.LocalNet.Secrets 不得被 grain / projector / readmodel 依赖

附加:proto 字段级 buf lint custom rule,channel/workflow/chat proto 字段名禁止含 _secret|_token|_password|_api_key(除非 reserved)。

开放问题

  1. INyxIdCapabilityBroker 的具体契约 —— 走 OAuth Token Exchange (RFC 8693)、MCP (Anthropic)、A2A (Google)、GNAP (RFC 9635) 哪条路?待与 NyxID 侧讨论后定
  2. 用户在 NyxID 配置的 service 如何让 Aevatar 的 agent 发现 —— discovery endpoint 的契约
  3. per-call consent vs pre-approved scope 的 policy 模型 —— NyxID 侧决定,Aevatar 只消费结果
  4. CI 三层防线落地优先级 —— 建议 Layer 1 与 Fix NyxID Lark relay authentication and keep relay tokens out of persisted state #366 最后一步(删除 HMAC fallback)合并推进,因为都要动 _secretsStore 相关代码

不 block 的事项

相关

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions