# 设备 Auth V2 与 AI 语音鉴权流程 > 本文只讲清楚一件事:一台新 ESP32 设备如何从工厂写入身份开始,完成公钥绑定、Challenge/Response 认证,取得设备 JWT,再换取语音 Token 并建立 WebSocket。 > > 当前实际配置:`CONFIG_AUTH_V2_ENABLE=y`、`CONFIG_TQ_FACTORY_CLI_ENABLE=y`。 ## 1. 先看完整流程 整个鉴权过程分为两个阶段: 1. **工厂注册阶段**:每台新设备原则上只做一次,目的是让服务器建立可信关系:`device_id -> 设备公钥`。 2. **设备日常运行阶段**:设备启动后自主执行,目的是证明自己仍持有对应私钥,并逐级取得语音服务 Token。 ### 1.1 工厂注册阶段:建立设备身份和公钥绑定 ```text 工厂后台创建一条设备记录 │ │ 得到 device_id、serial_number ▼ 工厂工具通过串口连接 ESP32 的 factory_cli 任务 │ │ set_device_id │ set_serial_number │ save_config ▼ ESP32 保存 device_id、serial_number │ │ get_auth_material ▼ ESP32 本地生成 P-256 密钥对 │ ├── 私钥:只保存在 ESP32,不上传 └── 公钥、JKT、key_version:通过串口返回工厂工具 │ ▼ 工厂工具携带“工位身份和工位签名”请求 Register 服务 │ │ 把 device_id 与设备公钥可信绑定 ▼ 服务器保存:device_id -> pub_key、JKT、key_version │ ▼ 工厂工具取得服务器 Challenge │ │ auth_sign_challenge ▼ ESP32 用设备私钥签名 │ ▼ 工厂工具把设备签名提交给服务器 │ ▼ 服务器用已绑定的设备公钥验签,设备完成激活 ``` 这里必须区分两种工具: | 参与者 | 所在位置 | 职责 | | --- | --- | --- | | `factory_cli` | ESP32 固件中的串口监听任务 | 写入设备身份、生成/读取设备公钥、使用设备私钥签名 | | 工厂工具 | 生产电脑上的程序 | 发送串口命令、持有工位凭证、请求 Register 服务、组织首次绑定流程 | `factory_cli` 本身不会代替工厂工具完成所有 HTTP 注册操作。它主要负责访问设备内部数据和调用设备私钥。 ### 1.2 日常运行阶段:设备自主取得语音 Token 首次绑定完成以后,不需要每次语音聊天都连接工厂串口。设备可以自主执行: ```text ESP32 读取 device_id 和本地设备密钥 │ │ POST /register/api/auth/challenge ▼ 服务器查询 device_id 对应的已绑定公钥 │ │ 返回 challenge_id、challenge、pub_key_jkt、key_version ▼ ESP32 检查 Challenge 字段并构造待签名消息 │ │ 使用设备私钥做 P-256 ECDSA/SHA-256 签名 ▼ ESP32 得到 signature = base64url(raw r || raw s) │ │ POST /register/api/auth/response ▼ 服务器使用已绑定的设备公钥验签 │ │ 验签成功 ▼ 服务器返回短期设备 JWT │ │ Authorization: Bearer │ POST /token/api/device/token ▼ Token 服务验证 JWT、设备身份和 voice:session 权限 │ │ 返回临时语音 Token、workspace_id、app_id、voice_id ▼ ESP32 建立 DashScope WSS │ Authorization: Bearer <临时语音Token> │ X-DashScope-WorkSpace: ▼ 开始 AI 语音会话 ``` ### 1.3 三种凭证不要混淆 | 凭证 | 谁产生 | 谁验证/使用 | 作用 | | --- | --- | --- | --- | | 设备签名 `signature` | ESP32 用设备私钥产生 | Register 服务用设备公钥验证 | 证明设备持有私钥 | | 设备 JWT | Register 服务签发 | Token 服务验证 | 授权设备调用 TalkingQ 业务接口 | | 临时语音 Token | Token 服务返回 | DashScope WebSocket 使用 | 授权实际 AI 语音会话 | 设备 JWT 不能直接替代语音 Token连接 DashScope;语音 Token 也不是设备长期身份证明。 --- ## 2. 工厂串口任务如何启动 当配置: ```text CONFIG_TQ_FACTORY_CLI_ENABLE=y ``` 运行时启动代码会调用: ```c control_plane_factory_cli_start(); ``` 它创建名为 `factory_cli` 的 FreeRTOS 任务,通过 Console UART 读取以 `\r` 或 `\n` 结束的命令。 关键参数: | 参数 | 当前值 | | --- | ---: | | 最大命令行长度 | 512 B | | 最大参数数量 | 8 | | 串口读取超时 | 100 ms | | 任务栈 | 12 KiB | | 任务优先级 | 4 | | 运行核心 | `CONFIG_TQ_LOCAL_TASK_CORE` | 命令响应格式: ```text OK ERROR <一行 JSON> ``` 鉴权相关命令: | 命令 | 作用 | | --- | --- | | `factory_mode` | 标记当前 CLI 进入工厂模式 | | `get_device_info` | 读取已保存的设备 ID 和序列号 | | `set_device_id ` | 临时设置设备 ID | | `set_serial_number ` | 临时设置序列号 | | `save_config` | 提交并保存两个身份字段 | | `get_auth_material` | 生成或加载设备密钥,返回公开材料 | | `auth_sign_challenge ` | 使用设备私钥签署工厂提供的 Challenge | | `auth_prepare_rotate ` | 生成新密钥并准备轮换授权签名 | ## 3. 第一步:写入设备身份 ### 3.1 工厂工具发送什么 ```text factory_mode set_device_id 550e8400-e29b-41d4-a716-446655440000 set_serial_number 0508-0001 save_config get_device_info ``` ### 3.2 `set` 和 `save_config` 的区别 `set_device_id` 和 `set_serial_number` 只将值暂存在 RAM: ```c s_pending_device_id s_pending_serial_number ``` 只有执行 `save_config` 后,才会调用: ```c domain_runtime_write_device_identity(device_id, serial_number); ``` 并保存: ```text device_id serial_number ``` `save_config` 要求两个字段都不为空,否则返回: ```text ERROR identity_not_ready ``` 保存完成后,代码会调用: ```c control_plane_multimodal_device_auth_invalidate(0); ``` 使旧设备身份对应的 JWT 缓存失效。下次鉴权必须使用新身份重新执行 Challenge/Response。 ### 3.3 身份字段的使用位置 | 字段 | 后续用途 | | --- | --- | | `device_id` | Challenge 请求、签名消息、服务器公钥查询、JWT 主体 | | `serial_number` | 业务 Token 请求中的辅助身份校验 | 当前 Auth V2 的签名逻辑要求 `device_id` 能解析成16字节 UUID。 ## 4. 第二步:生成或读取设备密钥 ### 4.1 工厂工具请求 ```text get_auth_material ``` 设备调用链: ```text handle_get_auth_material() -> control_plane_multimodal_device_auth_get_key_material() -> key_store_load_or_create_locked() ``` ### 4.2 第一次执行 如果设备中没有密钥记录: ```text key_store_load_or_create_locked() -> generate_key_material() -> 生成私钥 -> 推导公钥 -> 计算公钥 JKT -> key_store_save_locked() ``` 实际生成函数: ```c static esp_err_t generate_key_material( uint8_t out_priv[AUTH_PRIVKEY_LEN], uint8_t out_pub[AUTH_PUBKEY_LEN], uint8_t out_jkt[AUTH_PUBKEY_JKT_LEN]); ``` 三个参数都是输出缓冲区,不需要外部提供密码或随机种子。 内部逻辑: ```text esp_fill_random(out_priv, 32) -> derive_public_from_private(out_priv, out_pub) -> build_pubkey_jkt(out_pub, out_jkt) ``` 固定长度: ```c #define AUTH_PRIVKEY_LEN 32 #define AUTH_PUBKEY_LEN 65 #define AUTH_PUBKEY_JKT_LEN 32 ``` 65字节公钥是未压缩 P-256 点: ```text 0x04 || X坐标32字节 || Y坐标32字节 ``` ### 4.3 后续执行 如果设备中已经存在密钥,则加载原密钥,不会重新生成: ```text 读取 priv_key、pub_key、pub_key_jkt、key_version -> 根据私钥重新推导公钥和JKT -> 检查/修复公钥材料是否匹配 -> 返回原公钥材料 ``` ### 4.4 串口返回什么 ```json { "pub_key": "<65字节公钥的base64url>", "pub_key_jkt": "<32字节JKT的base64url>", "key_version": 1 } ``` 工厂工具只能拿到公钥、JKT 和版本。设备私钥不会通过 `get_auth_material` 返回。 ### 4.5 JKT在流程中起什么作用 设备先将 P-256 公钥转换成稳定的 JWK 表示: ```json { "crv": "P-256", "kty": "EC", "x": "", "y": "" } ``` 再计算 SHA-256,得到32字节 JKT: ```text 公钥 JWK -> SHA-256 -> pub_key_jkt ``` JKT 是公钥指纹,用于确认 Challenge 指向的公钥与设备本地公钥一致。JKT 不能签名,也不能还原私钥。 ## 5. 第三步:工厂首次绑定公钥 ### 5.1 为什么必须经过工厂授权 服务器不能因为收到下面的请求,就无条件相信公钥: ```json { "device_id": "某台设备", "pub_key": "某个公钥" } ``` 否则攻击者可以给别人的 `device_id` 绑定自己的公钥。 首次绑定必须同时证明: 1. `device_id` 是工厂后台正式分配的设备; 2. 此次操作来自合法工位; 3. 公钥确实由目标 ESP32 生成; 4. ESP32 持有该公钥对应的私钥。 ### 5.2 工厂首次Challenge请求 接口: ```text POST /register/api/auth/challenge ``` 首次绑定请求包含: ```json { "device_id": "550e8400-e29b-41d4-a716-446655440000", "key_version": 1, "pub_key": "<设备返回的公钥>", "force_rebind": false, "station_id": "station-001", "timestamp": 1778192400, "nonce": "<工位生成的随机串>", "sig_b64": "<工位签名>" } ``` 其中: | 字段 | 来源 | 作用 | | --- | --- | --- | | `device_id` | 工厂后台/设备 | 指定要注册的设备 | | `key_version` | ESP32 | 首次通常为1 | | `pub_key` | `get_auth_material` | 要绑定的设备公钥 | | `station_id` | 工厂工具 | 表明是哪一个合法工位 | | `timestamp` | 工厂工具 | 限制请求有效时间 | | `nonce` | 工厂工具 | 防止工位请求重放 | | `sig_b64` | 工厂工具 | 工位私钥对请求内容的签名 | `sig_b64` 是工位签名,不是 ESP32 设备签名。 ### 5.3 服务器返回Challenge ```json { "challenge_id": "", "challenge": "", "pub_key_jkt": "", "key_version": 1, "expires_in": 60, "server_time": 1778192400 } ``` 服务端同时保存挑战上下文: ```text challenge_id device_id challenge pub_key_jkt key_version expires_at used=false ``` ### 5.4 工厂工具让ESP32签名 工厂工具通过串口发送: ```text auth_sign_challenge ``` 设备调用: ```text handle_sign_challenge() -> control_plane_multimodal_device_auth_sign_challenge() -> sign_message_raw_rs_b64u() ``` 设备返回: ```json { "signature": "" } ``` ### 5.5 工厂工具提交签名 接口: ```text POST /register/api/auth/response ``` 首次/pending阶段的请求概念上是: ```json { "device_id": "550e8400-e29b-41d4-a716-446655440000", "challenge_id": "<服务器返回值>", "signature": "", "station_id": "station-001", "timestamp": 1778192400, "nonce": "<新的工位随机串>", "sig_b64": "<工位签名>" } ``` 服务器分别验证: ```text 工位签名是否有效 -> 首次绑定操作是否可信 设备签名是否有效 -> ESP32 是否持有待绑定公钥对应的私钥 ``` 两个检查都通过后,服务器才能安全地激活: ```text device_id -> pub_key、pub_key_jkt、key_version ``` ## 6. 第四步:日常Challenge/Response 设备激活后,工厂工位不再参与普通认证。 ### 6.1 确保设备JWT可用 语音 Token 流程调用: ```c control_plane_multimodal_device_auth_ensure( CONTROL_PLANE_DEVICE_AUTH_SCOPE_VOICE_SESSION, force_refresh); ``` 它先检查: ```text 是否有缓存JWT JWT是否即将过期 JWT是否包含voice:session权限 调用方是否要求强制刷新 ``` 满足条件则复用 JWT;否则执行新的 Challenge/Response。 ### 6.2 普通Challenge请求 调用: ```c request_challenge(device_id, key_version, pub_key_b64u, &result); ``` 请求: ```json { "device_id": "550e8400-e29b-41d4-a716-446655440000", "key_version": 1, "pub_key": "" } ``` 因为服务器已经保存可信绑定,这次不再需要工位字段。 内部接收结构: ```c typedef struct { char challenge_id_b64u[128]; char challenge_b64u[128]; char pub_key_jkt_b64u[128]; uint32_t key_version; bool rotate_required; bool factory_required; } challenge_result_t; ``` 如果服务器认为设备尚未完成首次绑定,返回 `factory_required`,设备进入 `ST_WAIT_FACTORY`,不会绕过工厂流程。 ## 7. 设备具体签名的内容 设备不是只签名 `challenge` 字符串。 它先解码服务器返回的 Base64URL 字段,然后按精确顺序拼接: ```text UTF8("robot-auth-v1") + UUID_TO_16_BYTES(device_id) + base64url_decode(challenge_id) + base64url_decode(challenge) + base64url_decode(pub_key_jkt) + int32_be(key_version) ``` 各字段的解码长度: | 字段 | 长度 | | --- | ---: | | `robot-auth-v1` | 13 B | | `device_id` UUID | 16 B | | `challenge_id` | 16 B | | `challenge` | 32 B | | `pub_key_jkt` | 32 B | | `key_version` | 4 B,大端序 | 为什么要签这么多字段: | 字段 | 防止的问题 | | --- | --- | | `robot-auth-v1` | 防止同一签名被挪作其他协议用途 | | `device_id` | 防止签名被转移给另一台设备 | | `challenge_id` | 绑定服务器保存的具体挑战记录 | | `challenge` | 保证每次认证的新鲜性 | | `pub_key_jkt` | 绑定服务器预期的公钥 | | `key_version` | 防止不同密钥版本混用 | 签名函数: ```c sign_message_raw_rs_b64u(message, message_len, output, output_len); ``` 算法: ```text SHA-256(完整组合消息) -> 使用P-256设备私钥做ECDSA签名 -> 得到(r, s) ``` P-256 ECDSA 的 `r` 和 `s` 各占32字节。当前协议按下面的格式上传: ```text raw_signature = r_32_bytes || s_32_bytes signature = base64url_no_padding(raw_signature) ``` 它不是 DER 编码;服务端必须按64字节 `raw r||s` 解码。 ## 8. 服务器如何验签并签发JWT 设备请求: ```text POST /register/api/auth/response ``` 普通请求体: ```json { "device_id": "550e8400-e29b-41d4-a716-446655440000", "challenge_id": "<服务器返回的challenge_id>", "signature": "" } ``` 服务器处理顺序应当是: ```text 根据challenge_id读取挑战记录 -> 检查挑战属于device_id -> 检查未过期 -> 检查used=false -> 读取device_id绑定的公钥、JKT和版本 -> 重建与ESP32完全相同的签名字节 -> 解码signature得到r和s -> 使用设备公钥执行P-256 ECDSA/SHA-256验签 -> 原子地将挑战标记为used -> 签发短期设备JWT ``` 成功响应可能是: ```json { "access_token": "", "token_ttl": 600, "key_version": 1, "scope": "voice:session" } ``` 设备兼容解析多个字段名,如 `jwt`、`device_jwt`、`access_token` 等。 内部结构: ```c typedef struct { char jwt[AUTH_JWT_MAX_LEN]; uint64_t exp_epoch_s; uint32_t scope_mask; uint32_t key_version; bool rotate_required; bool factory_required; } response_result_t; ``` 设备缓存: ```c typedef struct { char jwt[AUTH_JWT_MAX_LEN]; uint64_t exp_epoch_s; uint32_t scope_mask; uint32_t key_version; bool valid; } jwt_cache_t; ``` 设备 JWT 是 Register 服务签发的 **JSON Web Token**,典型 Claim: | Claim | 含义 | | --- | --- | | `sub` | 设备 ID | | `exp` | 到期时间 | | `scope` | 权限,如 `voice:session` | | `kv` | 设备密钥版本 | 设备用私钥签 Challenge;JWT 则由认证服务器自己的签名密钥签发。这是两次不同的签名。 ## 9. 使用设备JWT换取语音Token 入口: ```c control_plane_multimodal_auth_fetch(); ``` 设备先准备请求体: ```json { "deviceId": "550e8400-e29b-41d4-a716-446655440000", "serialNumber": "0508-0001" } ``` 再请求: ```text POST https://gz.api.talkingq.com/token/api/device/token ``` 请求头: ```http Authorization: Bearer Content-Type: application/json ``` Token 服务应验证: ```text JWT签名是否可信 JWT是否过期 JWT的sub是否与请求设备匹配 JWT是否包含voice:session deviceId与serialNumber是否属于同一设备 设备是否启用 设备是否配置了可用的app/workspace/voice ``` 代表性响应: ```json { "code": 200, "data": { "appId": "mm_xxx", "workspaceId": "llm_xxx", "voiceId": "kenny", "token": "", "deviceId": "550e8400-e29b-41d4-a716-446655440000", "serialNumber": "0508-0001", "expires_at": 1778194200 } } ``` 设备保存: ```c control_plane_multimodal_auth_info_t { token; workspace_id; app_id; voice_id; device_id; serial_number; valid; } ``` 如果第一次请求返回401或403: ```text 使缓存设备JWT失效 -> 强制重新执行Challenge/Response -> 得到新设备JWT -> 再请求一次语音Token ``` 最多额外重试一次,避免无效凭证造成无限循环。 ## 10. 使用语音Token建立WebSocket 设备使用上一步得到的临时语音 Token 建立: ```text wss://dashscope.aliyuncs.com/api-ws/v1/inference ``` 握手头: ```http Authorization: Bearer <临时语音Token> X-DashScope-WorkSpace: ``` `workspace_id` 必须与 Token 所属工作空间以及后续 Start 指令中的配置一致,否则可能收到: ```text Invalid workspace_id ``` 到这里,鉴权链路才算完成: ```text 设备私钥证明身份 -> 设备JWT授权TalkingQ业务接口 -> 临时语音Token授权DashScope -> WSS连接成功 ``` ## 11. JWT缓存与刷新 设备在使用 JWT 前检查: ```text jwt.valid exp_epoch_s scope_mask key_version ``` 如果 JWT 仍有效、权限满足,并且未进入提前刷新窗口,就直接复用。 当前提前刷新配置: ```text CONFIG_AUTH_JWT_REFRESH_MARGIN_SEC=90 ``` 也就是距到期不足90秒时按需要刷新处理,减少请求进行到一半 JWT 过期的概率。 设备会解析 JWT Payload 中的 `exp` 和 `scope`,但当前并不在本地验证 JWT 签名。本地解析只用于缓存决策;真正的安全授权必须由 Token 服务验签完成。 ## 12. 密钥轮换 当服务器要求 `rotate_required`,设备需要从旧密钥 V1 切换到新密钥 V2: ```text 设备生成新私钥V2和新公钥V2 -> 暂时保留旧私钥V1为活动密钥 -> 使用旧私钥V1签署“切换到公钥V2”的消息 -> POST /register/api/auth/rotate -> 服务器用旧公钥V1验签 -> 服务器接受公钥V2并升级key_version -> 设备保存私钥V2、公钥V2和新版本 -> 重新执行Challenge/Response获取新JWT ``` 轮换签名的精确字节: ```text UTF8("robot-rotate-v1") + UUID_TO_16_BYTES(device_id) + int32_be(current_key_version) + base64url_decode(new_pub_key_jkt) + base64url_decode(new_pub_key) ``` 请求体: ```json { "device_id": "550e8400-e29b-41d4-a716-446655440000", "key_version": 1, "new_pub_key": "<新公钥>", "new_pub_key_jkt": "<新公钥JKT>", "signature": "<旧私钥产生的签名>" } ``` 旧私钥签名的含义是: > 当前仍被服务器信任的设备密钥,授权将本设备切换到这个指定的新公钥。 新旧私钥都不会通过网络上传。 当前自动轮换存在一个需要关注的异常窗口:服务器先接受 V2,设备再保存 V2。如果中间断电或保存失败,可能出现服务器信任 V2、设备仍只有 V1 的不一致状态。量产实现应考虑 pending 双槽记录和轮换恢复协议。 ## 13. Auth V2状态 ```c typedef enum { ST_READY = 0, ST_NEED_JWT, ST_WAIT_FACTORY, ST_BACKOFF, ST_ROTATE, } auth_state_t; ``` | 状态 | 含义 | | --- | --- | | `ST_READY` | 已有可用设备JWT | | `ST_NEED_JWT` | 需要执行Challenge/Response | | `ST_WAIT_FACTORY` | 设备尚需首次绑定或工厂授权 | | `ST_BACKOFF` | 临时失败后等待重试 | | `ST_ROTATE` | 需要执行密钥轮换 | ## 14. 关键参数汇总 ### 14.1 设备本地身份 ```text device_id 最大64字符;Auth签名路径要求可解析为UUID serial_number 最大64字符 ``` ### 14.2 设备密钥 ```text priv_key 32字节P-256私钥 pub_key 65字节未压缩P-256公钥 pub_key_jkt 32字节SHA-256公钥指纹 key_version 32位正整数 ``` ### 14.3 Challenge ```text challenge_id Base64URL传输,解码后16字节 challenge Base64URL传输,解码后32字节 pub_key_jkt Base64URL传输,解码后32字节 key_version 32位正整数 expires_in 服务端挑战有效期 ``` ### 14.4 设备签名 ```text 算法 P-256 ECDSA + SHA-256 原始格式 r的32字节 || s的32字节 传输格式 无填充Base64URL 原始总长度 64字节 ``` ### 14.5 设备JWT ```text sub 设备ID exp 到期时间 scope 至少需要voice:session kv 设备密钥版本 ``` ### 14.6 临时语音Token结果 ```text token DashScope临时Token workspace_id 工作空间ID app_id 多模态应用ID voice_id TTS音色ID ``` ## 15. 代码调用链汇总 ### 15.1 工厂身份写入 ```text factory_cli_worker() -> handle_command_line() -> handle_save_config() -> domain_runtime_write_device_identity() -> platform_write_device_identity() ``` ### 15.2 设备密钥生成/读取 ```text handle_get_auth_material() -> control_plane_multimodal_device_auth_get_key_material() -> key_store_load_or_create_locked() -> generate_key_material() // 仅本地没有密钥时 -> key_store_save_locked() ``` ### 15.3 日常设备JWT ```text control_plane_multimodal_device_auth_ensure() -> refresh_jwt_internal() -> request_challenge() -> sign_message_raw_rs_b64u() -> request_response() -> save_state_to_store_locked() ``` ### 15.4 语音Token ```text control_plane_multimodal_auth_fetch() -> control_plane_multimodal_device_auth_ensure() -> control_plane_multimodal_device_auth_copy_jwt() -> http_post_json() -> domain_runtime_post_json() -> parse_token_response() ``` ### 15.5 WebSocket ```text control_plane_multimodal_start() -> domain_runtime_multimodal_transport_start() -> platform_multimodal_transport_start() -> multimodal_ws_start() ``` ## 16. 当前实现需要重点确认的安全事项 1. 首次公钥绑定必须要求合法工位认证,不能只相信设备上报的 `pub_key`。 2. 服务端 Challenge 必须短期有效、一次性使用,并与设备、公钥和版本绑定。 3. Token 服务必须验证设备 JWT 签名、到期时间、设备主体和 `voice:session` 权限。 4. 私钥、设备 JWT 和临时语音 Token 不得打印到日志。 5. 当前配置未启用 Secure Boot、Flash Encryption 和 NVS Encryption,量产前需要评估私钥的物理读取风险。 6. 工厂 CLI 在量产出厂后应关闭或限制访问,否则物理串口会扩大攻击面。 7. 密钥轮换需要补充断电后的事务恢复方案。 8. 缺少或无法解析 `scope` 时应拒绝使用,不应默认拥有全部权限。 ## 17. 一句话总结 ```text 工厂先把设备ID写入ESP32,并将ESP32本地生成的公钥可信绑定到服务器; 日常运行时,服务器发随机Challenge,ESP32用永不上传的私钥签名; 服务器用绑定公钥验签后发设备JWT,ESP32再用设备JWT换取临时语音Token, 最后携带临时语音Token和workspace_id建立AI语音WebSocket。 ```