背景
本 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 提出的修复(立)
两条收紧:
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
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.proto 中 reply_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 抽象:
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(结构性)
把 IAevatarSecretsStore 从 src/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|HmacKey 的 string / 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)。
开放问题
INyxIdCapabilityBroker 的具体契约 —— 走 OAuth Token Exchange (RFC 8693)、MCP (Anthropic)、A2A (Google)、GNAP (RFC 9635) 哪条路?待与 NyxID 侧讨论后定
用户在 NyxID 配置的 service 如何让 Aevatar 的 agent 发现 —— discovery endpoint 的契约
per-call consent vs pre-approved scope 的 policy 模型 —— NyxID 侧决定,Aevatar 只消费结果
CI 三层防线落地优先级 —— 建议 Layer 1 与 Fix NyxID Lark relay authentication and keep relay tokens out of persisted state #366 最后一步(删除 HMAC fallback)合并推进,因为都要动 _secretsStore 相关代码
不 block 的事项
相关
背景
本 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)invalid_signature阶段 2:reply_token 随 event 流转(破)
NyxID callback payload 带
reply_token(短期、单次使用的 reply credential),Aevatar 把它塞进ChatActivity.OutboundDelivery.ReplyAccessToken,clone 到NeedsLlmReplyEvent,进入 conversation actor 的 event-sourced pending state。问题:
阶段 3:#366 提出的修复(立)
两条收紧:
Callback 认证切到专用 JWT + JWKS(NyxID 现有
jwt_keys复用)aud = channel-relay/callback,TTL 5min,60s clock skewbody_sha256覆盖原始 bytes,防篡改X-NyxID-User-Token不再作为 callback 主认证reply_token 放 actor-owned runtime state
correlation_id(等于 callback JWTjti)reply_token_missing_or_expired配套:
BuildRelayCredentialRef、relayCredentialRef写入、NyxIdRelayRegistrationCredentialResolverchat_activity.proto中reply_access_token = 2显式 reserved_secretsStore.Set(...)从 provisioning 路径移除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):ChannelBotRegistrationGAgent → Projector → Document链)envelope encryption 也不够
加密只是权限分层,不是结构化保证:
正确的不变量
这条是架构不变量,不是最佳实践。
Aevatar / NyxID 的职责边界
根据产品定位(用户绑自己的 agent/channel,平台不运营 channel;multi-tenant = NyxID 层事)和 NyxID 产品侧的 scope 声明(NyxID 不做 general vault,只做 credential 相关):
IAevatarSecretsStore(文件 + Keychain)tools/+demos/+ LocalNet hostNyxID 已经做了什么(不需要重新发明)
这些是向 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 管理AI Services discovery— 下游客户端(Aexon 等)已在消费Aevatar 侧需要的抽象(未来 PR,非本 issue scope)
关键约束:
CapabilityHandle返回值不得跨 event sourcing 边界 — arch test 强制credentialRef字符串,不存CapabilityHandleIssueShortLivedAsync,用完即弃架构约束 / CI 三层防线
独立 PR 推进,与 #366 解耦:
Layer 1 — Assembly boundary(结构性)
IAevatarSecretsStore从src/Aevatar.Configuration迁到src/Aevatar.LocalNet.Secretssrc/*ProjectReference 审计后按白名单收紧:tools/Aevatar.Tools.*,demos/Aevatar.Demos.*, LocalNet hostAevatar.Foundation.*,Aevatar.Bootstrap.*,Aevatar.*Host*,agents/**Layer 2 — Roslyn BannedApiAnalyzers
BannedSymbols.txtper-directory 启用T:Aevatar.LocalNet.Secrets.IAevatarSecretsStore在 host / agents / Foundation 目录下 bannedLayer 3 — Architecture test(NetArchTest)
IGAgent<TState>/IEventSourcedState/IProjectionDocument实现里,字段树不得出现名含Secret|Token|Credential|Password|ApiKey|PrivateKey|HmacKey的string/byte[]字段(白名单:*Ref,*Id)INyxIdCapabilityBroker返回类型不得作为这些类型的字段Aevatar.LocalNet.Secrets不得被 grain / projector / readmodel 依赖附加:proto 字段级
buf lintcustom rule,channel/workflow/chat proto 字段名禁止含_secret|_token|_password|_api_key(除非reserved)。开放问题
INyxIdCapabilityBroker的具体契约 —— 走 OAuth Token Exchange (RFC 8693)、MCP (Anthropic)、A2A (Google)、GNAP (RFC 9635) 哪条路?待与 NyxID 侧讨论后定_secretsStore相关代码不 block 的事项
相关