
Go 后端生成的 ECDSA P-256 签名在 JavaScript 中无法验证,主因是 JS 库隐式二次哈希 + 字符串编码方式不一致;需改用 KJUR.crypto.ECDSA 并对 hex 字符串做 UTF-8 编码,而非 hex 解码。
go 后端生成的 ecdsa p-256 签名在 javascript 中无法验证,主因是 js 库隐式二次哈希 + 字符串编码方式不一致;需改用 `kjur.crypto.ecdsa` 并对 hex 字符串做 utf-8 编码,而非 hex 解码。
在构建跨平台数字签名验证系统(如文件完整性校验)时,常见陷阱并非密钥或算法不匹配,而是签名/验证流程中数据预处理逻辑的隐式差异。本文聚焦一个典型问题:Go 后端使用 crypto/ecdsa 对 SHA-256 哈希值(32 字节十六进制字符串)签名,而前端 JavaScript 使用 jsrsasign 验证失败——即使公钥、签名、哈希内容完全一致。
根本原因有两个,且相互耦合:
1. ❌ 隐式哈希冲突:Signature vs ECDSA
-
KJUR.crypto.Signature({"alg": "SHA256withECDSA"})在调用updateHex()时,会自动对输入执行 SHA-256 哈希。
但你的 Go/Dart 流程中,输入已是SHA-256哈希结果(如"bb5dbf...f942"),若再哈希一次,得到的是SHA256("bb5dbf...f942"),而非原始哈希值本身 → 必然验证失败。 - Dart 的
ECDSASigner(PointyCastle)不执行任何隐式哈希,它直接对传入的字节数组(Uint8List)进行签名运算。因此,JS 端必须跳过哈希步骤,使用底层ECDSA接口。
✅ 正确做法:改用 KJUR.crypto.ECDSA({'curve': 'secp256r1'}),它仅执行纯 ECDSA 运算,无隐式摘要。
2. ❌ 字节序列不一致:Hex 解码 vs UTF-8 编码
- 你的 Dart 代码中:
utf8.encode(hash)将 hex 字符串(如"bb5dbf...f942",64 字符)按 UTF-8 编码为 64 字节 数组。 - 而原 JS 代码中:
sig.updateHex(hashData)会将 hex 字符串解码为原始字节(即把"bb5dbf..."解为 32 字节二进制哈希)。这导致两端输入字节完全不等价。
⚠️ 注意:ECDSA P-256 规范(FIPS 186-5 §6.4)规定,若消息长度 > 32 字节,仅取前 32 字节参与签名运算。Dart 的 UTF-8 编码产生 64 字节,实际只用了前 32 字节(即 "bb5dbf...f9" 的 UTF-8 编码前半部分),而 Go 签名时也仅处理了原始 32 字节哈希 —— 这恰好“巧合”地达成一致。但这是危险的实现依赖,不应作为设计依据。
✅ 正确做法:JS 端也对 hex 字符串做 UTF-8 编码,保持与 Dart 完全一致:
// ✅ 正确:UTF-8 编码 hex 字符串(64 字节) const msgHashUtf8 = new TextEncoder().encode(hashString); // hashString = "bb5dbf...f942" const msgHashHex = ArrayBuffertohex(msgHashUtf8.buffer); // ❌ 错误:hex 解码(得到 32 字节原始哈希) // const msgHashBytes = hexToBytes(hashString); // const msgHashHex = ArrayBuffertohex(msgHashBytes.buffer);
✅ 完整修复后的 JS 验证函数
async function validateSignature(keyPem, signatureHex, hashString) {
try {
// 1. 导入公钥并提取 uncompressed SEC1 格式(04 + x + y)
const pubKey = KEYUTIL.getKey(keyPem);
const { x, y } = pubKey.getPublicKeyXYHex();
const pubKeyHex = '04' + x + y; // secp256r1 / P-256 兼容格式
// 2. 将 hex 字符串 UTF-8 编码后转 hex(关键!)
const encoder = new TextEncoder();
const hashUtf8Bytes = encoder.encode(hashString); // 64-byte Uint8Array
const hashHex = Array.from(hashUtf8Bytes, b => b.toString(16).padStart(2, '0')).join('');
// 3. 使用 ECDSA 实例验证(无隐式哈希)
const ec = new KJUR.crypto.ECDSA({ curve: 'secp256r1' });
return ec.verifyHex(hashHex, signatureHex, pubKeyHex);
} catch (e) {
console.error('Signature validation failed:', e);
return false;
}
}⚠️ 重要注意事项
-
不要修复 Go 签名逻辑:Go 的
ecdsa.Sign直接作用于 32 字节哈希,是标准且安全的。问题在 JS 验证端未对齐。 -
公钥格式必须为 SPKI:确保 Go 生成的公钥是
-----BEGIN PUBLIC KEY-----(PKIX),而非-----BEGIN EC PUBLIC KEY-----(SEC1)。后者需转换,可用KEYUTIL.getKey(pem).getPublicKeyXYHex()自动兼容。 -
避免 Web Crypto API 的坑:
SubtleCrypto.verify()要求签名是 DER 编码的 ASN.1 序列(你已满足),但输入消息必须是原始哈希字节(非 hex 字符串)。若选用 Web Crypto,需手动hexToBytes(hashString)并verify(..., hashBytes, signatureBytes)—— 但jsrsasign方案更直观。 - 长期建议:后端签名时应明确约定输入为“原始哈希字节”,前端统一 hex 解码后再验证,消除 UTF-8 编码歧义。当前方案是兼容性修复,非最佳实践。
通过以上两处关键修正,Go 生成的 ECDSA 签名即可在浏览器中 100% 验证通过,实现移动 App 与 Web 端一致的安全验证能力。

















