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