English

Digital Worker Phase 1.5 — Self-Service Identity Verification Design

A Phase 1.5 self-service identity verification flow: a verified Gremlin user binds their own external chat identity by DMing a short-lived, single-use code to the worker on Telegram or WhatsApp, and the inbound webhook supplies the authoritative external_user_id so no one copies an opaque platform ID. It replaces admin-by-hand ID sourcing while producing the same verified dw_external_identities row, leaving the Phase 1 security gate unchanged.

View source markdown ↗ generated by claude-opus-4-8 · diagrams mermaid

概述

自助式身份验证颠倒了"谁来干活":不再由管理员手动获取不透明的平台 ID,而是由用户通过向 worker 私信一个短时效、一次性的验证码来证明所有权,入站 webhook 则提供权威的 external_user_id。 其产出仍是 Phase 1 早已信任的同一行已验证 dw_external_identities —— 只是入册方式变了, 安全闸门并未改变。

  • 阶段 1.5 —— 将 Phase 1 中被推迟的 DM 验证码挑战正式落地
  • 提供方(v1) Telegram(黄金路径)+ WhatsApp;Discord 仍走管理员断言
  • 新增面 一张表 + 一个解析分支;不新增任何传输通道
  • 新增 ADR 0035 · 0036

范围与目标

纳入范围

v1
  • 仅 Telegram + WhatsApp —— 二者都经由现有 webhook 路径入站,因此分支只需放在一处。
  • dw_verification_codes 表及其生命周期(签发 → pending → consumed / revoked)。
  • 按提供方的发起方式:面板验证码、点击即聊深链、二维码。
  • 身份解析之前的 webhook 解析(发送者尚未被映射)。
  • 仅验证码的批量预置 + 实时"M 中已验证 N 人"。
  • 自解释式首次接触:一条安全、限流的回复。

不在范围

推迟
  • Discord 自助 —— 仅 Gateway 入站(ADR-0023),不走 webhook 路径;需要另建 Gateway/斜杠命令分支以及公会/私信可达性。仍走管理员断言。
  • 改动已验证身份闸门本身 —— 维持不变。
  • 主动(未经邀约的)验证提示 —— 属于有后果操作,需经闸门。
  • 彻底取代管理员断言 —— 它仍是有效的回退手段。

成功标准

  • 此前没有任何映射的用户,一次点击即可自助验证,无需复制 ID。
  • 管理员把邀请链接/二维码分发给一份名册,并实时观察状态推进("12 人中已验证 7 人")。
  • 每一次验证码签发、消费、过期与失败都可审计。
  • 未验证的发送者会被告知如何验证,且不会泄露是否已知任何身份。
  • 没有任何路径能在缺少一个存活、未消费、绑定正确的验证码的情况下产出 verified 记录。

关键决策

自助 DM 验证码挑战取代 ID 获取(ADR-0035)

用户向 worker 发送一个令牌;webhook 信封携带权威的 external_user_id。任何人都不必读取或复制不透明的平台 ID。

管理员断言叠加了三件难事 —— 获取 ID、把它对应到用户、且没有反馈回路。颠倒之后,最容易做错的那一环被移除:平台主动给我们 ID,而完成挑战本身就是成功信号。它完整复用了 Phase 1 的入站路径。

已否决:以管理员断言为唯一路径(即当前痛点);OAuth 式平台登录(没有可用的按用户 OAuth 能返回 bot 作用域的 external_user_id);信任显示名(被 Phase 1 安全模型禁止)。

验证码是唯一的新秘密;解析发生在身份解析之前(ADR-0036)

验证在身份解析之前的一个独立分支中匹配,因为此刻发送者还是 unknown_user。匹配到的验证码被原子地消费,并在同一事务中写入已验证记录。

若把分支放在身份解析之后,每次尝试都会被默认拒绝。验证码必须一次性,并在写入身份的同一事务中被消费,否则两条并发私信可能造成重复绑定。

已否决:先解析身份再对失败做特例处理(产生嘈杂的拒绝);长期可复用验证码(重放/共享);从用户数据派生验证码(可被猜测)。

提供方矩阵 —— 发起方式的差异

一旦消息落地,解析过程完全一致;只有把用户引导进私信的方式、以及可预填的内容多少有所不同。

提供方深链验证码可见?说明
Telegram (黄金路径) t.me/<bot>?start=<payload> —— 不透明 payload 体验最干净:?start= 携带一个不透明的一次性令牌,以 /start <payload> 形式投递。用户从不看到验证码。
WhatsApp wa.me/<E164>?text=<prefilled> 是(预填,无需键入) 正文以带命名空间的哨兵串预填(如 GV-<code>);用户只需点发送。入站走同一 webhook + HMAC 路径。
Discord (推迟) 斜杠命令 / 私信 bot 无法真正预填。仅 Gateway 入站(ADR-0023)+ 公会成员要求 → v1 默认走管理员断言。

数据模型

dw_verification_codes

每签发一次挑战即一行,把一个不透明的高熵令牌精确绑定到一个 Gremlin 用户、一个组织,以及(可选的)一个目标提供方。

类型说明
iduuid PK
org_idtext租户作用域
code_lookuptext唯一由秘密派生的列:HMAC-SHA256(token, server_pepper)。以此匹配。原始令牌从不存储或记录;pepper 是服务端配置秘密(与 AES-GCM 密钥同一套做法),因此仅数据库被攻破也既无法逆推也无法暴力破解。
gremlin_user_idtext消费时此码所验证的用户
role_agent_iduuid NULL仅作溯源/过滤 —— 供向导的按 role-agent 计数器使用。限定所得身份的作用域(身份是组织级的)。
target_providertext NULLCHECK IN (whatsapp,telegram),或 null = 任意
statustextCHECK IN (pending,consumed,revoked)。不存储 expired —— 过期由时间推导,因此无需扫描器/定时任务。
expires_attimestamptz短 TTL,取自模块级 DigitalWorkerConfig 的默认 15 分钟;按组织 TTL 推迟。
consumed_at / consumed_external_user_id / consumed_provider可空成功时原子写入;记录平台投递的 ID 以备审计
attempt_countint仅作按码重放信号 —— 随机猜错匹配不到任何行,故它不是防暴力破解的控制项。
issued_by / created_attext / timestamptz签发的管理员/用户;若为用户自助请求则记 self

索引:UNIQUE (code_lookup)(org_id, gremlin_user_id, status);为解析器热路径建立的部分索引 WHERE status = 'pending'

原子消费

成功即一条带守卫的 UPDATE,把 pending → consumed,并在同一事务中插入身份行。更新到零行即失败尝试。

UPDATE dw_verification_codes SET status='consumed', consumed_at=now(),
       consumed_external_user_id=$uid, consumed_provider=$provider
WHERE code_lookup=$1 AND org_id=$worker_org AND status='pending'
      AND expires_at>now() AND (target_provider IS NULL OR target_provider=$provider)
RETURNING gremlin_user_id, role_agent_id;

dw_external_identities —— 专用插入分支

当一次成功消费返回 U 后,结合信封中的 (org, provider, Y),该分支先解析已有映射:

已有 (org,provider,Y)动作
不存在INSERT 已验证 U↔Y —— 唯一会写入行的路径
映射到 U幂等成功;退役该码;不做改动
映射到 V ≠ U接管守卫:不做改动,标记给管理员,退役该码,返回通用失败回复

U 在该提供方上已持有一个不同的已验证 external id,按标记给管理员 / 不自动新增处理(可能是账号变更)。结构性兜底:ON CONFLICT DO NOTHING

预置 —— 仅验证码

签发授权

验证码是一种持有者令牌,消费时会写入 (consumed_external_user_id → gremlin_user_id) —— 因此谁在签发时控制 gremlin_user_id,谁就控制了哪个聊天账号能成为哪个 Gremlin 用户。

自助签发(issued_by='self'

任意已认证的组织成员,但服务端强制把 gremlin_user_id 设为调用者本人的用户 id,并拒绝任何指定他人的尝试。你只能绑定自己的身份。

为他人签发(名册 / 批量)

需要组织 owner/admin 或现有的 CanManageOrg 式作用域。gremlin_user_id 为所选目标成员;issued_by 是签发的管理员,由其担保链接送达正确的人。

"管理员为 V 签发,链接却泄露给攻击者"这一残余暴露,恰好就是我们接受的 TTL + 一次性持有者链接风险(与点击验证邮件同一信任模型),这也是为什么邀请通过面板/邮件发出,而绝不以 bot 主动发起的消息发出。

解析逻辑 —— 从 webhook 到已验证记录

EventProcessor.Process 中,该分支恰好插在 !res.NeedsAIPath 早返回之后、policy.Evaluate 之前。这一摆位免费换来三项性质:

  1. 先被审计

    ingest.Ingest 已写入 receiveddw_chat_events 行,因此验证私信在分支动它之前就被记录。

  2. 两种竞态,两道守卫

    被重复投递的同一条私信会被作为 Duplicate 过滤(传输幂等);而原子 DB 消费独立地守护不同消息同一验证码的竞态。

  3. 在默认拒绝之前拦截

    尚未映射的发送者永远到不了 policy.Evaluate —— 否则那里本会(正确地)拒绝他们。

该逻辑位于 EventProcessor 上的一个可选 verification 依赖之后(为 nil 即禁用,与 sender/learning 一致)。新的 IngestDecision 终态值 verifieddenied_verification 把验证私信挡在 decision IN ('received','processed') 的抽取/上下文窗口之外。

身份解析之前的验证分支,及其向未改动的 Phase 1 流水线的回落。
身份解析之前的验证分支,及其向未改动的 Phase 1 流水线的回落。

如何在任何 AI 之前廉价判断"这是不是一次验证尝试?"

  • Telegram —— 消息字面上就是 /start <payload>(或 /verify <code>);确定性字符串匹配,payload 即候选令牌。
  • WhatsApp —— 预填正文带有命名空间哨兵前缀,因此一条纯文本私信可被区分。自由键入的仿冒只会在原子消费时失败 —— 从构造上安全。
  • Discord (推迟) —— 本应是 /verify 斜杠命令或带哨兵前缀的私信,但 Discord 自助不在范围内(仅 Gateway 入站)。

嵌入"安全 vs 有后果"模型(ADR-0029)

DM 挑战完全由安全可执行动作组成 —— 由用户发起,且 worker 并非代表组织行事。

步骤归类原因
用户发送验证私信—(入站)经由组织签发的链接邀约而来。
worker 消费验证码 + 写入已验证身份安全用户已证明其资格的一次自我绑定;改变的是身份映射,而非访问/能力。
worker 回复"已验证" / "验证码无效"安全对用户刚发出的消息的、被邀约的、渠道内回复。
首次接触"我不认识你,请在此验证"安全被邀约、通用、限流,不泄露任何信息。
主动私信一个从未联系过 worker 的用户有后果代表组织的未经邀约出站 —— 需经闸门 / 不在范围。邀请走面板。

关键一句:验证确立一个聊天身份是谁;它从不确立他们可做什么 能力、审批闸门与 AccessGate 行为均未触动 —— 一个刚刚自助验证的用户,能做的恰好就是其 Gremlin 角色所允许的,不多一分。

入职界面(面板)

按"单位投入产出的杠杆"排序:

  1. 按用户邀请链接 / 二维码

    在渠道绑定或成员界面,生成一个验证码并得到 wa.me / t.me?start= 链接外加二维码(仅 Telegram/WhatsApp;Discord 用管理员断言)。这是主干。

  2. 批量"邀请整个团队"

    向已知名册分发预置链接;界面按成员展示实时验证状态。

  3. 自解释式首次接触

    接好"未验证—被邀约"回复,让每次失败的互动都成为一条恢复路径。

  4. 引导式绑定向导

    带着实时"M 中 N 人已验证"计数器,走完 建 role-agent → soul → 绑渠道 → 能力 → 验证成员。

错误处理

情形行为
验证码有效,原子消费命中翻为 consumed,在同一 TX 插入已验证身份,安全成功回复,审计。
验证码过期 / 已消费 / 已撤销零行更新 → attempt_count++,记拒绝事件,通用限流提示。
提供方不匹配按未命中处理;通用提示。(防止 Telegram 的码被从 WhatsApp 使用。)
(org,provider,external_user_id) 已验证到同一用户幂等:成功回复,不重复建行(ON CONFLICT DO NOTHING);消费该码以退役它。
…… 已验证到不同用户(Case C)接管守卫:不做改动,标记给管理员,退役该码,返回通用失败回复。
同一用户在该提供方上已以不同 external id 映射(Case D)标记给管理员(可能账号变更);不自动新增第二个绑定。
某发送者超出限流窗口内被拒绝验证事件计数超阈值 → 停止回复;继续记录拒绝事件。
把貌似秘密/凭据的令牌当作验证码发送永不匹配,除作为一次失败尝试外永不存储;从不以明文记录。
未验证用户在渠道(非私信)中提及 worker按 Phase 1 默认拒绝;可选发一条安全提示引导去私信验证,按"用户×渠道"防抖。

安全与防护

  • 产出仍是一行已验证记录。没有任何东西削弱 UNIQUE (organization_id, provider, external_user_id) + status='verified';改变的只是入册。
  • 只存带 key 的哈希。持久化单个 code_lookup = HMAC-SHA256(token, server_pepper) 并以此匹配;从不存储原始令牌、第二个哈希,也不记录二者。
  • 一次性、短 TTL、原子消费。封住重放与并发双绑定竞态。
  • 无 oracle。每次失败都返回同一句通用消息。按发送者限流即对被拒绝验证 dw_chat_events(org_id, provider, external_user_id) 做窗口 COUNT(如 10 分钟内 >5 次)→ 抑制回复。无需新表;复用已写入的事件,必要时加一个支撑索引。
  • 自我绑定 ≠ 自我提权。验证授予的是身份,不是能力(ADR-0029/0032)。
  • 下游复用闸门。记录一旦存在,Phase 2 的自动保存投毒防御与 Phase 3 的属主强制规则便自动生效。
  • 事事审计。签发/消费/撤销以及每一次失败尝试都按 ADR-0007 落账(人类动作 → audit_log;可观测性 → dw_chat_events)。

上线计划

每一步都可逆且为增量;没有一步改动 Phase 1 闸门。

  1. Schema + 解析器

    dw_verification_codes、原子消费分支、身份解析前的 webhook 检查。先做 Telegram /start

  2. WhatsApp 发起

    wa.me 预填哨兵(Discord 仍走管理员断言)。

  3. 邀请链接 / 二维码

    面板内按用户链接 + 二维码;成功/失败安全回复。

  4. 首次接触

    未验证—被邀约提示,限流。

  5. 批量 + 实时状态

    名册分发,"M 中 N 人已验证"。

  6. 引导向导

    把上述折叠进一条有序的设置流程。

新增的 ADR

ADR决策
0035自助 DM 验证码挑战取代管理员 ID 获取;webhook 信封中平台提供的 external_user_id 即权威的绑定键。
0036验证在身份解析之前的分支中完成,带原子一次性验证码消费;失败均为通用(无 oracle)且限流。以三类对未验证发送者的回复动作扩展 ADR-0029 安全集,由验证/邀约上下文把关。

建立在:ADR-0023(单属者租约,Discord Gateway)、ADR-0029(安全 vs 有后果 + 已验证身份)、ADR-0031(直连提供方集成)、ADR-0032(身份/记忆是上下文而非权威)、ADR-0007(两套审计系统)之上。

待解问题