跳到主要内容

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
XTalkDeviceIdentitydeviceID: UInt32did: Stringserial: Stringsource: XTalkDeviceIdentitySource
XTalkDeviceAuthConfiguration五个正整数:probeTimeoutMs = 800strictProbeTimeoutMs = 1200challengeTimeoutMs = 4000identityTimeoutMs = 4000maxChallengeAttempts = 3
XTalkDeviceAuthResultidentityencodingdidRequireChallenge
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
.invalidDeviceIdentityEFUSE SN 和 SN 都未产生可用身份。
.cancelledcancel() 或等待认证的 Task 被取消。

初始化与生命周期

XTalkDeviceAuthenticator@MainActorObservableObject。用已连接的 transport 创建一个实例,并在整个设备会话期间强引用它。

  1. 连接并等待 AT 通道就绪。
  2. 创建 transport 适配器和 XTalkDeviceAuthenticator(transport:)
  3. 调用 authenticate(configuration:),只在成功时保存返回的 identity/encoding。
  4. 离开页面、切换设备或断链时,取消外部 Task 并调用 cancel()
  5. 新设备必须新建或重置对应会话,不得复用上一台设备的身份。

完整 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 & 0x1Fweek & 0x7Fserial & 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 含义
160ASCII 十进制 UInt32制造元数据 1;校验但不参与 DID
2002ASCII 十进制 UInt32制造元数据 2;校验但不参与 DID
324ASCII 十进制 UInt32year,取低 5 bit 写入 DID
424ASCII 十进制 UInt32week,取低 7 bit 写入 DID
511ASCII 十进制 UInt32制造元数据 5;校验但不参与 DID
64ASCII 十进制 UInt32制造元数据 6;校验但不参与 DID
71EE70ASCII 十六进制 UInt32serial,取低 20 bit 写入 DID
800025018ASCII 十六进制 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.didXTalkDeviceIDCodec.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 通信的必要状态,应保留到设备会话结束。
  • RecoveringBleAuthTransportXTalkDeviceAuthenticator 必须与 BLE 会话 一起被强引用;局部临时对象不能承担跨重连的恢复状态。

常见问题

为什么一直 .transportUnavailable

先确认连接未断开、AT 已就绪、transport 真正更新了编码,并且会在超时后清理迟到响应。

可以在 Challenge 失败后继续使用旧 DID 吗?

不可以。必须将本次会话视为未认证。

为什么编码探测偶发读到上一条命令?

transport 没有保持严格的事务边界。返回超时/取消前必须结束或丢弃该事务的迟到响应。 如果边界已经不可信,不能假设认证 SDK 会修复 BLE;必须由 transport 执行断开、 重连、等待 atReady、重设编码,再返回空结果或原错误。

何时重用 authenticator?

可以在同一设备会话中串行重试;切换物理设备后建议随新 transport 创建新认证器。

关联 SDK 与下一步