1. 从一个真实需求说起:为什么金融场景里 PIN 加密不能随便写
我在做银行卡相关系统的几年里,遇到过不少刚接触这块的同事,第一反应都是:“不就是把密码用 SM4 加密一下吗?直接 AES 换 SM4 不就完了?”真这么干,上线那天大概率要出事。
先说清楚一个概念:PIN(Personal 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.8
2.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 “带主账号信息”到底带的是什么,怎么带
带主账号信息,指的是把银行卡号(PAN,Primary 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 的明文输入。
还是用 PIN=123456举例,不带账号的原始块是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> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>1.70</version> </dependency>BC 版本建议不要低于 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 参数;
- 如果用其他国密库(比如某些硬件加密机厂商的 SDK),API 可能不叫 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(); } }跑这个示例时,建议多测几组边界 PIN:0000、1234、123456、123456789012(12 位最长),尤其注意奇数位 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字节 | 16 | 16 直接加密 | 新标准常见 |
我这边用的是把 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 需要 IV,IV 的管理和传递会增加联调复杂度。金融行业里,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 构造和密钥分散,大概率问题就出在那里。