Skip to content

Fix NyxID Lark relay authentication and keep relay tokens out of persisted state #366

@eanzhao

Description

@eanzhao

症状

线上 Lark -> NyxID -> Aevatar 链路已经能收到 Lark 消息,NyxID 也能成功 forward 到 Aevatar,但 Aevatar 在 relay callback 认证阶段失败,导致 bot 不回复。

典型日志:

warn: Aevatar.NyxId.Chat.Relay[0]
      Relay callback authentication failed: code=invalid_signature, detail=Relay callback signature verification failed.

同时观察到 channel-bot-registration projection scope watermark 提交 OCC:

fail: Aevatar.Foundation.Core.EventSourcing.EventSourcingBehavior[0]
      Event sourcing commit failed. agentId=projection.durable.scope:channel-bot-registration:channel-bot-registration-store eventType=ProjectionScopeWatermarkAdvancedEvent version=4 result=failed errorType=InvalidOperationException
      System.InvalidOperationException: Optimistic concurrency conflict: expected 4, actual 5

warn: Aevatar.CQRS.Projection.Core.Orchestration.ProjectionMaterializationScopeGAgent[0]
      Projection scope observation handling failed. actorId=projection.durable.scope:channel-bot-registration:channel-bot-registration-store projectionKind=channel-bot-registration
      System.InvalidOperationException: Optimistic concurrency conflict: expected 4, actual 5

还出现过 relay user token 过期日志:

Microsoft.IdentityModel.Tokens.SecurityTokenExpiredException: IDX10223: Lifetime validation failed.

当前判断

这不是单纯的 Lark webhook 问题。Lark ingress 和 NyxID forward 已经能工作,失败点在 Aevatar 的 NyxID relay callback 认证与短期 token 生命周期处理。

当前实现存在两个核心问题。

  1. Aevatar 仍在生产 relay 链路里依赖 IAevatarSecretsStore 保存/读取 relay signing secret。

    • provisioning 从 NyxID API key full_key 计算 hash,然后写入 secrets store。
    • callback 认证时再通过 registration -> credential_ref -> secrets store 读出该值,用它校验 X-NyxID-Signature
    • 这违反架构约束:Aevatar 生产环境不应该存储任何 secrets,IAevatarSecretStore / IAevatarSecretsStore 不应参与生产 relay 认证。
  2. 这个约束违规已经在多 pod 部署里变成直接运行时故障。

    • AevatarSecretsStore 是节点本地 store,依赖 macOS Keychain aevatar-agent-framework~/.aevatar/masterkey.bin / ~/.aevatar/secrets.json
    • provisioning 命中 pod A 时,secret 只写到 pod A 本地。
    • callback 命中 pod B 时,pod B 本地 secrets store 没有这条记录,Get 返回空或解析失败。
    • Aevatar 取不到 HMAC key,最终 callback 被拒为 invalid_signature
    • 因此 HMAC fallback 在多 pod 生产环境不是可靠兜底,只能视为单 pod / 本地 / 回滚 only 的安全阀。
  3. NyxID callback payload 里的 reply_token 是短期、单次使用的 reply credential,但 Aevatar 现在会把它放进 ChatActivity.OutboundDelivery.ReplyAccessToken,随后 clone 到 NeedsLlmReplyEvent,并进入 conversation actor 的 event-sourced pending state。

    • 这不再是“短暂存储在内存”。
    • token 过期、重放、actor 恢复后复用都会造成 reply 失败或安全边界不清。

X-NyxID-User-Token 也不适合作为 callback 主认证:bot 收到消息时用户 session 不一定在线,user access token 本来就可能很快过期。它可以继续作为“后续代表用户调用 NyxID API”的 delegation token,但不应决定 inbound callback 是否可信。

修复目标

NyxID relay callback 认证不依赖 Aevatar 持久化 secret;relay reply token 只在一次 turn 的运行态中短暂存在,不进入 event store / actor persisted state / read model。

修复方案

1. 直接切到专用 callback JWT,不再用 HMAC 作为生产主链路

让 NyxID 在 forward callback 时新发一个专用 JWT,例如通过 X-NyxID-Callback-Token 传给 Aevatar。

建议 token 语义:

  • aud = "channel-relay/callback"
  • TTL 约 5 分钟。
  • clock skew 明确为 60 秒,和 NyxID reply token 保持一致或更紧,但不要放大。
  • RS256,由 NyxID 现有 jwt_keys 签发,Aevatar 通过 JWKS 验签。
  • JWT claims 只放认证与绑定所需字段:api_key_idmessage_idplatformbody_sha256expiatjti
  • 不在 JWT 里重复放 correlation_id claim。correlation_id 是 callback payload 的业务字段;NyxID 设置 payload.correlation_id = token.jti,Aevatar 验证 payload.correlation_id == token.jti
  • body_sha256 必须覆盖原始 callback body bytes,避免从 HMAC 切到 JWT 后丢掉 body 防篡改能力。

Aevatar 认证应 fail-fast,按下面顺序校验并产出一一对应的错误码:

顺序 校验 错误码
1 缺少 X-NyxID-Callback-Token callback_jwt_missing
1 kid 在 JWKS 中不存在,且受限 refresh 后仍不存在 callback_jwt_kid_not_found
1 JWT 签名无效 callback_jwt_signature_invalid
1 issuer 与 NyxID discovery 不一致 callback_jwt_issuer_mismatch
1 aud != channel-relay/callback callback_jwt_audience_mismatch
1 exp/iat/nbf 超出 60 秒 clock skew 容忍 callback_jwt_lifetime_invalid
2 api_key_id != payload.agent.api_key_id callback_jwt_api_key_id_mismatch
2 message_id != payload.message_idX-NyxID-Message-Id 不一致 callback_jwt_message_id_mismatch
2 platform != payload.platform callback_jwt_platform_mismatch
2 payload.correlation_id 缺失或不等于 token jti callback_jwt_correlation_id_mismatch
3 SHA256(raw_body_bytes) != body_sha256 callback_jwt_body_hash_mismatch
4 jtimessage_id 已处理过 callback_jwt_replay_detected

JWKS refresh 必须有冷却窗口,建议 10 秒,并且并发 kid miss 要 single-flight 合流,避免伪造 kid 打出 refresh 风暴。

X-NyxID-User-Token 不再作为 callback 主认证。若存在且未过期,可以作为 downstream proxy / approvals 等 user delegation token 使用;若缺失或过期,不应导致 callback 被拒绝,只影响后续需要用户身份的工具调用。

2. body_sha256 必须按原始字节校验

JWT 在 header 里,HTTP body 在 request stream 里。Aevatar 可以先校验 JWT 的签名、aud、issuer、lifetime,再读取 raw body 做 payload 一致性与 body hash 校验;读取 body 时必须保证下游反序列化还能拿到同一份 bytes。

实现要求:

  • 不允许 trim、格式化或 re-serialize JSON 后再算 hash。
  • NyxID 侧必须对实际发送到 wire 的 serialized body bytes 计算 body_sha256
  • Aevatar 侧必须对收到的 raw body bytes 计算 hash。
  • 如果 ASP.NET Core 下游还需要读取 body,要么在入口 pre-read 成 byte[] / MemoryStream 并复用,要么显式 EnableBuffering() 后重置 stream position。
  • 同一个逻辑 JSON,只要字节不同,例如多一个空格、字段顺序不同、末尾多一个 \n,就应该在 JWT body hash 校验阶段被拒绝。

3. relay reply token 放到 actor-owned 短期运行态,不做 singleton cache

不要新增中间层 INyxRelayReplyTokenCache 之类的 singleton messageId -> token 进程内映射,这会撞中间层状态约束,也会在 actor 迁移/换节点时天然 miss。

首选方案:

  • ChatConversationGAgent / ConversationGAgent 内维护非持久化、actor-owned runtime state,生命周期为一次 turn。
  • key 使用 NyxID callback payload 中的 correlation_id,其值必须等于 callback JWT jti
  • 运行态 value 记录 reply_tokenreply_message_id、expiry。
  • turn 完成、发送成功、token consumed、超时、deactivate 时清理。
  • actor 运行态丢失时,LLM reply 返回明确错误 reply_token_missing_or_expired,不降级到持久化 secret 或 API key。

如果确认 actor 在一次 turn 内也可能跨节点迁移,次选方案是使用抽象化分布式短期状态服务(Redis / Orleans transient grain 等),仍然不要在 ChannelRuntime 中维护 singleton map。

4. 收紧 proto/契约,避免 secret 字段继续泄露

  • OutboundDeliveryContext 只保留非 secret 事实,例如 reply_message_idcorrelation_id
  • chat_activity.proto 中现有 reply_access_token = 2 应明确 reserved 2; reserved "reply_access_token";,不要简单删除后回收字段号。
  • 显式新增/使用 correlation_id 字段来定位 actor runtime 中的短期 token,不要依赖 message_id 一字段兼做所有语义。
  • NeedsLlmReplyEvent、conversation actor persisted state、read model 都不得包含 reply_token / access token / HMAC secret。

5. provisioning 路径同步瘦身

  • 从 Lark Nyx provisioning 中移除对 NyxID API key full_key 的读取、hash 和 _secretsStore.Set(...)
  • 删除 BuildRelayCredentialRef、relay CredentialRef 写入、NyxIdRelayRegistrationCredentialResolver 等只服务 HMAC secret 查找的生产代码。
  • ChannelBotRegisterCommand / ChannelBotRegistrationEntry / ChannelBotRegistrationDocument 里的 credential_ref 如果只剩历史 HMAC 用途,应删除并 reserve proto field;不要保留空壳兼容。
  • ServiceCollectionExtensions 中 ChannelRuntime 对 IAevatarSecretsStore 的默认注册应移走。

IAevatarSecretsStore 允许使用范围应收窄为白名单:

  • 允许:tools/Aevatar.Tools.Cli 用户本地 token / 本地配置。
  • 允许:本地 dev / 本地集成测试显式 opt-in。
  • 禁止:任何服务端 Host / ChannelRuntime / agent service 生产 DI 默认注册。

需要新增 CI 守卫:src/Aevatar.*Host*agents/**/ServiceCollectionExtensions.cs 禁止出现 AddSingleton<IAevatarSecretsStore> / TryAddSingleton<IAevatarSecretsStore>,防止后续重新漏回生产链路。

6. 过渡策略

生产里已有带 credential_ref 的旧 ChannelBotRegistration。切换时需要避免“一上线就全量失败”。

feature flag 粒度应是 per-registration:

  • 新注册 bot 一律 JWT-only,不再写 credential_ref,也不再写本地 secret。
  • 历史注册按 registration 级 flag 决定是否允许临时 HMAC fallback。
  • 可以保留一个全局 emergency kill switch,但不能用全局 flag 代替 per-registration 灰度。

建议顺序:

  1. NyxID 先上线 callback JWT,并在 forward 时同时携带旧 X-NyxID-Signature 与新 X-NyxID-Callback-Token
  2. Aevatar 先上线 JWT 验证路径,并通过 per-registration feature flag 控制历史注册的 HMAC fallback。
  3. 灰度期间优先 JWT;JWT 缺失时短期允许 HMAC fallback,但它只适用于单 pod / 本地 / 回滚场景,不是多 pod 生产的可靠兜底。
  4. 确认线上 callback 都带 callback JWT 后,关闭 HMAC fallback。
  5. 最后删除 Aevatar HMAC secret 存储、credential_ref、resolver 和相关 DI。

7. inbound replay、日志与指标

  • inbound callback 幂等由 Aevatar 负责,至少按 message_id / callback JWT jti 去重,重复 callback 不应双写 conversation event,也不应触发两次 LLM reply。
  • replay guard 状态应放在 actor runtime 或分布式短期状态服务,不要新增中间层 singleton map。
  • 日志错误码必须和第 1 节的验证规则一一对应,避免继续全部压成 invalid_signature
  • 建议新增指标:callback_jwt_validation_failures_total{reason=<error_code>},灰度阶段比只看日志更容易判断失败分布。
  • 继续保留 reply_token_missing_or_expired 作为 outbound reply 阶段的明确错误,不要混到 callback JWT 认证错误里。

8. Projection OCC 验证

PR #365 / commit da69364c 已引入 EventStoreOptimisticConcurrencyException 并让 projection scope 对 OCC rethrow,以便 runtime retry。需要在最新主干上验证实际端到端语义,而不是只看日志。

验收测试要具体构造:

  • 构造并发两条 observation 指向同一个 channel-bot-registration projection scope。
  • 预期一条先提交成功,另一条遇到 EventStoreOptimisticConcurrencyException
  • runtime 自动 reload + retry 后,两条 observation 都最终物化到 read model。
  • committed version 连续递增,无 gap。
  • 不出现同一个旧 _currentVersion / 未清 pending event 上无限重试。

相关 PR / 外部协同

回归测试

需要新增/调整:

  • callback JWT happy path:无 IAevatarSecretsStore 注册时,Aevatar 仍可通过 JWKS 验证 callback 并返回 202。
  • callback JWT kid miss 后 refresh JWKS,再验证成功/失败分支。
  • JWKS refresh 有冷却窗口与 single-flight,并发 unknown kid 不会打爆 discovery/JWKS。
  • callback JWT aud 不匹配拒绝,且不继续做 body hash 等后续工作。
  • callback JWT lifetime 边界:60 秒 clock skew 内按约定通过,超过后拒绝。
  • callback JWT api_key_id/message_id/platform 与 payload 不一致拒绝。
  • payload correlation_id 缺失或不等于 token jti 时拒绝。
  • body_sha256 不匹配拒绝。
  • 收到相同逻辑 payload 但 raw body bytes 不完全一致,例如多余空格或末尾 \n,JWT 被拒。
  • callback JWT expired / not-yet-valid 拒绝。
  • 重复 callback 不双写 conversation event,不触发重复 LLM reply。
  • per-registration feature flag:新注册 JWT-only,历史注册才允许短期 fallback。
  • reply_token 不出现在 event store payload / actor persisted state / read model 中。
  • actor runtime token 命中时可正常调用 /api/v1/channel-relay/reply
  • actor runtime token missing/expired 时返回 reply_token_missing_or_expired,不尝试使用持久化 secret 或 API key。
  • callback_jwt_validation_failures_total{reason=<error_code>} 按错误原因递增。
  • channel-bot-registration projection OCC 并发 observation 测试通过,最终 read model 物化到最新 committed version。
  • CI 守卫禁止服务端 Host / agents production DI 默认注册 IAevatarSecretsStore

验收标准

  • Aevatar 生产 relay 链路不再通过 IAevatarSecretsStore 保存或读取 NyxID relay signing secret。
  • Aevatar callback 主认证使用 NyxID 专用 callback JWT + JWKS,不依赖 HMAC secret。
  • callback JWT 使用 5 分钟左右 TTL 和明确的 60 秒 clock skew 容忍。
  • callback JWT 绑定 byte-exact body hash,body bytes 被篡改时拒绝。
  • JWT 中不重复放 correlation_id claim;payload correlation_id 明确等于 token jti
  • callback JWT 验证顺序、错误码、日志与指标原因值一一对应。
  • JWKS kid miss refresh 有冷却窗口和并发合流。
  • reply_access_token proto 字段被 reserved,reply_token 不进入任何持久化 state / event / read model / proto transport contract。
  • feature flag 是 per-registration;新注册 JWT-only,历史注册才允许短期 fallback。
  • Lark -> NyxID -> Aevatar callback 在无本地 secret 的生产配置下认证通过并返回 202。
  • Aevatar 能使用 actor-owned 短期 runtime token 调用 NyxID /api/v1/channel-relay/reply 完成回复。
  • token 过期、cache miss、JWT kid/aud/body hash/message replay 等错误日志可区分。
  • channel-bot-registration projection OCC 不会导致 read model 长期停在旧版本。

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions