
1. 从一个真实需求说起为什么金融场景里 PIN 加密不能随便写我在做银行卡相关系统的几年里遇到过不少刚接触这块的同事第一反应都是“不就是把密码用 SM4 加密一下吗直接 AES 换 SM4 不就完了”真这么干上线那天大概率要出事。先说清楚一个概念PINPersonal Identification Number是银行卡密码的行业统称不是某个具体算法的输出。它指的是持卡人输入的那 4 到 12 位数字在金融系统里流转时必须加密而且加密格式必须符合银联、PBOC 或者国际卡组织的规定。随便拿 SM4 把 PIN 字符串塞进去加密出来的密文谁也解不开因为对方严格按照 ANSI X9.8 格式来拆包。这篇文章围绕“银行卡密码键盘 SM4 ECB ANSI X9.8带主账号信息PIN 加解密示例”展开核心是解决三个问题SM4 在 PIN 加密场景里到底怎么用和普通文件加密有什么区别ANSI X9.8 格式里的“带主账号信息”是什么意思为什么要带上那 12 位账号从密码键盘到后台解密完整的一条链路该怎么设计、怎么验证明文对不对。适合的人群很明确做金融支付终端开发的、写银行前置系统的、做 ATM/自助设备软件外包的还有刚入行没多久但被 PIN 加密需求砸中的朋友。下面所有代码和步骤我都按自己能直接跑通的标准来写不搞花架子。2. 三个必须拆开理解的概念SM4、ECB 模式、ANSI X9.82.1 SM4 只是算法不是格式SM4 是国密对称分组算法分组长度 128 位密钥长度也是 128 位也就是 16 字节。它和 AES 的工作方式很像但算法结构完全不同不能用 AES 的库“改个名”来假装支持国密必须用支持 SM4 的密码库。ECB 模式是分组密码最简单的运行模式每个分组独立加密明文分组相同则密文分组相同。在 PIN 加密这种场景下明文往往只有 8 字节一个分组就装下了所以 ECB 模式完全够用。网上很多人一看到 ECB 就喊不安全那是因为加密大段数据时 ECB 会泄露明文模式但在 PIN 加密场景里每个 PIN 块是独立构造的而且每次还会带随机因子或者账号信息所以不存在“相同明文块”被批量利用的问题。2.2 ANSI X9.8 标准到底规定了什么ANSI X9.8 是金融领域 PIN 加密格式的核心标准也常被称作 ISO 9564-1 格式 0 或格式 1。它定义了 PIN 明文块PIN Block的构造方式。最常用的格式如下字段项长度说明控制字段1 字节高 4 位固定为 0低 4 位表示 PIN 长度PIN 明文N 字节每字节存储 2 位十进制数字BCD 编码填充字段剩余字节补 0xF填满整字节块比如 PIN 是123456长度 6 位那么明文块前 1 字节是0x06后面跟着0x12 0x34 0x56剩下字节全部填0xFF凑满 8 字节最终 PIN Block 是06 12 34 56 FF FF FF FF如果不带主账号信息直接拿这个块去 SM4 加密就是ANSI X9.8 格式 0无主账号。但金融交易里更常见的是“带主账号信息”的格式也就是题目里说的那一种。2.3 “带主账号信息”到底带的是什么怎么带带主账号信息指的是把银行卡号PANPrimary Account Number参与进 PIN Block 的构造过程通常是拿 PAN 的最右 12 位数字不含校验位时就是右 12 位含校验位时一般也只取右 12 位不足 12 位时左补 0。然后用这个 12 位账号数字构造一个 8 字节的因子块前 1 字节填0x00后面按 BCD 编码填充账号6 字节存 12 位数字最后一字节填0x00。假设 PAN 是6225881234567890最右 12 位是881234567890那么因子块就是00 88 12 34 56 78 90 00重点来了加密前的 PIN Block 并不是直接拿 PIN 去异或而是先构造不带账号的 PIN 明文块再和这个账号因子块按字节异或异或结果才作为 SM4 的明文输入。还是用 PIN123456举例不带账号的原始块是06 12 34 56 FF FF FF FF和因子块00 88 12 34 56 78 90 00逐字节异或后得到06 9A 26 62 A9 87 6F FF这个结果才拿去 SM4 加密。解密端收到 SM4 密文后先解密得到上面的块再和同样的账号因子块异或还原出06 12 34 56 FF FF FF FF最后按控制字段取出 PIN 长度再把后面的 BCD 数字还原成明文 PIN。这个过程里账号参与的价值在于同一位用户在不同账号下即使 PIN 相同产生的密文也不同可以防止攻击者在已知部分明密文的情况下构造碰撞同时避免跨账号重放。3. 密码键盘内部发生了什么从按键到密文的完整链路很多人以为“密码键盘加密”就是终端把用户按下的数字直接传给后台后台再加密这是最大的误解。真实流程里明文 PIN 在密码键盘内部就完成了加密之后任何环节都不应该再出现明文 PIN。我用一款常见的 PCI 密码键盘来举例实际产品和型号差异较大但逻辑几乎一致3.1 第一阶段终端发起“取随机数”后台系统有一个叫“工作密钥”PIN Working Key的密钥存储在硬件加密机HSM或软件安全模块中。终端要先向后台要一个随机数这个随机数通常是后台生成的一段 8 字节或 16 字节随机数据用于参与后续的密钥分散或 MAC 计算这里不过多展开重点是终端需要拿到后台返回的随机数才能进入 PIN 输入流程。3.2 第二阶段终端下发密钥索引密码键盘内部持有若干套密钥每套密钥在键盘管理后台注册时有一个索引号。终端在用户刷卡后会把卡号信息和当前要用的密钥索引一起发给密码键盘。密码键盘根据索引找到对应的 SM4 加密密钥。3.3 第三阶段密码键盘构造 PIN Block 并加密用户输入 PIN 并按下确认键后密码键盘在安全芯片内部完成从键盘缓冲区读取明文 PIN根据卡号最右 12 位构造账号因子块构造不带账号的 PIN 明文块填入控制字段和 BCD 数字剩余补 F逐字节异或用 SM4 ECB 模式加密把密文输出给终端同时清除内部明文缓冲区。密钥索引和密文一起由终端组装成交易报文发给后台。明文 PIN 从始至终没有离开过密码键盘的安全边界。3.4 第四阶段后台解密校验后台收到密文后调用 HSM 或软件密码模块使用对应的工作密钥 SM4 解密得到异或后的块再拿账号因子块还原出原始 PIN Block最后解析 PIN 长度和 BCD得到明文 PIN用于和发卡行侧的 PIN 校验。一条链路下来口令键盘、终端、后台三方各司其职安全性依赖硬件安全边界和密钥管理而不是靠“信不信终端”。4. 后端解密模块的完整实现Java 代码示例下面给出一段我实际在项目中用过的 Java 解密示例。项目里用了 Bouncy Castle 的 SM4 支持如果你公司有自研的国密库替换算法提供商即可结构完全不用变。4.1 环境依赖我用的 Maven 依赖如下dependency groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk15on/artifactId version1.70/version /dependencyBC 版本建议不要低于 1.60早期版本对 SM4 支持不完整。如果你的工程里同时有 JDK 自身的 JCE 和其他安全库需要把 BC 注册为最高优先级 Provider否则可能出现“Algorithm SM4 not available”的错误。4.2 核心工具类SM4 加解密import org.bouncycastle.jce.provider.BouncyCastleProvider; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; import java.security.Security; public class Sm4Util { static { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) null) { Security.addProvider(new BouncyCastleProvider()); } } private static final String ALGORITHM SM4; private static final String TRANSFORMATION SM4/ECB/NoPadding; public static byte[] sm4Encrypt(byte[] key, byte[] data) throws Exception { Cipher cipher Cipher.getInstance(TRANSFORMATION); SecretKeySpec keySpec new SecretKeySpec(key, ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, keySpec); return cipher.doFinal(data); } public static byte[] sm4Decrypt(byte[] key, byte[] data) throws Exception { Cipher cipher Cipher.getInstance(TRANSFORMATION); SecretKeySpec keySpec new SecretKeySpec(key, ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, keySpec); return cipher.doFinal(data); } }注意事项密钥长度必须 16 字节多一个字节少一个字节都会直接抛异常ECB 模式不需要 IV所以不要像 CBC 那样再传一个 IV 参数如果用其他国密库比如某些硬件加密机厂商的 SDKAPI 可能不叫 Cipher但底层输入输出仍然是byte[]。4.3 ANSI X9.8 带主账号 PIN Block 构造public class PinBlockUtil { public static byte[] buildPinBlockWithPan(String pin, String pan) { byte[] pinBlock buildRawPinBlock(pin); byte[] panFactor buildPanFactor(pan); byte[] result new byte[8]; for (int i 0; i 8; i) { result[i] (byte) (pinBlock[i] ^ panFactor[i]); } return result; } public static byte[] buildRawPinBlock(String pin) { byte[] block new byte[8]; int pinLen pin.length(); // 控制字段高4位为0低4位为PIN长度 block[0] (byte) pinLen; int pinIndex 0; for (int i 1; i (pinLen 1) / 2; i) { int high pin.charAt(pinIndex) - 0; // 第一个数字 int low 0x0F; // 如果只有奇数位低位默认F pinIndex; if (pinIndex pinLen) { low pin.charAt(pinIndex) - 0; pinIndex; } block[i] (byte) ((high 4) | low); } for (int i (pinLen 1) / 2 1; i 8; i) { block[i] (byte) 0xFF; } return block; } public static byte[] buildPanFactor(String pan) { byte[] factor new byte[8]; // 取最右12位数字不足时左补0 String last12 pan.length() 12 ? pan.substring(pan.length() - 12) : String.format(%012d, 0) pan; if (last12.length() 12) { last12 last12.substring(last12.length() - 12); } factor[0] 0x00; int index 1; for (int i 0; i 6; i) { int high last12.charAt(i * 2) - 0; int low last12.charAt(i * 2 1) - 0; factor[index] (byte) ((high 4) | low); } factor[7] 0x00; return factor; } public static String extractPinFromBlock(byte[] decryptedBlock) { int pinLen decryptedBlock[0] 0x0F; char[] pin new char[pinLen]; int pinIndex 0; for (int i 1; pinIndex pinLen; i) { int high (decryptedBlock[i] 4) 0x0F; int low decryptedBlock[i] 0x0F; if (pinIndex pinLen) { pin[pinIndex] (char) (0 high); } if (pinIndex pinLen low ! 0x0F) { pin[pinIndex] (char) (0 low); } } return new String(pin); } }这里有两个细节值得多说一句buildRawPinBlock里把控制字段的高 4 位直接留零因为 PIN 长度最多 12 位二进制的 12 就是0x0C不会超过 4 位表达范围但严谨的做法是(byte)((0 0xF0) | (pinLen 0x0F))防止未来标准扩展后出错extractPinFromBlock里对低半字节是 F 的情况做了判断避免奇数位 PIN 时把填充的 F 当作数字 15 解析出来。5. 验证环节别等联调才发现格式错位5.1 用一段可跑的 main 方法验证解密链路我写业务代码时习惯先在本机跑通再去接业务系统。验证方法很简单用同样的 PIN、PAN、密钥分别做一次加密构造和一次解密还原最后断言还原的 PIN 和原始 PIN 一致。public class PinBlockDemo { public static void main(String[] args) throws Exception { String pin 123456; String pan 6225881234567890; // 16字节测试密钥 byte[] key 0123456789ABCDEF.getBytes(UTF-8); // 1. 构造待加密的PIN Block异或账号因子后 byte[] plainPinBlock PinBlockUtil.buildPinBlockWithPan(pin, pan); // 2. 打印异或后的明文块方便排查 System.out.println(XOR后的明文块: bytesToHex(plainPinBlock)); // 3. SM4加密 byte[] ciphertext Sm4Util.sm4Encrypt(key, plainPinBlock); System.out.println(SM4密文: bytesToHex(ciphertext)); // 4. SM4解密 byte[] decrypted Sm4Util.sm4Decrypt(key, ciphertext); // 5. 还原PIN String recoveredPin PinBlockUtil.extractPinFromBlock(decrypted); System.out.println(还原的PIN: recoveredPin); // 6. 校验 System.out.println(pin.equals(recoveredPin) ? 校验通过 : 校验失败); } private static String bytesToHex(byte[] bytes) { StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02X, b)); } return sb.toString(); } }跑这个示例时建议多测几组边界 PIN0000、1234、123456、12345678901212 位最长尤其注意奇数位 PIN 如12345时的填充解析很容易在低半字节处理上栽跟头。5.2 常见联调失败场景与原因现象真正原因解密出来 PIN 前几位对后面全是乱码BCD 解析时把填充 F 当作数字 15或者控制字段被当成 1 字节普通数字直接转字符串密文长度不对SM4 分组固定 16 字节如果输入长度不是 16 的倍数就会报错PIN Block 必须是 8 字节异或后也是 8 字节如果你把 8 字节直接传给 SM4 就会报 IllegalBlockSizeException相同 PIN、相同卡号但每次密文不同这可能是因为后台每次生成不同的随机数参与密钥分散或者你们实际用了带随机因子的 PIN 格式不是格式 0解密出来的 PIN 第一位是 0 丢失控制字段解析错误把低 4 位长度和高 4 位混淆这几类问题里最大的坑是第一类。很多人写extractPinFromBlock时直接用String.valueOf(decryptedBlock[i])得到的是字节的十进制值而不是 BCD 数字。6. 原理解析为什么账号因子参与异或而不是直接拼接这段如果只看代码不看原理很容易出现“照着写但换了个场景就懵”的情况。我用自己的话解释一遍。ANSI X9.8 格式 0 的初衷是保证每个 PIN 在整个生命周期里不以明文出现。但如果不做任何附加处理同一个 PIN 在同一个密钥下永远加密成同一个密文。攻击者只要收集足够的密文样本就能在不破解算法的情况下做重放攻击——把 A 用户某次交易里的密文替换成 B 用户当前交易里的密文。带上账号因子异或之后密文不仅依赖 PIN还依赖卡号。攻击者即使复制了 A 用户的密文也无法用于 B 用户的交易因为 B 用户解密时用的账号因子不同还原出的 PIN Block 必然不正确。有些朋友会问那为什么不直接把卡号拼在 PIN 后面再参与 SM4 加密那样做当然也能让密文依赖卡号但坏处是破坏了标准格式。金融行业内不同机构之间互相通信必须严格按 ANSI X9.8 来构造字段你自定义一种“拼接卡号”的格式对方后台根本没法按标准解析。异或这种方式的好处是既依赖账号又保持了输出长度固定为 8 字节符合标准解析器的输入预期。再补充一个细节实际很多银行的 POS 终端在构造 PIN Block 时卡号取的是完整卡号的右 12 位但如果卡号长度不是 16 位而是 19 位依然取右 12 位。比如 19 位卡号6225881234567890123最右 12 位是881234567890123去掉前导不对这里要仔细19 位取右 12 位就是第 8 到第 19 位如果按字符串截取substring(19-12)得到的是567890123加上前导其实就是第 8 位到第 19 位。我举个具体例子6225881234567890123长度 19右 12 位从第 8 个字符开始第 8 位是1所以是123456789012。这个数字共 12 位直接用。假如 13 位卡号1234567890123右 12 位就是234567890123。位数不是 12 时要用左补 0 的方式补足比如 11 位账号12345678901右 12 位应当按012345678901处理。这个左补 0 的细节非常容易被忽略而且在真实交易里一定会遇到建议写代码时明确处理。7. 密码键盘硬件受理环境的工程细节加密机和密钥分散如果你的项目不是纯软件模拟而是真的接密码键盘那 PM 模式下的几个工程问题躲不掉这里提前排雷。7.1 工作密钥怎么来的后台和密码键盘之间不可能直接存一份静态密钥就完事。常见做法是后台用主密钥Master Key和工作密钥索引通过密钥分散算法通常基于卡号或随机数动态生成每台终端的工作密钥。密码键盘出厂时只预置主密钥或传输密钥交易时由后台下发分散因子键盘本地计算得到工作密钥。这意味着你在做解密模块时不能把“工作密钥”当成一个固定常量写死。要正确还原 PIN必须先复现后台的分散流程得到和键盘一致的工作密钥。很多联调卡在这里终端侧加密出来的密文后台用固定密钥解不开原因就是工作密钥用了分散逻辑而不是全局同一把。7.2 密码键盘返回的数据可能带 MAC密码键盘输出密文的同时往往还会附带一串 MAC 校验码用于保证密文在传输到后台过程中没有被篡改。MAC 算法可能是 SM4 的 CBC-MAC也可能是国密 SM3 的 HMAC。后台验 MAC 不通过时即使 SM4 解密能解出看起来合理的 PIN Block也不能信任。这是金融安全里“完整性保护”的一个体现光加密不校验相当于快递上了锁但没封条。7.3 联调环境下的真实数据长什么样我模拟一组完整数据方便你对照自己系统里的日志排查密钥: 01 23 45 67 89 AB CD EF 01 23 45 67 89 AB CD EF PAN: 6225881234567890 PIN: 123456 不带账号PIN块: 06 12 34 56 FF FF FF FF 账号因子: 00 88 12 34 56 78 90 00 异或后明文: 06 9A 26 62 A9 87 6F FF SM4 ECB加密: XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX你拿上面这一串去验证自己写的代码如果前面几步的输出都对上了说明构造和解析逻辑正确。SM4 密文因为算法固定只要密钥和明文一致任何标准 SM4 实现出来的结果也应该完全一致。如果密文不一致优先检查密钥字节序是不是高低位颠倒了或者 PIN 长度控制字段是不是写错了。8. 用 SM4 ECB 做 PIN 加密时的边界条件与踩坑实录8.1 输入长度8 字节还是 16 字节这是我被问得最多的问题。SM4 分组长度是 16 字节但 ANSI X9.8 的 PIN Block 是 8 字节。如果直接把 8 字节 PIN Block 丢给 SM4会报Input length must be multiple of 16 bytes这类异常。正确做法是8 字节 PIN Block 必须扩展成 16 字节再加密。常见扩展方式是把 8 字节 PIN Block 放在前 8 字节后 8 字节补零或者直接使用 16 字节的 PIN Block 构造标准。不同银行的标准有差异有些采用 16 字节 PIN Block 格式即左右各 8 字节左半部分存 PIN 信息右半部分全 F 或全零所以联调时一定要先确认对方用的是 8 字节还是 16 字节格式。给个对照表格式类型明文块长度SM4 输入长度备注ANSI X9.8 8字节8需补到 16补零或补F旧式终端常见ANSI X9.8 16字节1616 直接加密新标准常见我这边用的是把 8 字节块放前面、后面补 8 个零字节的做法主要原因是对端 HSM 的接口就这么定义的。你接到新项目时第一件事不是写代码而是问清对方 PIN Block 是 8 字节还是 16 字节以及补位规则。8.2 账号因子要和 PIN 块保持同一字节序PAN 是最右 12 位数字构造因子时从高位到低位直接按顺序填入 6 个字节这里没有端序转换的问题。但有些 HSM 接口要求把 PAN 数字倒序排列后再构造因子甚至要求 PAN 只取右 12 位中的奇数位或偶数位这种变体在国内银行联调中真的存在。遇到“解密出来 PIN 完全不对”但格式没写错时优先怀疑字节序和位序问题。8.3 没带主账号的格式 0 也别丢有些场景下报文里没有卡号比如部分自助设备的 PIN 修改流程可能要求使用不带账号的格式 0。这种情况很简单把账号因子块全部置零即可异或后 PIN Block 等于原始 PIN Block。所以一套代码里可以留一个开关控制是否启用 PAN 参与异或这样能同时兼容两种需求。8.4 SM4 进 S盒 的混淆风险有位同事之前把 SM4 和 SMS4 混着写还有人把 SM4 分组长度记成 8 字节导致代码跑通但结果始终和 HSM 对不上。SM4 分组长度就是 16 字节密钥 16 字节迭代 32 轮结构上接近 AES 但完全不是同一套 S 盒。不要试图用任何 AES 的中间变量去“等价替换”必须用完整 SM4 实现。9. 金融安全设计密文重放、随机数与防窥探的层次关系写到这里肯定有朋友想继续追问ECB 模式在别的场景会被攻击为什么 PIN 加密不换成 CBC 或者 GCM简单说PIN 加密的每个分组本质上是一次性使用的短报文明文长度极其有限而且格式固定、有账号因子参与。ECB 模式在这里不会引发 CBC 那种“雪崩效应不足”的问题也不会因为“相同明文分组相同密文”带来批量泄露。反倒是 CBC 需要 IVIV 的管理和传递会增加联调复杂度。金融行业里PIN 加密这块标准就是 ANSI X9.8 分组算法你在交易报文里能看到的就是密文、账号、随机数没有哪个字段专门存 IV。另外一个值得提的点是随机数的使用。ECB 模式下相同的明文、密钥会产生相同密文。为了对抗重放除了账号因子之外很多系统还会把终端号、交易序号、随机数一起参与进来。具体做法不一定是改 PIN Block 格式而可能是对报文整体做 MAC 保护。因此你可能在报文中看到类似randomNumber、traceNo的字段不要去动它们它们不是加解密的输入而是防重放的重要依赖。我在实际项目里还踩过一个坑后台解完 PIN 后直接拿明文去查数据库日志框架把参数打出来了。明文 PIN 出现在日志里这在等保和 PCI 合规里是红线。正确做法是解密后立刻使用使用完马上清空引用不让 PIN 对象被日志框架或者内存 dump 抓到。Java 里字符串不可变这个要求很难做到百分百但至少别手动打印更别把明文 PIN 塞进数据库表。10. 从示例到生产一份可直接抄的落地清单最后按“抄作业”的标准把完整落地步骤列成清单照顾一下刚接触这套东西的朋友。先确认联调对象提供的 PIN Block 格式8 字节还是 16 字节是格式 0 还是格式 1是否带账号因子向对方索取密钥分散规则确认工作密钥如何生成准备好 SM4 库优先选择已经通过国密检测的商用密码产品软件实现只建议用于开发和测试环境写一个独立的加解密工具模块把 PIN Block 构造、账号因子构造、SM4 加密、SM4 解密、PIN 还原五个函数单独拆开用 I 自己构造的测试向量核对每一步中间结果如果有 HSM直接调 HSM 接口对比密文跑边界用例PIN 长度 4、6、7、12 位PAN 长度 13、16、19 位PAN 右 12 位含前导 0 的情况做安全自查确认日志不打印明文 PIN确认密钥硬编码只出现在本地测试生产环境密钥必须走密钥管理系统和终端联调时先传一个固定 PIN、固定 PAN 的测试用例方便双向定位问题全部通过后再切换到随机 PIN 和真实卡号的批量验证对 MAC 校验部分不要跳过如果密码键盘返回了 MAC后台必须验 MAC 成功再解密 PIN。从开发量来说这套东西本身不大核心代码就一两百行但真正花时间的地方全在“格式对齐”和“密钥管理”上。很多团队低估了这部分工作导致进集成测试阶段才发现和密码键盘厂商 SDK 对不上返工成本很高。如果你正在处理银行卡密码键盘相关的需求建议先把这篇文章里的示例代码保存下来一步一步对照联调对象的文档去改。等中间结果全部对上再进集成测试你会发现自己能省下大量反复沟通的时间。我在实际项目中最大的体会是PIN 加密这种看似简单的功能真正决定成败的往往不是加密算法本身而是那些没写在标准文档里的联调细节。遇到问题别急着换算法先回去检查 PIN Block 构造和密钥分散大概率问题就出在那里。