Skip to content

feat(channel): Phase 2 — Lark webhook security + durable dedup#177

Merged
eanzhao merged 6 commits intodevfrom
feat/2026-04-13_channel-security-phase2
Apr 14, 2026
Merged

feat(channel): Phase 2 — Lark webhook security + durable dedup#177
eanzhao merged 6 commits intodevfrom
feat/2026-04-13_channel-security-phase2

Conversation

@eanzhao
Copy link
Copy Markdown
Contributor

@eanzhao eanzhao commented Apr 13, 2026

Summary

Channel Runtime Phase 2: 生产级安全加固。

  • Lark 签名验证: X-Lark-Signature header,SHA256(timestamp + nonce + encrypt_key + body) 常量时间比较
  • 加密事件解密: AES-256-CBC 解密 Lark 加密 payload(encrypt_key → SHA256 → AES key,IV = ciphertext 前 16 字节)
  • 持久化去重: processed_message_ids 持久化到 actor state(event sourcing),跨 actor 重启存活
  • encrypt_key 全链路: proto → GAgent → 注册端点 → 投影 → NyxId chat tool

安全模型

场景 行为
配置 encrypt_key + 签名匹配 正常处理
配置 encrypt_key + 签名不匹配 静默拒绝(返回 null → 200 OK,不泄露信息)
配置 encrypt_key + 加密 payload 先解密再解析
encrypt_key 回退到 token 验证(向后兼容 Phase 1)

架构决策

  • 签名 + 加密共用 encrypt_key: 这是 Lark 的协议设计,一个密钥同时用于签名验证和事件解密
  • 签名验证使用原始 body: 加密场景下,签名基于加密前的原始 HTTP body 计算
  • 签名输入来自 HTTP headers: timestamp/nonce 从 X-Lark-Request-Timestamp / X-Lark-Request-Nonce 请求头读取,而非 JSON body 字段
  • 持久化去重在 dispatch 成功后: ChannelInboundDispatchedEvent 在 chat dispatch 成功后才持久化 messageId,避免 crash 导致 session 被 dedup 卡住
  • endpoint 层 IMemoryCache 保留: 作为第一层快速防线,actor 层持久化去重作为跨重启保障

改动文件

文件 改动
channel_runtime_messages.proto encrypt_key 加入 Entry/Command/Document;processed_message_ids 加入 ChannelUserState;新增 ChannelInboundDispatchedEvent
LarkPlatformAdapter.cs 签名验证(从 HTTP headers 读取 timestamp/nonce) + AES 解密 + 加密 URL 验证
ChannelBotRegistrationGAgent.cs 持久化 encrypt_key
ChannelCallbackEndpoints.cs 注册接受 encrypt_key 参数
ChannelBotRegistrationProjector.cs 投影 encrypt_key
ChannelRegistrationTool.cs tool 接受 encrypt_key 参数
ChannelUserGAgent.cs 激活时恢复持久化 dedup set;dispatch 成功后通过 ChannelInboundDispatchedEvent 持久化 messageId
LarkPlatformAdapterTests.cs 签名验证/拒绝/跳过、加密解密 round-trip、加密 URL 验证、加密事件解析、HTTP header 签名输入
ChannelBotRegistrationStoreTests.cs encrypt_key 持久化测试

关联 Issues

Issue 关系
Closes #125 Lark adapter 安全增强(签名验证 + 事件加密 + 去重)
Ref #124 per-message 身份解析Cancelled(NyxID blocker 被 CEO 否决)
Ref #172 Phase 1 PR(已合并)

Phase 2 剩余(deferred)

  • 身份映射 bind flow(OAuth callback → ChannelUserBoundEventCancelled(NyxID blocker 被 CEO 否决)
  • Registration 清理(orphan projection documents)— 需 IProjectionWriteDispatcher.DeleteAsync
  • Telegram 签名验证 — 无生产 Telegram bot,暂不需要

Test plan

  • 构建 0 错误
  • 156 ChannelRuntime 测试全部通过(含安全测试 + dedup 测试 + Codex P1 修复测试)
  • 部署后:配置 Lark app encrypt_key → 签名验证生效 → 加密事件正确解密

🤖 Generated with Claude Code

eanzhao and others added 2 commits April 13, 2026 13:18
Add cryptographic signature verification (SHA256), encrypted event
decryption (AES-256-CBC), and durable actor-state backed dedup for
the Lark channel runtime.

Closes #125

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7a8fb363a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

VerificationToken = entry.VerificationToken ?? string.Empty,
WebhookUrl = entry.WebhookUrl ?? string.Empty,
NyxUserToken = entry.NyxUserToken ?? string.Empty,
EncryptKey = entry.EncryptKey ?? string.Empty,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Copy encrypt key into query-side registration entries

This adds EncryptKey to the projected document, but the callback path resolves registrations through IChannelBotRegistrationQueryPort.GetAsync, whose ToEntry mapper still omits document.EncryptKey. In production that leaves registration.EncryptKey empty, so encrypted Lark callbacks cannot be decrypted and signature verification is never activated even when users provide an encrypt key.

Useful? React with 👍 / 👎.

Comment on lines +146 to +147
var timestamp = header.TryGetProperty("create_time", out var ts) ? ts.GetString() ?? "" : "";
var nonce = header.TryGetProperty("nonce", out var n) ? n.GetString() ?? "" : "";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive Lark signature inputs from request headers

ParseInboundAsync builds the signature from header.create_time and header.nonce, but Lark webhook signatures are computed from request timestamp/nonce headers (X-Lark-Request-Timestamp and X-Lark-Request-Nonce) plus raw body. Using payload fields here can produce a different digest for valid signed callbacks, causing legitimate events to be dropped whenever encrypt_key is configured.

Useful? React with 👍 / 👎.

Comment on lines +151 to +153
if (http.Request.Headers.TryGetValue("X-Lark-Signature", out var sigHeader) &&
!string.IsNullOrWhiteSpace(sigHeader))
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject callbacks missing X-Lark-Signature

Signature verification only runs when X-Lark-Signature is present, so a caller can omit the header and bypass verification entirely. In the same code path, token verification is disabled when EncryptKey is set, so unsigned callbacks can still be accepted and processed under encrypt-key registrations.

Useful? React with 👍 / 👎.

Comment on lines +610 to +613
if (!string.IsNullOrEmpty(evt.Session.MessageId) &&
!next.ProcessedMessageIds.Contains(evt.Session.MessageId))
{
next.ProcessedMessageIds.Add(evt.Session.MessageId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist dedup IDs only after dispatch succeeds

This persists Session.MessageId during ApplyRequested, which happens before chat dispatch is attempted. Because HandleInbound checks dedup IDs before pending-session recovery, a crash/deactivation between request persistence and successful dispatch can restore that ID as already processed and drop retried webhooks, leaving pending sessions stranded and user messages unprocessed.

Useful? React with 👍 / 👎.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 13, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.60%. Comparing base (080b6b4) to head (1ccbf90).
⚠️ Report is 7 commits behind head on dev.

@@            Coverage Diff             @@
##              dev     #177      +/-   ##
==========================================
- Coverage   81.62%   81.60%   -0.02%     
==========================================
  Files         741      741              
  Lines       47055    47055              
  Branches     6230     6230              
==========================================
- Hits        38407    38401       -6     
- Misses       5956     5960       +4     
- Partials     2692     2694       +2     
Flag Coverage Δ
ci 81.60% <ø> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

eanzhao and others added 4 commits April 13, 2026 13:34
…ryPort + reject missing signature

- ToEntry() now copies EncryptKey from document to entry (was dead code in prod)
- Signature verification rejects when X-Lark-Signature header is missing
  but encrypt_key is configured (was an authentication bypass)
- Add test for missing-signature rejection
- Fix encrypted event test to include required signature header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…TP headers + dedup after dispatch

- Lark signature verification now reads timestamp/nonce from X-Lark-Request-Timestamp
  and X-Lark-Request-Nonce HTTP headers instead of JSON body fields (header.create_time/nonce)
- Durable dedup ID persistence moved from ApplyRequested to ApplyInboundDispatched,
  emitted only after successful dispatch — prevents stranding sessions on crash between
  persist and dispatch
- Added ChannelInboundDispatchedEvent proto message for post-dispatch dedup tracking
- 156 channel runtime tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@eanzhao eanzhao merged commit 81e8854 into dev Apr 14, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Aevatar: Lark adapter 安全增强 (签名验证 + 事件加密 + 去重)

1 participant