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 再传入签名接口,而另一方直接传原始消息,导致实际被签名的字节不同。

签名方(Signer) 消息 M 原始字节 ctx = "app-v1" 7 字节 M' = 0x00 ‖ len(ctx) ‖ ctx ‖ M ML-DSA.Sign(sk, M', ctx) 验签方(Verifier) 消息 M 原始字节 ctx = "app-v1" 实际传入 "app-v1\0" M' = 0x00 ‖ len(ctx) ‖ ctx ‖ M ML-DSA.Verify(pk, M', sig, ctx) 同一签名 验签失败:ctx 字节不一致 len(ctx) 与 ctx 内容均参与 M' 构造 第二类不一致:消息被二次哈希 签名方:Sign(M) 直接签原始消息 验签方:Verify(SHA256(M)) 多算了一次摘要 M' 不同 → 验签失败 与 ctx 无关

复现与排查步骤

下面用 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);
}
排查顺序建议:先固定消息与 ctx 做一次「同端自签自验」基线,确认算法与密钥可用;再逐项替换为对端参数,每次只改一个变量,用上面的 A/B/C 对照法定位。这样能把「ctx 不一致」与「二次哈希」两类原因彻底分开,而不是反复猜测。
不要把 ctx 当作可选的「附加信息」随意省略。FIPS 204 中 ctx 参与 M' 构造,省略与传入空串在字节层面等价,但与传入非空 ctx 不等价;跨语言、跨库调用时尤其要核对长度语义(是否含结尾 NUL、是否按 UTF-8 字节计)。