iOS 设备认证 SDK
SDK Version: 1.0.0 Product:
XTalkDeviceAuthSdk
产品用途
XTalkDeviceAuthSdk 在已建立的 AT 通道上完成编码探测、设备挑战验证和 DID 读取,将可信的设备身份交给业务层。它适用于 BLE、UART 或其他能按顺序执行 AT 命令的传输。
职责与边界
SDK 负责:
- 在
.plain和.xor之间探测可用编码。 - 在设备要求时完成 Challenge 验证,并在内部校验签名。
- 优先读取 EFUSE SN,识别
+EFUSESN和固件实际返回的+SN别名,失败时才回退到普通 SN。 - 在 SDK 内完成 SN 字段校验、DID 打包和规范化,直接输出可信的
result.identity.did。 - 提供单会话状态、取消和稳定的公开错误。
SDK 不负责建立 BLE/UART 连接、申请系统权限、存储登录态或自动重连。认证密钥和签名格式属于产品内部边界;客户不应自行实现或绕过 Challenge 流程。
App 不得从原始 +SN/+EFUSESN 行拼接或计算 DID。iOS 业务端直接使用
result.identity.did;BLE 通道失步后的断开、重连和 atReady 恢复由 App
拥有的 transport 完成,认证 SDK 不会替 App 自动恢复 CoreBluetooth 链路。
适用范围与前提
- Swift tools 5.9,iOS 17+ 或 macOS 14+。
- 设备已连接,AT 通道已就绪;BLE 场景通常要等待
XTalkBleClient.atReady == true。 - 同一物理通道上的 AT 请求必须串行。超时或取消后,transport 必须清理迟到响应,再开始下一条命令。
- 认证成功前不得将旧 DID 用于当前设备。
产物与依赖
在 Xcode 中使用供应商提供的 Swift Package URL,并精确锁定到 1.0.0;Target 链接 XTalkDeviceAuthSdk 并在代码中 import XTalkDeviceAuthSdk。BLE 集成还需链接 XTalkBleSdk。
认证产品本身不依赖 CoreBluetooth,客户通过 DeviceAuthTransport 适配实际通道。
权限与项目配置
SDK 不直接申请系统权限。如果 transport 使用 BLE,应按 BLE SDK 文档配置 Bluetooth Usage Description;UART 或其他通道由客户自行配置。
DeviceAuthTransport 实现必须保证:
label是可读的通道名称。atEncoding始终反映底层真实编码,setAtEncoding(_:)在下一条命令前异步更新底层。runAtCommand按顺序执行,支持超时与 Task 取消。.collectFullWindow和.recoverableEncodingProbe不因普通OK或早到错误而提前结束。.recoverableEncodingProbe出现空窗口或不可信终态时,先恢复干净且atReady的命令通道,再返回空数组让认证器继续另一种编码探测。
公开类型
| 类型 | 公开成员/含义 |
|---|---|
DeviceAuthAtEncoding | .plain、.xor |
DeviceAuthResponseMode | .automatic、.collectFullWindow、.recoverableEncodingProbe |
XTalkDeviceIdentitySource | .efuseSn、.sn |
XTalkDeviceIdentity | deviceID: UInt32、did: String、serial: String、source: XTalkDeviceIdentitySource |
XTalkDeviceAuthConfiguration | 五个正整数:probeTimeoutMs = 800、strictProbeTimeoutMs = 1200、challengeTimeoutMs = 4000、identityTimeoutMs = 4000、maxChallengeAttempts = 3 |
XTalkDeviceAuthResult | identity、encoding、didRequireChallenge |
XTalkDeviceAuthState | .idle、.probing、.challenging(attempt:maxAttempts:)、.readingIdentity、.authenticated(identity)、.failed(error)、.cancelled |
XTalkDeviceAuthError 全部公开错误:
| 错误 | 含义/建议 |
|---|---|
.busy | 认证会话仍占用此 authenticator;应等待它的 authenticate 调用返回。cancel() 只请求终止,不响应取消的命令可能让 authenticator 保持 busy,直到该命令真正退出。 |
.invalidConfiguration | 配置中存在非正整数。 |
.transportUnavailable | 通道不可用、断开或返回了无法分类的错误。 |
.commandTimedOut(command) | 指定认证命令超时;先检查链路再重试。 |
.secureRandomUnavailable | 安全随机数不可用;不要降级验证。 |
.invalidChallenge、.challengeMissing、.malformedChallengeReply | 挑战数据缺失或格式无效。 |
.invalidSignatureLength(actual)、.signatureVerificationFailed | 设备签名无效;按认证失败处理。 |
.challengeRetryExhausted | 已用尽 maxChallengeAttempts。 |
.invalidDeviceIdentity | EFUSE SN 和 SN 都未产生可用身份。 |
.cancelled | cancel() 或等待认证的 Task 被取消。 |
初始化与生命周期
XTalkDeviceAuthenticator 是 @MainActor 的 ObservableObject。用已连接的 transport 创建一个实例,并在整个设备会话期间强引用它。
- 连接并等待 AT 通道就绪。
- 创建 transport 适配器和
XTalkDeviceAuthenticator(transport:)。 - 调用
authenticate(configuration:),只在成功时保存返回的 identity/encoding。 - 离开页面、切换设备或断链时,取消外部 Task 并调用
cancel()。 - 新设备必须新建或重置对应会话,不得复用上一台设备的身份。
完整 API 参考
DeviceAuthTransport
@MainActor
public protocol DeviceAuthTransport: AnyObject {
var label: String { get }
var atEncoding: DeviceAuthAtEncoding { get }
func setAtEncoding(_ encoding: DeviceAuthAtEncoding) async
func runAtCommand(
_ command: String,
timeoutMs: Int,
log: Bool,
responseMode: DeviceAuthResponseMode
) async throws -> [String]
}
XTalkDeviceAuthenticator
| API | 说明 |
|---|---|
init(transport:) | 绑定一个 DeviceAuthTransport。 |
@Published private(set) var state | 初始为 .idle,在主执行者上更新。 |
authenticate(configuration: = .init()) async throws -> XTalkDeviceAuthResult | 执行完整认证;成功返回身份,失败抛出 XTalkDeviceAuthError。 |
cancel() | 请求取消当前会话;可重复调用。 |
XTalkDeviceIDCodec
| API | 用途 |
|---|---|
canonicalize(_:) -> UInt32 | 将已有数值转成规范设备 ID。 |
fromSn(year:week:serial:) -> UInt32 | 先把 year/week 截断为 UInt32,再打包协议低位(year & 0x1F、week & 0x7F、serial & 0x000F_FFFF)并规范化结果。该方法会掩码而不是校验字段,且永远不会返回 nil。 |
parse(_:) -> UInt32? | 解析已存储的 DID/SN 文本;无效返回 nil。 |
format(_:) -> String | 输出规范 DID 文本。 |
SN 响应字段与 DID 映射
AT+EFUSESN? 允许返回 +EFUSESN:,也允许返回真机固件使用的 +SN
别名。结构化响应必须有 7 段或 8 段;前 6 段是 ASCII 十进制,第 7 段是
ASCII 十六进制序列号,第 8 段是可选的 ASCII 十六进制 EFUSE/追溯字段。
| 段号 | 真机值 | 格式 | SDK 含义 |
|---|---|---|---|
| 1 | 60 | ASCII 十进制 UInt32 | 制造元数据 1;校验但不参与 DID |
| 2 | 002 | ASCII 十进制 UInt32 | 制造元数据 2;校验但不参与 DID |
| 3 | 24 | ASCII 十进制 UInt32 | year,取低 5 bit 写入 DID |
| 4 | 24 | ASCII 十进制 UInt32 | week,取低 7 bit 写入 DID |
| 5 | 11 | ASCII 十进制 UInt32 | 制造元数据 5;校验但不参与 DID |
| 6 | 4 | ASCII 十进制 UInt32 | 制造元数据 6;校验但不参与 DID |
| 7 | 1EE70 | ASCII 十六进制 UInt32 | serial,取低 20 bit 写入 DID |
| 8 | 00025018 | ASCII 十六进制 UInt32 | 可选 EFUSE/追溯字段;校验但不参与 DID |
真机示例:
AT+EFUSESN?
+SN 60:002:24:24:11:4:1EE70:00025018
AT_OK
SDK 内部统一调用
XTalkDeviceIDCodec.fromSn(year: 24, week: 24, serial: 0x1EE70),得到:
(24 & 0x1F) << 27 | (24 & 0x7F) << 20 | (0x1EE70 & 0xFFFFF)
= 0xC181EE70
因此 iOS 返回 result.identity.did == "0xC181EE70"、source == .efuseSn,
并且不会继续调用 AT+SN?。第 8 段 00025018 不进入 DID。任一字段格式
不合法时,该响应整体无效,不能从局部数字生成身份。
分功能示例
用 BLE SDK 适配 transport,并恢复 plain 探测失步
import Foundation
import XTalkBleSdk
import XTalkDeviceAuthSdk
@MainActor
final class RecoveringBleAuthTransport: DeviceAuthTransport {
let label = "xTalk BLE"
private(set) var atEncoding: DeviceAuthAtEncoding
private let ble: XTalkBleClient
private let peripheralID: UUID
private var pendingEncodingError: Error?
init(ble: XTalkBleClient, peripheralID: UUID) {
self.ble = ble
self.peripheralID = peripheralID
atEncoding = ble.atEncoding == .plain ? .plain : .xor
}
func setAtEncoding(_ encoding: DeviceAuthAtEncoding) async {
atEncoding = encoding
do {
try await ble.setAtEncoding(bleEncoding(encoding))
pendingEncodingError = nil
} catch {
pendingEncodingError = error
}
}
func runAtCommand(
_ command: String,
timeoutMs: Int,
log: Bool,
responseMode: DeviceAuthResponseMode
) async throws -> [String] {
if let pendingEncodingError {
self.pendingEncodingError = nil
throw pendingEncodingError
}
let bleMode: XTalkAtResponseMode
switch responseMode {
case .automatic:
bleMode = .automatic
case .collectFullWindow, .recoverableEncodingProbe:
bleMode = .collectUntilTimeout
}
do {
let lines = try await ble.runAtCommand(
command,
timeoutMs: timeoutMs,
log: log,
responseMode: bleMode
)
if responseMode == .recoverableEncodingProbe, lines.isEmpty {
try await reconnectAndRestoreEncoding()
return []
}
return lines
} catch let error as XTalkBleError {
guard responseMode != .automatic,
requiresBoundaryRecovery(error)
else { throw error }
try await reconnectAndRestoreEncoding()
if responseMode == .recoverableEncodingProbe {
// 当前 probe 已无可信结果。认证器收到空窗口后会把
// `.plain` 切到 `.xor` 并发起下一次 `AT+FREQ?`。
return []
}
// Challenge retry 或 EFUSE -> SN fallback 前通道已经恢复;
// 保留本次命令的原始失败语义给认证器。
throw error
}
}
private func reconnectAndRestoreEncoding() async throws {
ble.disconnect()
try await waitUntilDisconnected(timeout: .seconds(5))
try await ble.startScan()
defer { ble.stopScan() }
let rediscovered = try await waitForPeripheral(timeout: .seconds(10))
try await ble.connect(id: rediscovered.id)
try await waitUntilAtReady(timeout: .seconds(10))
// 新连接默认编码不可信;先恢复当前 probe 使用的编码,再返回。
try await ble.setAtEncoding(bleEncoding(atEncoding))
pendingEncodingError = nil
}
private func waitUntilDisconnected(timeout: Duration) async throws {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while ble.connectionState != .disconnected {
try Task.checkCancellation()
guard clock.now < deadline else { throw XTalkBleError.timeout }
try await Task.sleep(for: .milliseconds(50))
}
}
private func waitForPeripheral(timeout: Duration) async throws -> XTalkScanResult {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while clock.now < deadline {
try Task.checkCancellation()
if let result = ble.scanResults.first(where: { $0.id == peripheralID }) {
return result
}
if case .failed(let error) = ble.connectionState { throw error }
try await Task.sleep(for: .milliseconds(50))
}
throw XTalkBleError.deviceNotFound(peripheralID)
}
private func waitUntilAtReady(timeout: Duration) async throws {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while !ble.atReady {
try Task.checkCancellation()
if case .failed(let error) = ble.connectionState { throw error }
if case .disconnected = ble.connectionState {
throw XTalkBleError.notConnected
}
guard clock.now < deadline else { throw XTalkBleError.timeout }
try await Task.sleep(for: .milliseconds(50))
}
}
private func bleEncoding(_ encoding: DeviceAuthAtEncoding) -> XTalkAtEncoding {
encoding == .plain ? .plain : .xor
}
private func requiresBoundaryRecovery(_ error: XTalkBleError) -> Bool {
switch error {
case .timeout, .notConnected, .notReady, .connectionFailed,
.writeFailed, .malformedResponse:
true
default:
false
}
}
}
完整恢复顺序是:
plain AT+FREQ? 空窗口/失步
→ Transport 断开 BLE
→ 重新扫描并连接同一 peripheral
→ 等待 atReady == true
→ 重设 .plain
→ 向 Auth 返回 []
→ Auth 调用 setAtEncoding(.xor)
→ Auth 继续 XOR AT+FREQ?
这段恢复必须发生在 DeviceAuthTransport 内。认证 SDK 只根据空结果选择下一
编码,不会自动调用 disconnect()、startScan() 或 connect(id:)。
配置、认证和 DID 工具
let config = XTalkDeviceAuthConfiguration(
probeTimeoutMs: 800,
strictProbeTimeoutMs: 1_200,
challengeTimeoutMs: 4_000,
identityTimeoutMs: 4_000,
maxChallengeAttempts: 3
)
let result = try await authenticator.authenticate(configuration: config)
let did = result.identity.did
print(did)
业务代码直接使用 result.identity.did。XTalkDeviceIDCodec.fromSn(...) 是
SDK 身份解析器的字段构造能力,不是 App 从原始 AT 回应手算 DID 的接入口。
完整接入示例
import Combine
import XTalkBleSdk
import XTalkDeviceAuthSdk
@MainActor
final class AuthSession: ObservableObject {
let ble: XTalkBleClient
private let transport: RecoveringBleAuthTransport
private let auth: XTalkDeviceAuthenticator
private var task: Task<Void, Never>?
@Published private(set) var identity: XTalkDeviceIdentity?
@Published private(set) var failure: XTalkDeviceAuthError?
init(ble: XTalkBleClient, peripheralID: UUID) {
self.ble = ble
transport = RecoveringBleAuthTransport(
ble: ble,
peripheralID: peripheralID
)
auth = XTalkDeviceAuthenticator(transport: transport)
}
func authenticateConnectedDevice() {
guard ble.atReady else { failure = .transportUnavailable; return }
task?.cancel()
task = Task { @MainActor in
do {
let result = try await ble.withExclusiveAtSession { [auth] _ in
try await auth.authenticate()
}
guard !Task.isCancelled else { return }
identity = result.identity
failure = nil
} catch let error as XTalkDeviceAuthError {
identity = nil
failure = error
} catch {
identity = nil
failure = .transportUnavailable
}
}
}
func disconnect() {
task?.cancel()
auth.cancel()
task = nil
identity = nil
ble.disconnect()
}
}
状态/并发/资源与恢复
- 认证器、transport 协议和状态都隔离在
@MainActor;从其他执行器调用时使用await MainActor.run或主执行者 Task。 - 每个认证器同时只允许一次
authenticate,并发调用返回.busy。 cancel()和 Task 取消都会请求取消,对外体现为.cancelled。如果 transport 没有及时响应取消,底层会话会保持 active,直到当前命令真正返回;在这段清理窗口内,新调用authenticate会返回.busy。应等待上一次authenticate完成后再重试。- 认证失败为 fail closed:清除当前 identity。如果通道边界不确定,断开并重连后再重试。
- 成功结果中的
encoding是后续 AT 通信的必要状态,应保留到设备会话结束。 RecoveringBleAuthTransport和XTalkDeviceAuthenticator必须与 BLE 会话 一起被强引用;局部临时对象不能承担跨重连的恢复状态。
常见问题
为什么一直 .transportUnavailable?
先确认连接未断开、AT 已就绪、transport 真正更新了编码,并且会在超时后清理迟到响应。
可以在 Challenge 失败后继续使用旧 DID 吗?
不可以。必须将本次会话视为未认证。
为什么编码探测偶发读到上一条命令?
transport 没有保持严格的事务边界。返回超时/取消前必须结束或丢弃该事务的迟到响应。
如果边界已经不可信,不能假设认证 SDK 会修复 BLE;必须由 transport 执行断开、
重连、等待 atReady、重设编码,再返回空结果或原错误。
何时重用 authenticator?
可以在同一设备会话中串行重试;切换物理设备后建议随新 transport 创建新认证器。
关联 SDK 与下一步
- 先阅读 BLE 连接 SDK并建立稳定 AT 通道。
- 认证成功后,再初始化 Mesh SDK、低延迟 PTT SDK、文件与图片传输 SDK 或 OTA SDK。
- 如需跨模块顺序,参考 完整接入示例。