Files
lucky-cat/docs/knowledge vault/device-auth-v2-flow.zh-CN.md
T

1007 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设备 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。
```