SPDM 1.2.0 零基础拆解:设备身份认证与安全会话建立全流程

发布时间:2026/10/15 18:55:35
SPDM 1.2.0 零基础拆解:设备身份认证与安全会话建立全流程
简介SPDMSecurity Protocol and Data Model1.2.0是DMTF发布的开放规范面向服务器固件、硬件安全与带外管理相关技术人员为设备间认证、会话建立和数据安全交换提供统一协议与数据模型可支撑云计算、物联网及企业安全场景中的可信设备管理。压缩包内仅含1个PDF文件约2.56MB完整收录DSP0274规范正文包含2021年12月14日的版本标识、安全协议定义、数据模型说明、版权声明以及替代1.1.1的更新说明。读者可借助该文档系统理解SPDM的消息交互流程、算法套件协商与证书验证机制为固件开发、安全策略设计、硬件身份认证调试提供权威依据。该文档已有1158人学习下载适合需要查证原始标准或进行安全协议研究的工程师参考阅读。1. 为什么我要把 SPDM 1.2.0 这份规范从头到尾拆一遍SPDMSecurity Protocol and Data Model在服务器安全里是一个常被当成黑匣子的协议——文档里全是消息格式和状态机真正动手时才发现它解决的是硬件设备「我是谁、我是不是原厂固件、我能不能和对面建立一个可信会话」这三件最基础的事。DSP0274 1.2.0 是 DMTF 在 2021 年底发布的规范化文档核心能力包括身份认证、固件与配置度量、以及基于 DHE 或 PSK 的安全会话。我拆这份资源是为了两件事一是把手握手的消息序列和参数协商彻底搞懂二是给做固件安全、带外管理控制器和硬件验证的同行一份可以直接照着拼请求的参考。适合的人不是泛泛做网络安全的而是真正要跟硬件设备打交道、需要让设备自证身份的那批工程师。2. SPDM 在技术栈里的定位:它到底管哪三层安全2.1 先分清 SPDM 和 TLS/传输安全的边界很多第一次接触 SPDM 的开发者会问:既然有 TLS,为什么还需要 SPDM?这里必须先立住一个结论——SPDM 不是为了给大数据通道做传输加密,它关注的是「设备间的信任建立」和「运行状态的可信度量」。典型的使用场景是一条带外管理总线:主机侧的请求方(Requester)要通过 BMC 或管理控制器与一个设备(Responder)通信,通信之前首先要确认三件事。第一,这个设备是不是声称的那个设备,证书链是否完整;第二,设备里跑的固件是不是预期版本,度量值能不能对上;第三,后续这条通道需要传输敏感控制信息,能不能在会话层做一层轻量加密保护。SPDM 1.2.0 里所有消息都围绕这三件事展开,消息类型包括 GET_VERSION、GET_CAPABILITIES、NEGOTIATE_ALGORITHMS、GET_DIGESTS、GET_CERTIFICATE、CHALLENGE、GET_MEASUREMENTS 以及 KEY_EXCHANGE 等会话类消息。它不是要替代底层传输安全,而是定义在传输之上的安全协议和数据模型。提示:如果你要保护的是批量的业务数据流,SPDM 不是对的工具;它更适合设备身份认证、平台固件度量、以及轻量控制信令的保护。2.2 通用消息头里的版本编码和字节序陷阱SPDM 所有消息复用同一个头部结构,这是先要背下来的部分。消息头包含 SPDMVersion、RequestResponseCode、Param1、Param2 以及紧随其后的 payload。1.2.0 版本编码是 0x12,1.1.1 对应 0x11,1.0.0 对应 0x10。注意这里用的是 BCD 编码方式,高四位是主版本,低四位是次版本。字节序方面,规范里明确要求 SPDM 消息整体使用小端序传输,但有一个例外——Hash 值和编码后的 ASN.1 证书内容保持原样,不做字节翻转。我用 Python 构造请求时经常被这个搞晕,后来固定了一套模板:import struct def build_get_version(): # SPDM 1.2.0 版本号 0x12, GET_VERSION 请求码 0x84 header struct.pack(BBBB, 0x12, 0x84, 0x00, 0x00) return header def parse_version_response(raw): # 响应头两个字节: 版本 响应码 0x04 spdm_version, code raw[0], raw[1] reserved, num_versions raw[2], raw[3] versions [] offset 4 for i in range(num_versions): # 每个版本条目占 2 字节: 更新号 版本号 v raw[offset:offset2] v_num (v[1] 4) * 10 (v[1] 0x0F) versions.append(f{v_num}.{v[0]}) offset 2 return spdm_version, code, versions这段代码里 struct.pack 用BBBB明确指定小端序和四个无符号字节。GET_VERSION 请求本身没有任何 payload,所以头四个字节就是完整消息。响应解析时有一个细节:规范里 VersionNumberEntry 是两字节,但字节排列是低字节在前,所以 v[1] 才是主版本信息。我在初版脚本里在这里写反过,解析出来永远比预期大 16 倍,浪费了半个下午。2.3 GET_CAPABILITIES 能力发现:哪些位决定你能干什么版本协商完成后,请求方要发 GET_CAPABILITIES 探询对端的能力。这条消息的 payload 是一段固定 8 字节的能力标志位,其中几个关键位决定了后面能不能走完整安全流程:位含义影响Bit 0CERT_CAP支持证书链认证Bit 1CHAL_CAP支持 CHALLENGE 认证Bit 2MEAS_CAP支持度量,0不支持,1仅支持摘要,2支持签名Bit 4ENCRYPT_CAP支持会话加密Bit 5MAC_CAP支持会话 MACBit 6MUT_AUTH_CAP支持双向认证Bit 7KEY_EX_CAP支持 DHE 密钥交换Bit 8PSK_CAP支持预共享密钥会话这里要特别留意 MEAS_CAP 是两比特字段,取值 0/1/2 分别代表完全不可度量、只给摘要、给签名度量。很多开发者在能力协商时随便给个值,结果后面 GET_MEASUREMENTS 时对端返回 unsupported,其实问题出在这里。GET_CAPABILITIES 请求还带两个附加参数:CTE 和 CTE2。CTE 表示请求方希望以何种方式获取证书(默认 0 表示通过 GET_CERTIFICATE 消息),1.2.0 里扩展了 CTE2 用于多证书槽位选择。在实现里我一般固定填 0,除非对端明确声明支持其他模式。# 用简单的二进制拼接观察完整请求 # GET_CAPABILITIES 请求码 0xE1, payload 8 字节 两个附加参数字节 # 示例能力值: 0x0000015A 表示支持证书、挑战、度量、会话加密和 MAC printf \x12\xE1\x00\x00\x5A\x01\x00\x00\x02\x00\x00\x00 | xxd2.4 NEGOTIATE_ALGORITHMS 算法协商:一次谈拢所有加密原语这是整条握手流里最容易翻车的一步。NEGOTIATE_ALGORITHMS 不是简单地把支持的算法列出来,而是要严格按规范里的数据结构逐段拼接。消息里包含 alg_type 和 alg_count,每个算法条目占 4 字节,低 16 位是算法 ID,高 16 位是密钥长度或特定算法的额外位。协商的核心策略是一次性给出完整列表,让对端逐项确认。比如哈希算法同时声明 SHA-256 和 SHA-384,签名算法声明 ECDSA-P256 和 ECDSA-P384,最终对端会在响应里回一个固定算法,接下来所有消息都用这个结果。def build_negotiate_algorithms(algs): # algs 是 [(alg_type, alg_id, key_bits)] 列表 payload bytearray() payload struct.pack(BBBB, 0x12, 0xE3, 0x00, 0x00) payload struct.pack(H, 0) # length 占位, 稍后回填 payload struct.pack(B, 2) # measurement_spec 固定为 2 (DMTF) payload struct.pack(B, len(algs)) for alg_type, alg_id, key_bits in algs: payload struct.pack(HH, alg_id, key_bits | (alg_type 12)) # 回填总长度 payload[4:6] struct.pack(H, len(payload)) return payload这段代码里最需要解释的是key_bits | (alg_type 12)这一步。规范里算法条目 32 位,bit 0-11 是 AlgKeySize,bit 12-15 是 AlgType,bit 16-31 是 AlgId。所以是先放 key size 和 type,再放 alg id。很多参考实现直接把 alg_id 放进低 16 位,导致对端解析出来算法类型完全错乱。协商响应里还有一个大多数人不注意的字段——ResponderCapabilities 里的 AlgStruct 是否需要额外预留。1.2.0 里出现了 extended algorithm 的概念,如果对端在 CAPABILITIES 阶段声明了 ExtendedAlg,那么 NEGOTIATE_ALGORITHMS 的 payload 末尾要多塞 4 字节保留字段。我遇到过对端不回这段字段,直接导致后面消息长度校准失败。2.5 三步认证链路:GET_DIGESTS、GET_CERTIFICATE、CHALLENGE算法协商完成后进入身份认证阶段。第一步 GET_DIGESTS 拿证书摘要,第二步按槽位 GET_CERTIFICATE 取完整证书链,第三步 CHALLENGE 完成签名挑战。这三步是我在模拟项目X里实现得最踏实的部分,因为每条消息的格式都很固定。GET_DIGESTS 请求只有头部加 8 字节 payload,其中前 4 字节是槽位位图,表示要取哪些证书槽的摘要。响应里每个摘要大小由前面协商的哈希算法决定,SHA-256 就是 32 字节。如果一个响应包里塞了多个摘要,必须按槽位顺序从小到大排列。GET_CERTIFICATE 请求带 2 字节的槽位号和 2 字节的偏移量。收到响应后,先看前 2 字节是片段长度,再决定是继续发下一段还是拼完整。证书链在响应里是完整 DER 编码,获取时需要自己拼接收到的所有分片。CHALLENGE 消息是认证的核心,请求里带一个随机数 Nonce,响应里返回签名。签名内容不是单纯对 Nonce 签名,而是对整条消息的 transcript 签名,具体包括 GET_DIGESTS 响应、GET_CERTIFICATE 响应和 CHALLENGE 请求的哈希值。这个 transcript 规则在后面 KEY_EXCHANGE 里同样适用,是 SPDM 安全性的根基。3. 会话建立与密钥生命周期:KEY_EXCHANGE、FINISH、KEY_UPDATE 怎么配合3.1 KEY_EXCHANGE 消息:密钥交换不只交换密钥如果前面协商了 ENCRYPT_CAP 和 KEY_EX_CAP,握手流程会进入会话建立阶段。KEY_EXCHANGE 请求里包含请求方的 DHE 公钥、会话策略、随机数,以及对会话 transcript 的摘要。响应方返回自己的 DHE 公钥、测量摘要和签名。这个阶段最大的特点是:密钥材料在 KEY_EXCHANGE 消息里交换,但它不触发加密;真正让会话进入加密状态的是 FINISH 消息。把那段时间的编码逻辑整理成代码会更直观:def build_key_exchange(req_nonce, dhe_pub, req_cert_chain_hash): # req_cert_chain_hash 是前面证书链的哈希摘要 body bytearray() body struct.pack(BB, 0x12, 0xE4) # KEY_EXCHANGE 请求码 body struct.pack(BB, 0x00, 0x04) # Param1 会话ID 高字节, Param2 会话ID 低字节 body struct.pack(H, 0x0200) # 会话策略 body struct.pack(H, 0) # 给响应方的 DHE 公钥长度占位 body req_nonce # 32 字节 body dhe_pub # DHE 公钥原始字节 body req_cert_chain_hash # 证书链哈希 body struct.pack(H, 48) # 后面签名长度 body b\x00 * 48 # 签名占位 return body这段代码把 KEY_EXCHANGE 的结构完整摆出来:头部之后是会话参数,然后是 Nonce、DHE 公钥、证书链哈希,最后是对整条握手 transcript 的签名。会话 ID 在这里被拆成高低两个字节放在 Param1 和 Param2,这个细节在抓包排查时非常有用——因为后面所有会话消息都要带上这个 ID。签名覆盖的 transcript 范围是:GET_VERSION 响应、GET_CAPABILITIES 响应、NEGOTIATE_ALGORITHMS 响应、GET_DIGESTS 响应、GET_CERTIFICATE 响应、CHALLENGE 响应(如果有)、KEY_EXCHANGE 请求。漏掉任何一段,响应方验签都会失败,而且错误信息往往只给一个通用的 InvalidSignature。3.2 FINISH 与会话密钥的正式生效FINISH 消息承担两个职责:证明请求方确实掌握了和 KEY_EXCHANGE 响应对应的私钥,并且确认双方密钥计算一致。FINISH 请求包含一个基于会话密钥计算的 MAC,而不是签名,这是和 KEY_EXCHANGE 最大的区别。会话密钥计算采用 HKDF,提取阶段输入 DHE 共享密钥,扩展阶段分别派生出数据加密密钥、MAC 计算密钥和握手确认密钥。FINISH 的 MAC 就是用握手确认密钥对「FINISH 消息体 全部握手 transcript」做 HMAC 的结果。这里有一个实施上容易踩坑的点:标准规定的密钥派生顺序是固定的,data key 在前,MAC key 随后,HMAC key 最后。如果实现时把 HMAC key 和 data key 的 label 写反了,双方协商出的密钥就完全不一致,但奇怪的是第一帧加密消息可能还是通的,要到第二条消息才暴露。3.3 HEARTBEAT 和 KEY_UPDATE:会话维护的双通道会话建立后,维护机制容易被人忽略,但生产环境里恰恰是它出问题最多。HEARTBEAT 消息用于在长时间无业务流量时维持会话状态,它不需要任何 payload,纯粹是探活。很多协议栈为了省资源会省略 HEARTBEAT,但 SPDM 规范要求响应方必须处理它并返回 HEARTBEAT_ACK。KEY_UPDATE 才是真正影响安全的操作。它支持两种模式:单一方向更新和全部更新。KEY_UPDATE 请求里带 1 字节的密钥更新操作码,0x01 表示更新请求方到响应方方向的密钥,0x02 表示更新响应方到请求方方向,0x03 表示双向同时更新。def build_key_update(session_id, operation): # operation: 1请求方方向密钥, 2响应方方向密钥, 3双向 msg bytearray() msg struct.pack(BBBB, 0x12, 0xEA, 0x00, operation) msg struct.pack(BB, (session_id 8) 0xFF, session_id 0xFF) return msg这段构造里最关键的是把操作码放在 Param2,而不是 payload 里。我刚开始实现时把 operation 塞进了消息体,结果对端按 Param2 解析,永远读到 0x00,直接报 InvalidParam。另外,KEY_UPDATE 需要在加密状态下发送,消息本身要加 MAC,更新的密钥在收到 ACK 后立即生效。KEY_UPDATE 的时序有个隐藏要求:接收方在收到更新请求后,必须先用旧密钥验证 MAC,再更新密钥库。有些实现为了提高性能先换密钥再验 MAC,结果永远验证失败,这就是典型的顺序错位。3.4 会话生命周期状态机的几个关键节点完整的 SPDM 会话生命周期可以分为四个状态:未协商、协商完成、会话建立、会话终止。从非会话状态进入会话状态只在 KEY_EXCHANGE 成功之后;FINISH 成功只是确认了密钥一致性,不改变会话基本状态。异常路径要单独看。KEY_EXCHANGE 请求发出后如果收到 ERROR 响应,会话 ID 就已经被占用,不能立即重发同一个请求,必须用新的会话 ID。这个设计是为了防止重放攻击。有一个生产环境常见的坑是:设备复位后,请求方还拿着旧的会话 ID 发 HEARTBEAT,响应方因为状态已清空返回 SessionLost,请求方如果没处理这个错误码,就会一直卡在旧会话里。4. 避坑与排查:复现 DSP0274 时踩过的七个典型坑4.1 版本协商必须先于一切现象:直接发 GET_CAPABILITIES,对端返回 ERROR 0x01(InvalidRequest)。 原因:SPDM 状态机要求首个消息必须是 GET_VERSION,之前所有交互都会被拒绝。 解决:严格按 GET_VERSION → GET_CAPABILITIES → NEGOTIATE_ALGORITHMS 的顺序执行,不要尝试合并或跳步。我在模拟项目X里加了状态机日志,任何乱序消息都能被定位。4.2 版本响应解析错位现象:解析 VERSION 响应时,拿到的版本列表是乱码。 原因:VersionNumberEntry 是两字节小端排列,我最初按大端读,导致 1.2 读成了 2.1。 解决:统一用BB或int.from_bytes(data, little)解析。后来我给解析函数写了个单元测试,输入已知的字节序列验证输出,再没出过问题。4.3 NEGOTIATE_ALGORITHMS 长度字段回填错误现象:算法协商响应里长度比预期大 2 字节,后续消息全部解析失败。 原因:请求消息里 Length 字段应该算上消息头和所有 payload,我在代码里先填了 body 长度,忘了加 4 字节头。 解决:先构造完整消息,再用总长度回填 Length 字段。这条经验可以写进所有 SPDM 消息构造的通用模板。4.4 CHALLENGE_AUTH 签名验证失败现象:对端返回签名,验签始终失败,但证书链本身没任何问题。 原因:签名消息 transcript 里漏掉了 CHALLENGE 请求自身的哈希。CHALLENGE_AUTH 的签名是对整个握手过程的摘要签名,不是只签随机数。 解决:完整记录从 GET_DIGESTS 之后的每条消息,每次追加到 transcript 缓冲区后重新算哈希。后来我把 transcript 计算抽成公共函数,所有签名验证都走同一入口。4.5 KEY_EXCHANGE 会话 ID 与密钥派生不一致现象:KEY_EXCHANGE 成功,FINISH 里的 MAC 验证失败。 原因:会话密钥派生时把 requester 和 responder 的随机数顺序搞反了,或 HKDF label 拼错。 解决:对照规范逐个检查 HKDF 输入,尤其是 label 字符串尾部必须有 0x00 结束符。调试时在代码里打印握手密钥的十六进制值,和规范附录的测试向量比对。4.6 GET_MEASUREMENTS 返回空块现象:设备声明支持 MEAS_CAP2,但 GET_MEASUREMENTS 返回的块数量为 0。 原因:有些设备只在特定固件状态下才暴露度量值,或者需要先完成 CHALLENGE 认证才允许读取。 解决:在请求前先确认设备状态,必要时先跑完 CHALLENGE 流程再取度量。这条在真实设备上尤其重要,不要假设设备永远处于可度量状态。4.7 ERROR 响应解析不完整导致死循环现象:请求方收到 ERROR 后不检查 ErrorCode 直接重试,导致死循环。 原因:ERROR 消息的 Param1 是错误码,Param2 是错误子码,payload 里还有具体错误数据;实现里只读了头没读 payload。 解决:解析 ERROR 时至少读取 Param1 和 Param2,对已知错误码做分类处理。例如 0x01 是 InvalidRequest, 0x02 是 Busy, 0x03 是 UnexpectedRequest, 0x05 是 SessionRequired。BUSY 时可以延迟重试,其他错误码应该直接中止操作或要求重新协商。5. 进阶:用脚本搭一个最小 SPDM 请求方,把每次握手跑成可视化日志前面的代码是零散的消息构造,这一章把它们串成一个可运行的最小验证工具。这个工具的思路是:用 Python 构造 SPDM 消息、维护状态机和 transcript,同时把每一帧请求与响应打印成可读日志。对做方案选型的人来说,它能替代昂贵的协议分析仪完成初步验证;对做固件开发的人来说,它能当测试脚本用。class SpdmRequester: def __init__(self, transport): self.transport transport self.state IDLE self.transcript bytearray() self.negotiated {} def send(self, msg, description): self.transport.send(msg) resp self.transport.receive() self.transcript msg resp self._log(description, msg, resp) return resp def _log(self, desc, req, resp): print(f[{self.state}] {desc}) print(f REQ: {req.hex()}) print(f RSP: {resp.hex()}) if len(resp) 4: code resp[1] print(f CODE: 0x{code:02X} f({OK if code ! 0x7F else ERROR})) def handshake(self): self.state VERSION resp self.send(build_get_version(), GET_VERSION) self.state CAPABILITIES resp self.send(build_get_capabilities(), GET_CAPABILITIES) self.state ALGORITHMS resp self.send(build_negotiate_algorithms( [(1, 2, 256), (2, 2, 256)]), NEGOTIATE_ALGORITHMS) self.state CERT resp self.send(build_get_digests(), GET_DIGESTS) # 实际实现中需要根据摘要数量循环取证书 resp self.send(build_get_certificate(), GET_CERTIFICATE) self.state CHALLENGE resp self.send(build_challenge(), CHALLENGE) self.state DONE这段代码把整个握手浓缩成了一个 handshake 方法。每次 send 都同步更新 transcript,保证签名验证时使用的数据与对端一致。日志里把响应码 0x7F(ERROR)单独标出来,便于在一个终端里快速定位是哪一步出的问题。跑这个脚本时会注意到一个规律:如果 transport 层是模拟的(比如直接读写一个 socket 或管道),整个握手在毫秒级完成;如果走真实设备,时序上要关注 St1/St2 超时参数。1.2.0 里对 St1(请求方等待响应的最长时间)和 St2(响应方处理请求的最长时间)有明确数值,真实设备调试时最常遇到的问题不是协议错误,而是超时参数设得太紧,导致对端还没来得及返回就被判定为超时。从那以后我每次做 SPDM 联调,都会强制把 St1 先设成规范允许的最大值,等链路稳定了再逐步收紧,这个习惯帮我省掉了一大半的玄学排障时间。希望帮到你。本文还有配套的精品资源点击获取