1007 lines
23 KiB
Markdown
1007 lines
23 KiB
Markdown
# 设备 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 <challenge_id> <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 <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. 工厂串口任务如何启动
|
||
|
||
当配置:
|
||
|
||
```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 <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 工厂工具发送什么
|
||
|
||
```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": "<X坐标base64url>",
|
||
"y": "<Y坐标base64url>"
|
||
}
|
||
```
|
||
|
||
再计算 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": "<base64url-16bytes>",
|
||
"challenge": "<base64url-32bytes>",
|
||
"pub_key_jkt": "<base64url-32bytes>",
|
||
"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 <challenge_id> <challenge>
|
||
```
|
||
|
||
设备调用:
|
||
|
||
```text
|
||
handle_sign_challenge()
|
||
-> control_plane_multimodal_device_auth_sign_challenge()
|
||
-> sign_message_raw_rs_b64u()
|
||
```
|
||
|
||
设备返回:
|
||
|
||
```json
|
||
{
|
||
"signature": "<base64url(raw r||s)>"
|
||
}
|
||
```
|
||
|
||
### 5.5 工厂工具提交签名
|
||
|
||
接口:
|
||
|
||
```text
|
||
POST /register/api/auth/response
|
||
```
|
||
|
||
首次/pending阶段的请求概念上是:
|
||
|
||
```json
|
||
{
|
||
"device_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
"challenge_id": "<服务器返回值>",
|
||
"signature": "<ESP32设备私钥签名>",
|
||
"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": "<base64url公钥>"
|
||
}
|
||
```
|
||
|
||
因为服务器已经保存可信绑定,这次不再需要工位字段。
|
||
|
||
内部接收结构:
|
||
|
||
```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": "<base64url raw r||s>"
|
||
}
|
||
```
|
||
|
||
服务器处理顺序应当是:
|
||
|
||
```text
|
||
根据challenge_id读取挑战记录
|
||
-> 检查挑战属于device_id
|
||
-> 检查未过期
|
||
-> 检查used=false
|
||
-> 读取device_id绑定的公钥、JKT和版本
|
||
-> 重建与ESP32完全相同的签名字节
|
||
-> 解码signature得到r和s
|
||
-> 使用设备公钥执行P-256 ECDSA/SHA-256验签
|
||
-> 原子地将挑战标记为used
|
||
-> 签发短期设备JWT
|
||
```
|
||
|
||
成功响应可能是:
|
||
|
||
```json
|
||
{
|
||
"access_token": "<device-jwt>",
|
||
"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 <device-jwt>
|
||
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": "<dashscope临时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>
|
||
```
|
||
|
||
`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。
|
||
```
|