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

23 KiB
Raw Permalink Blame History

设备 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 工厂注册阶段:建立设备身份和公钥绑定

工厂后台创建一条设备记录
    │
    │ 得到 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 绑定自己的公钥。

首次绑定必须同时证明:

  1. device_id 是工厂后台正式分配的设备;
  2. 此次操作来自合法工位;
  3. 公钥确实由目标 ESP32 生成;
  4. 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. 当前实现需要重点确认的安全事项

  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. 一句话总结

工厂先把设备ID写入ESP32,并将ESP32本地生成的公钥可信绑定到服务器;
日常运行时,服务器发随机Challenge,ESP32用永不上传的私钥签名;
服务器用绑定公钥验签后发设备JWT,ESP32再用设备JWT换取临时语音Token,
最后携带临时语音Token和workspace_id建立AI语音WebSocket。