STM32H573 Secure Manager密钥生成-129错误排查与修复
最近在 STM32H573 上调一个安全存储相关的功能,卡在了一个非常不起眼的地方:psa_generate_key()对易失性 ECC/AES 密钥一直返回PSA_ERROR_NOT_PERMITTED,也就是 -129。这个错误对做嵌入式固件的人来说太有迷惑性了——你的第一反应肯定是密钥策略写错了,于是翻来覆去检查 usage flags、algorithm、lifetime,发现全都符合 PSA 规范,代码在别的平台上也能跑通,偏偏在 H573 的 Secure Manager 环境里就是不行。
这篇文章把我整个排查过程、最终定位的根因、以及正确的密钥生成配置完整记录一遍。如果你正在 STM32H573 上做安全启动、安全存储、TLS 密钥管理,或者准备把应用从裸 PSA Crypto 迁移到 Secure Manager 环境,这篇可以直接当避坑参考。
1. 问题现场:Secure Manager 环境下的 -129 报错全貌
1.1 硬件与软件背景
先说环境。我用的主控是 STM32H573,这芯片最大的卖点之一就是出厂内置了 Secure Manager,跑在 Cortex-M33 的安全世界里,对外提供 PSA Certified 级别的安全服务:安全存储、密码学运算、密钥管理、设备认证等。非安全侧的应用代码通过 Secure Manager 提供的 veneer 接口调用 PSA Crypto API,密钥的实际生成和管理都发生在安全侧隔离环境里。
软件侧我使用 STM32CubeH5 固件包里的 Secure Manager 相关组件,编译链接了官方提供的 NS(Non-Secure)侧库。应用本身跑在非安全世界,业务逻辑很简单:系统启动后,动态生成一把 ECC P-256 密钥对用于 TLS 客户端证书协商,再生成一把 AES-128 密钥用于通信数据加密。
问题就出在密钥生成这一步。无论 ECC 还是 AES,只要密钥生命周期设置为易失性(volatile),psa_generate_key()就稳定返回 -129。改成持久性密钥(persistent),有些场景能过去,有些场景仍然报错,但报错方式又不一样。
1.2 最小复现代码
当时复现问题的代码非常标准,基本就是从 PSA Crypto 标准示例里抄过来的:
#include "psa/crypto.h" static psa_status_t generate_volatile_ecc_key(psa_key_id_t *out_key_id) { psa_status_t status = psa_crypto_init(); if (status != PSA_SUCCESS) { return status; } psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; psa_set_key_type(&attr, PSA_KEY_TYPE_ECC_KEY_PAIR(PSA_ECC_FAMILY_SECP_R1)); psa_set_key_bits(&attr, 256); psa_set_key_lifetime(&attr, PSA_KEY_LIFETIME_VOLATILE); psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_SIGN | PSA_KEY_USAGE_VERIFY); psa_set_key_algorithm(&attr, PSA_ALG_ECDSA(PSA_ALG_SHA_256)); status = psa_generate_key(&attr, out_key_id); psa_reset_key_attributes(&attr); return status; }psa_generate_key()返回后,我把它打印出来:
psa_status_t status = generate_volatile_ecc_key(&key_id); printf("psa_generate_key status = 0x%08x (%d)\r\n", (uint32_t)status, (int32_t)status);输出是:
psa_generate_key status = 0xffffff7f (-129)0xffffff7f强转成int32_t就是 -129,对应PSA_ERROR_NOT_PERMITTED。AES 密钥复现方式一模一样:
psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; psa_set_key_type(&attr, PSA_KEY_TYPE_AES); psa_set_key_bits(&attr, 128); psa_set_key_lifetime(&attr, PSA_KEY_LIFETIME_VOLATILE); psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_ENCRYPT | PSA_KEY_USAGE_DECRYPT); psa_set_key_algorithm(&attr, PSA_ALG_CCM); status = psa_generate_key(&attr, &key_id);结果一样是 -129。
1.3 错误码怎么读:-129 不是一个常规参数错误
这里先提一个排查时容易忽略的细节。PSA Crypto 规范里的错误码是负数,但在不同的编译环境下打印出来长得不一样:
- 以
int32_t打印,-129 就是-129; - 以
uint32_t或者psa_status_t输出,看到的是0xFFFFFF7F; - 如果代码里不小心把
psa_status_t截断成int8_t,你会看到0x7F,也就是 127,完全没法判断。
所以排查的第一步,先确认你的打印逻辑没有把错误码截断。我见过不少人在论坛上问"为什么返回 127",其实就是 -129 被int8_t截断了。建议统一写成:
printf("status = %d (0x%08x)\r\n", (int32_t)status, (uint32_t)status);这样有符号、无符号两种形态都看得明白。
2. PSA_ERROR_NOT_PERMITTED 到底在拒绝什么
2.1 PSA 标准语义
按 PSA Crypto API 规范,psa_generate_key()返回PSA_ERROR_NOT_PERMITTED的场景主要有两类:
- 调用者没有执行该操作的权限;
- 密钥属性里配置的策略不允许该操作,典型情况是 usage flags 与算法不匹配,或者试图对一个策略受限的密钥做超出策略范围的事。
这两类语义一个是"你是谁",一个是"你想干的事合不合规"。注意它跟PSA_ERROR_INVALID_ARGUMENT的区别:参数错误是说"你传的东西本身就非法",而 NOT_PERMITTED 是说"东西合法,但你没权限或者策略不允许"。
2.2 Secure Manager 的"双重校验"
STM32H573 的 Secure Manager 不是一个简单的密码学函数库,它底层是一个基于 ARM TrustZone + 安全分区管理的完整安全服务框架。非安全侧调用psa_generate_key()时,实际路径是:
NS 应用 → veneer 接口 → SPM 安全分区管理 → Secure Manager Crypto 服务 → 密钥策略检查 → 生成密钥这条链路里至少有两层策略判断:
- 第一层是 SPM 层面对"调用者身份"的检查。非安全侧应用必须先建立合法的 PSA Client 连接,SPM 才会把请求路由到 Crypto 安全分区。如果这一步没通过,返回的基本就是
PSA_ERROR_NOT_PERMITTED。 - 第二层才是 Crypto 分区内部的密钥策略检查。Secure Manager 里的安全策略可能比通用 TF-M 更严格,或者说,它会把某些在标准 PSA 里属于"不支持"的情况也归并成"不允许"。
所以同样是 -129,在普通 MCU 上跑 TF-M 和在 H573 上跑 Secure Manager,根因可能完全不同。这也是我最开始被误导的原因:我一直在改密钥属性,结果真正的问题在服务访问层。
2.3 为什么这个问题容易误判
我复盘下来,误判有三个原因:
第一,PSA_ERROR_NOT_PERMITTED这个名字太容易让人联想到"权限位"。大家习惯性去看 usage flags,很少有人第一时间怀疑是底层服务连接的问题。
第二,AES 和 ECC 同时失败,反而让我觉得"不是密钥类型的问题",于是专注在共同点上——易失性生命周期、usage flags、algorithm。这三个恰恰是最容易出问题的点,结果绕了一圈。
第三,Secure Manager 的报错不是特别细。很多内部校验失败都被统一折叠成 -129,你没法从错误码本身猜出是哪一层拒的。这时候只能靠逐层剥离验证来定位,而不是盯着错误码猜。
3. 排查链路:从密钥策略到服务访问逐层剥离
3.1 第一层:属性策略逐字段核对
先把最基础的 PSA 属性检查一遍,排除"策略本身就不合规"的情况。我当时做了一轮代码走查,几个容易出问题的字段逐一确认:
| 字段 | 正确要求 | 我当时的配置 | 结论 |
|---|---|---|---|
| key type | ECC 密钥对要带曲线族 | PSA_KEY_TYPE_ECC_KEY_PAIR(PSA_ECC_FAMILY_SECP_R1) | 正确 |
| key bits | P-256 对应 256 | psa_set_key_bits(&attr, 256) | 正确 |
| lifetime | 易失性密钥用PSA_KEY_LIFETIME_VOLATILE | PSA_KEY_LIFETIME_VOLATILE | 正确 |
| usage flags | ECC 签名需要PSA_KEY_USAGE_SIGN | PSA_KEY_USAGE_SIGN | PSA_KEY_USAGE_VERIFY | 正确 |
| algorithm | ECDSA 需要指定哈希 | PSA_ALG_ECDSA(PSA_ALG_SHA_256) | 正确 |
肉眼看着全对。但我还是做了两件事:第一,把psa_key_attributes_t结构体在调用前后完整 dump 出来,确认每个字段真是我们设置的值;第二,把 usage flags 精简到最小集合,只留PSA_KEY_USAGE_SIGN,排除"flags 过多导致的策略冲突"。
结果:仍然 -129。这一层基本可以排除"代码里属性设置错误"。
这里还有个细节值得提醒:psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT;这条初始化语句不能省。如果像裸机代码那样声明一个局部变量不初始化,结构体里的 lifetime、usage、algorithm 字段全是栈上的随机值,Secure Manager 解析策略时可能直接判定为"策略非法/不允许",而且报的很可能就是 NOT_PERMITTED 而不是 INVALID_ARGUMENT。我当时确认了初始化没写错,才敢往下走。
3.2 第二层:易失性与持久性分治
既然易失性密钥失败,那就试试持久性密钥,用来判断问题是不是出在"易失性"这个属性上。
psa_set_key_lifetime(&attr, PSA_KEY_LIFETIME_PERSISTENT); psa_set_key_id(&attr, 0x00000001); // 持久性密钥需要显式指定 key id结果很有意思:持久性 AES 密钥能生成成功,持久性 ECC 密钥也能生成成功。这意味着 Secure Manager 的 Crypto 服务本身是可用的,密钥类型也支持,问题被进一步压缩到"易失性密钥路径"这一个分支。
我当时的第一个猜测是:Secure Manager 的默认配置里,非安全侧可能不允许创建易失性密钥,或者对易失性密钥有额外的使用限制。为了验证这个猜测,我把密钥生成函数搬到安全侧示例工程里跑——结果安全侧生成易失性密钥完全正常。
这就很奇怪了。同一把 Secure Manager,安全侧能生成易失性密钥,非安全侧不行。说明问题不在 Crypto 服务本身,而在非安全侧调用链路。
3.3 第三层:SM 服务初始化与 veneer 库
回头看调用链路的起点:非安全侧应用是通过 Secure Manager 的 veneer 接口发起调用的。Secure Manager 在非安全侧有一个配套的接口库,里面包含 PSA API 到 SPM IPC 的转封装,以及启动时与安全侧握手初始化的逻辑。
我检查工程配置时发现一个问题:我的工程链接了 Secure Manager 的 NS 接口库,但代码里完全没有调用 Secure Manager 的启动/握手接口,直接上来就psa_crypto_init()然后psa_generate_key()。psa_crypto_init()在 H573 的 Secure Manager 环境里并不是一个纯软件初始化,它底层要跟安全侧的 Crypto 服务建立 PSA Client 连接。如果 Secure Manager 的服务没有先就绪,或者 NS 侧没有完成必要的初始化步骤,psa_generate_key()请求到了 SPM 层就会被判定为"非授权调用"。
对照 ST 官方例程后确认:H573 的 Secure Manager 非安全侧例程里,main 函数最开始有一段 Secure Manager 初始化流程,把安全侧服务准备好之后,才会进入业务逻辑。我把这段初始化补上,再跑:
/* 伪代码:Secure Manager NS 侧初始化 */ SM_Init(); // 建立与安全侧通信的基础环境 psa_crypto_init(); // 初始化 PSA Crypto 客户端补完初始化之后,AES 易失性密钥生成立刻正常了。但 ECC 仍然报 -129。
也就是说,问题分成了两段:第一段是服务初始化/握手导致的整体拒绝,第二段是 ECC 密钥策略在 Secure Manager 侧的额外限制。
3.4 第四层:ECC 的 export 策略与算法歧义
把 ECC 属性逐个字段做二分排除后,最终定位到PSA_KEY_USAGE_EXPORT这个 flag 上。
我当时在 ECC 密钥的 usage flags 里加上了PSA_KEY_USAGE_EXPORT,理由很朴素:TLS 握手阶段需要导出公钥发给对端。在通用 PSA 实现里,公钥导出通常被PSA_KEY_USAGE_EXPORT覆盖,加上没毛病。但 Secure Manager 对 ECC 密钥对的管理更严格:由安全侧生成的 ECC 私钥默认不允许导出,即使你在 usage flags 里显式要求 EXPORT,Secure Manager 的策略引擎也会直接拒绝整个密钥生成请求,返回PSA_ERROR_NOT_PERMITTED。
这在语义上是说得通的:Secure Manager 的核心卖点就是"私钥永远不离开安全世界",如果你在策略里声明要导出私钥,它干脆连生成都不让你生成。PSA 规范里并没有强制要求实现方必须允许所有 usage 组合,Secure Manager 选择了更保守的策略。
把PSA_KEY_USAGE_EXPORT从 ECC 密钥的 usage flags 里去掉,保留PSA_KEY_USAGE_SIGN | PSA_KEY_USAGE_VERIFY,再次调用:
psa_generate_key status = 0 (0)ECC 易失性密钥生成通过。至此,两个问题都定位到了。
4. 根因确认与修复:ECC/AES 易失性密钥生成的正解
4.1 修复后的 ECC 密钥生成
最终确认可用的 ECC P-256 易失性密钥生成代码如下:
static psa_status_t generate_volatile_ecc_key(psa_key_id_t *out_key_id) { psa_status_t status = psa_crypto_init(); if (status != PSA_SUCCESS) { return status; } psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; psa_set_key_type(&attr, PSA_KEY_TYPE_ECC_KEY_PAIR(PSA_ECC_FAMILY_SECP_R1)); psa_set_key_bits(&attr, 256); psa_set_key_lifetime(&attr, PSA_KEY_LIFETIME_VOLATILE); psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_SIGN | PSA_KEY_USAGE_VERIFY); psa_set_key_algorithm(&attr, PSA_ALG_ECDSA(PSA_ALG_SHA_256)); status = psa_generate_key(&attr, out_key_id); psa_reset_key_attributes(&attr); return status; }两个关键点:
- 不要加
PSA_KEY_USAGE_EXPORT。如果后续业务需要在网络报文中携带公钥,用psa_export_public_key()单独导出公钥,而不是通过PSA_KEY_USAGE_EXPORT去"解锁"私钥的导出权限。 PSA_ALG_ECDSA(PSA_ALG_SHA_256)这种算法写法是推荐做法。有些代码里写PSA_ALG_ECDSA_BASE或PSA_ALG_ECDSA_ANY,在 Secure Manager 某些版本里会被当成"策略不完整"而拒绝。显式指定哈希算法,策略最清晰,踩坑最少。
4.2 修复后的 AES 密钥生成
AES 部分相对简单,修复后的代码:
static psa_status_t generate_volatile_aes_key(psa_key_id_t *out_key_id) { psa_status_t status = psa_crypto_init(); if (status != PSA_SUCCESS) { return status; } psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; psa_set_key_type(&attr, PSA_KEY_TYPE_AES); psa_set_key_bits(&attr, 128); psa_set_key_lifetime(&attr, PSA_KEY_LIFETIME_VOLATILE); psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_ENCRYPT | PSA_KEY_USAGE_DECRYPT); psa_set_key_algorithm(&attr, PSA_ALG_CCM); status = psa_generate_key(&attr, out_key_id); psa_reset_key_attributes(&attr); return status; }如果 AES 密钥后续要用于 GCM、CTR 或者 CBC,psa_set_key_algorithm()里改成对应的PSA_ALG_GCM、PSA_ALG_CTR、PSA_ALG_CBC_NO_PADDING即可。但注意:usage flags 要跟算法匹配。比如使用PSA_ALG_AEAD系的算法,既要ENCRYPT又要DECRYPT就都要写上;如果只做加密,只写PSA_KEY_USAGE_ENCRYPT反而更安全。
4.3 生成后的验证闭环
密钥生成之后不能只看返回值,必须做一次闭环验证,确认这把密钥真的能用。推荐做法是立刻用生成的密钥跑一次与该密钥算法匹配的操作,比如 ECC 密钥做一次psa_sign_message()/psa_verify_message(),AES 密钥做一次对称加解密:
static psa_status_t verify_ecc_key_works(psa_key_id_t key_id) { uint8_t message[32] = {0}; uint8_t signature[64] = {0}; size_t sig_len = 0; psa_status_t status; status = psa_sign_message(key_id, PSA_ALG_ECDSA(PSA_ALG_SHA_256), message, sizeof(message), signature, sizeof(signature), &sig_len); if (status != PSA_SUCCESS) { return status; } status = psa_verify_message(key_id, PSA_ALG_ECDSA(PSA_ALG_SHA_256), message, sizeof(message), signature, sig_len); return status; }AES 的验证类似,用psa_cipher_encrypt()/psa_cipher_decrypt()或者直接跑一个 AEAD 加解密。这一步能帮你确认:生成的密钥不仅在 Secure Manager 里"存在",而且真正能参与业务运算。
5. 工程复盘:Secure Manager 开发值得注意的几个点
5.1 例程先行,别直接照抄通用 PSA 代码
STM32H573 的 Secure Manager 虽然对外暴露的是标准 PSA Crypto API,但它毕竟是一个独立的安全服务产品,不是所有标准 PSA 行为都被 100% 继承。开发前最好先跑一遍 ST 官方提供的 Secure Manager 例程,尤其是"密钥生成 + 安全存储 + 认证"那个组合例程,确认你手上的 Secure Manager 固件版本和 NS 接口库版本,再动自己的业务代码。
我当时如果先跑例程,就不会在 EXPORT 这个 flag 上浪费那么多时间——例程里的 ECC 密钥生成几乎没有加 EXPORT 的。这属于"看了文档但没看例程"的典型教训。
5.2 密钥策略要"够用就好"
经过这次踩坑,我把密钥策略的设计原则改成了"最小授权":
- 不用的 usage flag 一律不设;
- 不导出的私钥坚决不写
PSA_KEY_USAGE_EXPORT; - 算法尽量显式指定,少用
_ANY、_BASE这类宽松写法; - 易失性密钥用途明确后,及时
psa_destroy_key(),不让密钥生命周期拖到系统重启。
尤其是 ECC 私钥,Secure Manager 的设计目标就是保护私钥不离开安全世界。你在策略里声明 EXPORT,等于跟安全模型对着干,被拒绝是合理的。搞清楚这个设计意图之后,很多看似"不合理"的返回码其实都能解释通。
5.3 调试时把错误码打印做扎实
这次排查里最花时间的不是代码逻辑,而是错误码打印不完整。Secure Manager 的错误码链路长,返回值在不同层会被折叠、透传,光看一个 -129 很难判断是哪一层的问题。
建议在应用里封装一个统一的 PSA 状态打印函数:
const char *psa_status_str(psa_status_t status) { switch (status) { case PSA_SUCCESS: return "PSA_SUCCESS"; case PSA_ERROR_NOT_PERMITTED: return "PSA_ERROR_NOT_PERMITTED"; case PSA_ERROR_INVALID_ARGUMENT: return "PSA_ERROR_INVALID_ARGUMENT"; case PSA_ERROR_NOT_SUPPORTED: return "PSA_ERROR_NOT_SUPPORTED"; case PSA_ERROR_BAD_STATE: return "PSA_ERROR_BAD_STATE"; case PSA_ERROR_COMMUNICATION_FAILURE: return "PSA_ERROR_COMMUNICATION_FAILURE"; default: return "UNKNOWN"; } } #define PSA_CHECK(expr) do { \ psa_status_t s_ = (expr); \ if (s_ != PSA_SUCCESS) { \ printf("%s failed: %s (%d)\r\n", #expr, psa_status_str(s_), (int32_t)s_); \ return s_; \ } \ } while (0)排查时可以只关注状态字符串,不用每次对着表格查 -129 到底是谁。
5.4 Secure Manager 开发的方向性建议
最后说点工程上的体会。Secure Manager 这类方案的价值在于:安全能力是出厂预置的,应用开发只需要关注非安全侧业务,不用自己维护安全固件和密钥分区。但它也带来了新的约束——你失去了对安全侧实现的完全控制,必须接受它定义的策略边界。比如"私钥不可导出"、"某些 usage 组合被拒绝"、"易失性密钥受调用上下文限制"等,都属于这类边界。
我的建议是:在项目早期把所有要用的 PSA API 都列出来,逐个在目标 Secure Manager 版本上跑一遍冒烟测试,把"哪些写法会被拒绝"提前摸清楚。这比写到一半再来查 -129 高效得多。
另外,不同版本的 Secure Manager 固件行为可能有差异。如果碰到文档和实测不一致的情况,优先看 STM32CubeH5 固件包里的 Release Notes 和 Secure Manager 集成指南,里面的已知限制列表往往藏着答案。这次 ECC 私钥导出限制,在最新版文档的安全策略章节里就有明确描述,只是平时不容易注意到。
