4. 后量子密码部署排障:ML-DSA 签名验证失败与上下文串不一致
故障现象与定位思路
应用层调用 ML-DSA 做签名与验签时,签名接口返回成功,但验签接口稳定返回失败(例如 liboqs 的 OQS_SIG_verify 返回非 0,或 OpenSSL 的 EVP_DigestVerify 返回 0)。这类失败在 ML-DSA 部署中非常典型,因为 ML-DSA 的验签输入不只是「消息 + 签名 + 公钥」,还隐含了 context string 与 消息预处理方式 两个容易在两端不一致的因素。
ML-DSA(FIPS 204)在纯签名模式下,签名对象是 M' = 0x00 || len(ctx) || ctx || M,其中 ctx 是长度 0–255 字节的上下文串。签名方与验签方必须使用完全相同的 ctx 字节序列,否则即使消息和密钥都正确,验签也必然失败。另一个高频原因是:一方把消息先做了一次 SHA-256 再传入签名接口,而另一方直接传原始消息,导致实际被签名的字节不同。
复现与排查步骤
下面用 liboqs 的 C 接口构造一个最小可复现示例。示例中故意让签名方使用 "app-v1"、验签方使用 "app-v1\0"(多了一个 NUL 字节),并让验签方对消息多做一次 SHA-256,从而同时触发两类不一致。示例参数与结果均为教学构造,用于演示定位方法。
#include <oqs/oqs.h>
#include <openssl/sha.h>
#include <stdio.h>
#include <string.h>
int main(void) {
OQS_SIG *sig = OQS_SIG_new(OQS_SIG_alg_ml_dsa_65);
if (!sig) { printf("alg unavailable\n"); return 1; }
uint8_t pk[OQS_SIG_ml_dsa_65_length_public_key];
uint8_t sk[OQS_SIG_ml_dsa_65_length_secret_key];
uint8_t sm[OQS_SIG_ml_dsa_65_length_signature];
size_t smlen = 0;
OQS_SIG_keypair(sig, pk, sk);
const char *msg = "transfer:100:alice";
size_t mlen = strlen(msg);
/* 签名方 ctx:7 字节 "app-v1" */
const uint8_t ctx_sign[] = {'a','p','p','-','v','1'};
size_t ctx_sign_len = sizeof(ctx_sign);
if (OQS_SIG_sign_with_ctx_str(sig, sm, &smlen,
(const uint8_t*)msg, mlen, ctx_sign, ctx_sign_len, sk) != OQS_SUCCESS) {
printf("sign failed\n"); return 1;
}
/* 验签方 ctx:多了一个 NUL,共 8 字节 */
const uint8_t ctx_verify[] = {'a','p','p','-','v','1','\0'};
size_t ctx_verify_len = sizeof(ctx_verify);
/* 验签方错误地对消息做了 SHA-256 */
uint8_t digest[32];
SHA256((const uint8_t*)msg, mlen, digest);
OQS_STATUS rc = OQS_SIG_verify_with_ctx_str(sig, (const uint8_t*)msg, mlen,
sm, smlen, ctx_verify, ctx_verify_len, pk);
printf("case A (ctx 不一致, 原始消息): %s\n",
rc == OQS_SUCCESS ? "PASS" : "FAIL");
rc = OQS_SIG_verify_with_ctx_str(sig, digest, sizeof(digest),
sm, smlen, ctx_sign, ctx_sign_len, pk);
printf("case B (ctx 一致, 二次哈希): %s\n",
rc == OQS_SUCCESS ? "PASS" : "FAIL");
rc = OQS_SIG_verify_with_ctx_str(sig, (const uint8_t*)msg, mlen,
sm, smlen, ctx_sign, ctx_sign_len, pk);
printf("case C (ctx 一致, 原始消息): %s\n",
rc == OQS_SUCCESS ? "PASS" : "FAIL");
OQS_SIG_free(sig);
return 0;
}
预期输出(教学构造,非实测):case A 与 case B 均为 FAIL,case C 为 PASS。这一组对照把两类原因分离验证:A 只改 ctx,B 只改消息预处理,C 全部对齐。若 A 通过而 B 失败,说明 ctx 一致但消息被二次处理;若 A 失败而 B 通过,说明 ctx 不一致但消息处理一致;两者都失败则需同时修正。
修正接口调用
修正的核心是让两端对「ctx 字节」和「消息字节」达成同一约定,并把约定固化到接口封装中,避免调用方各自拼装。
- ctx 统一为显式字节数组:不要用
strlen推导长度,也不要把 C 字符串字面量直接当 ctx 传入(字面量末尾隐含\0)。用sizeof或显式长度常量。 - ctx 长度上限 255 字节:超长会被截断或报错,需在封装层校验。
- 消息只预处理一次:明确约定「签名接口接收原始消息」,若业务需要摘要,则在签名前统一做一次,验签前做同样一次,且记录在协议文档中。
- 版本化 ctx:把
"app-v1"这类标识写入配置,签名与验签共用同一配置源,避免硬编码漂移。
/* 统一封装:ctx 与消息预处理只在此处定义 */
#define APP_CTX ((const uint8_t*)"app-v1")
#define APP_CTX_LEN 6 /* 不含结尾 NUL */
static OQS_STATUS app_sign(OQS_SIG *sig, uint8_t *sigbuf, size_t *siglen,
const uint8_t *msg, size_t mlen,
const uint8_t *sk) {
return OQS_SIG_sign_with_ctx_str(sig, sigbuf, siglen,
msg, mlen, APP_CTX, APP_CTX_LEN, sk);
}
static OQS_STATUS app_verify(OQS_SIG *sig, const uint8_t *msg, size_t mlen,
const uint8_t *sigbuf, size_t siglen,
const uint8_t *pk) {
return OQS_SIG_verify_with_ctx_str(sig, msg, mlen,
sigbuf, siglen, APP_CTX, APP_CTX_LEN, pk);
}