
在PHP项目里做数据加密绕不开的就是AES。之前接手一个卡密系统明文卡密直接存数据库结果被脚本跑出来批量兑换后来我改用AES加密再用bin2hex把密文转成十六进制字符串入库配合hex2bin解密还原才把这个问题彻底堵住。这篇内容适合正在做充值卡密、接口数据加密、登录加密的朋友参考我会把AES加密解密的基本原理、bin2hex/hex2bin的转换细节、完整代码封装和踩坑经历都讲清楚。1. AES加密选型为什么PHP项目里首选它1.1 AES是什么以及三种常用模式的区别AES的全称是Advanced Encryption Standard高级加密标准是当前应用最广泛的对称加密算法。对称加密的意思就是加密和解密用同一把密钥发送方和接收方都要持有这把密钥才能完成通信。AES从1998年发布到现在经过了十几年实战考验目前仍然没有有效的暴力破解手段所以很多业务系统、支付网关、云厂商API都默认拿它做数据保护。在PHP里实施AES加密我们需要先搞清楚几个概念密钥长度AES支持128位、192位、256位三种密钥长度对应我们常说的AES-128、AES-192、AES-256。密钥位数越高理论上暴力穷举的空间越大。PHP的openssl扩展里我们能直接指定AES-128-CBC、AES-192-CBC、AES-256-CBC这样的算法名。分组大小AES是分组加密算法明文按128位16字节一组进行加密。如果明文长度不是16字节的整数倍就需要填充。openssl扩展默认使用PKCS7填充这也是行业最通用的做法。加密模式常见有ECB、CBC、CFB、OFB、GCM等。平时最常用的是CBC和GCM。ECB模式最省事但同一明文块会生成同一密文块容易泄露数据规律而且无法防止块重放攻击所以我一般不建议生产环境用ECB。CBC模式让每个明文分组先和前一个密文分组做异或再加密这样相同明文在不同位置会得到不同的密文安全系数高很多是目前业务系统里的主流选择。GCM则是带认证的加密模式密文被篡改后能直接检测出来适合对完整性要求很高的场景PHP 7.1之后openssl_encrypt也支持了不过在早期版本里兼容性没CBC好。选择哪种模式核心看你的业务场景。如果只是存卡密、存密码备份这类数据CBC足够如果是两个系统之间传递数据又怕中间人篡改建议上GCM或者CBCHMAC签名组合。1.2 初始向量IV的作用与CBC模式的安全性很多刚接触openssl_encrypt的人都会卡在IV这个参数上。IV的中文名是初始向量英文是initialization vector很多人搜AES加密iv初始向量是什么其实就是它。IV用于让加密过程具备随机性。CBC模式的流程是这样的第一个明文分组跟一个随机数也就是IV做异或运算然后把结果送给AES加密得到第一个密文分组接下来第二个明文分组跟第一个密文分组做异或再加密以此类推。所以IV的作用就是给加密链条一个随机起点。IV有两个硬性要求长度要与AES分组大小一致。AES分组永远是16字节所以不管你是AES-128-CBC还是AES-256-CBCIV的长度都必须是16字节也就是16个ASCII字符或者32个十六进制字符。同一条数据加解密时必须使用相同的IV。加密时生成一个随机IV解密时把同一个IV传进去否则解出来全是乱码。这也就是为什么你在很多代码里看到别人把IV和密文一起拼接存储的原因。IV是否需要保密答案是否定的。IV的主要作用是引入随机性而不是隐藏消息它一般会被明文传递给接收方。CBC模式下IV如果被预测或固定不变攻击者就可能通过选择密文攻击来还原信息所以IV至少要保证不可预测最稳妥的做法是用random_bytes(16)生成。另外一个容易被忽略的知识点同一个密钥配合不同的IV对同一条明文加密得到的结果完全不同。这个特性在卡密系统里有个很实用的玩法——如果希望同一条卡密每次展示的密文都不一样防止被观测到规律就可以每次生成新的IV然后把IV拼在密文前面存下来。2. bin2hex与hex2bin加密后数据格式转换的幕后工作2.1 为什么加密出的二进制串不能直接存库和传输openssl_encrypt默认返回的是原始二进制串。所谓原始二进制串就是字节流里面可能包含不可见字符、换行符、甚至0x00。你把这种字符串直接存进MySQL的varchar字段、打印到日志、拼到URL参数里都有隐患存储层面数据库连接若使用非UTF-8字符集密文中的特殊字节可能被转义或截断有些ORM框架还会对特殊字符做转义存进去和取出来就不一致了。传输层面URL参数、JSON、HTTP Header都是文本协议二进制数据混进去会导致协议解析异常。比如密文里出现0x0A日志和响应就被换行截断。显示层面调试时把二进制串print出来满屏乱码根本看不出两个密文是否一样排查效率极低。解决办法是把二进制转成可打印文本常见三种编码编码方式说明使用场景bin2hex / hex2bin十六进制字符串1字节变2字符可逆数据库存储、日志记录、卡密展示base64_encode / base64_decode更紧凑容量膨胀约33%URL/JSON传输、接口交互rawurlencode / rawurldecode百分号编码兼容性一般拼接URL参数你可能会问既然base64更紧凑为什么还要用bin2hex两个原因一是bin2hex的结果只包含0-9、a-f这些字符在任何字符集下都不会被转义不会出现base64里常见的、/、等特殊字符拼URL或者存到某些老系统里更省心二是调试直观十六进制字符串可以直接肉眼比对对卡密、工单、审计这类场景很友好。当然代价是体积大密文长度变成原来的两倍。AES加密后长度本身可控这个膨胀在绝大多数业务场景里可以接受。2.2 bin2hex/hex2bin的正确使用姿势bin2hex和hex2bin是PHP内置函数用法非常直接$binary openssl_encrypt($plain, AES-256-CBC, $key, OPENSSL_RAW_DATA, $iv); $hex bin2hex($binary); // 二进制 - 十六进制 $binary2 hex2bin($hex); // 十六进制 - 二进制但有几个容易踩的细节我得单独提醒不要对hex字符串再做一次base64_encode。我见过有同学把bin2hex的结果又套了层base64结果解密时怎么都对不上。openssl_encrypt的options参数非常关键。OPENSSL_RAW_DATA表示返回原始二进制数据不经过base64编码如果这个参数不传或者传0openssl_encrypt默认会返回base64编码后的字符串。这时你再对它bin2hex等于把一个base64字符串当二进制转了十六进制解密时hex2bin拿回的是一串base64文本还得再base64_decode一次绕一大圈。我个人习惯统一用OPENSSL_RAW_DATA 手动bin2hex逻辑简单、出错率低。hex2bin不是总能成功。如果传入的字符串长度是奇数、或者包含非十六进制字符它会返回false。所以解密前最好先校验一下格式避免拿到不合法密文直接报错。把二进制转成hex只是一个格式转换它不改变加密本身的安全性。很多新手误以为输出多了hex步骤就是多套了一层加密其实不是hex只是让数据可以安全地躺在数据库和日志里而已。3. 完整代码实现从加密函数封装到可复用的工具类3.1 加密解密函数封装AES-256-CBC hex直接上代码。我项目中一直用的是AES-256-CBC OPENSSL_RAW_DATA hex存储这套组合已经稳定跑了好几年。?php class AesUtil { private string $key; private string $iv; public function __construct(string $key, string $iv) { // 密钥和IV统一做长度截取保证后续算法参数正确 $this-key substr($key, 0, 32); $this-iv substr($iv, 0, 16); } /** * 加密明文 - hex字符串 */ public function encrypt(string $plaintext): string { $encrypted openssl_encrypt( $plaintext, AES-256-CBC, $this-key, OPENSSL_RAW_DATA, $this-iv ); if ($encrypted false) { throw new RuntimeException(AES加密失败); } return bin2hex($encrypted); } /** * 解密hex字符串 - 明文 */ public function decrypt(string $hexCiphertext): string { $binary hex2bin($hexCiphertext); if ($binary false) { throw new InvalidArgumentException(密文不是合法的十六进制字符串); } $decrypted openssl_decrypt( $binary, AES-256-CBC, $this-key, OPENSSL_RAW_DATA, $this-iv ); if ($decrypted false) { throw new RuntimeException(AES解密失败请检查密钥和IV是否一致); } return $decrypted; } }使用示例$key YourSecretKey-32BytesLongString; $iv 0123456789abcdef; $util new AesUtil($key, $iv); $hex $util-encrypt(卡密内容); echo $hex; // 一串十六进制字符 $plain $util-decrypt($hex); echo $plain; // 卡密内容这套封装有三个地方是经过实战考虑的substr截断保证了无论外部传入多长的字符串密钥长度始终符合AES-256的要求。但要注意这是权宜之计如果外部密钥本身就是16字节而你想用AES-128就必须显式改算法名不能靠截断来混。openssl_encrypt在密钥长度或IV长度不符合要求时会抛E_WARNING返回值是false所以一定要检查返回值。很多人忽略这一步等到解密失败才回头找原因。解密接口接收的是hex字符串所以先用hex2bin还原二进制再交给openssl_decrypt。这一步顺序不能颠倒否则openssl_decrypt拿到的就是十六进制文本字节而不是真正的密文字节。3.2 密钥与IV的生成细节密钥管理是整个加密方案里最容易出问题、又最考验工程能力的一环。我总结三个要点密钥不是随便打的字符串。openssl_encrypt的key参数要求16/24/32字节如果你的密钥是123456这种他会取前N位那等于密钥强度只有6位数级别跟没加密差不多。真正安全的密钥应该由密码学安全的随机数生成比如random_bytes(32)或者使用至少32字节的高熵字符串。生产环境不要硬编码密钥。把密钥放在代码里一旦代码仓库泄露所有密文全部裸奔。常见做法是放在.env文件、配置文件或者环境变量里部署时单独设置并把密钥文件加入.gitignore。卡密系统这类高价值场景还应该做到一业务一密钥防止一个业务线泄露导致全盘皆输。IV的生成方式要和存储方式配套。我上面那个工具类里IV是构造传入的固定值适合内部数据加密、日志加密这类只有一个服务负责加解密的场景。但如果是对外接口需要让接收方解密每次加密时应该动态生成IV并把它拼在密文前面一起传输例如把iv的hex 密文hex拼接成完整字符串接收方先截取IV还原再解密。这个方案我在卡密实战里会细讲。3.3 与其他语言Java/Python加密互通的处理实际开发中跨语言加解密是绕不开的比如PHP后端加密的数据要给Java客户端解密。不同语言的AES实现细节不一样最容易踩坑的就是算法标识和填充方式。以Java为例Java里常见的写法是AES/CBC/PKCS5PaddingCipher cipher Cipher.getInstance(AES/CBC/PKCS5Padding);而PHP里用的是AES-256-CBCopenssl默认PKCS7填充。好消息是对于AES这种分组算法PKCS5和PKCS7在块大小为16字节时完全等价所以Java和PHP对接不成问题。真正需要注意的坑有三个密钥编码不一致。Java的SecretKeySpec通常通过key.getBytes(UTF-8)构造PHP侧如果直接字符串传key编码不一致就会导致解密失败。跨语言对接时最好把密钥统一为十六进制或base64字符串双方先解码成字节数组再喂给各自的API。options参数差异。Java的Cipher默认输出原始二进制等价于PHP的OPENSSL_RAW_DATA。有些PHP代码用了字符串形式没传OPENSSL_RAW_DATA得到的是base64串Java端就得先base64解码再解密双方要约定清楚哪一端负责编码解码。IV传递方式。Java端传入IV时用IvParameterSpecIV长度固定16字节。如果你在PHP端生成IV记得用hex或base64编码后拼到报文里Java端解码还原成字节数组再构建IvParameterSpec。顺便说一句如果你在Java端遇到java.security.InvalidKeyException: Wrong algorithm: AES or Rijndael required这种报错多半不是算法名写错而是密钥字节数组长度不对或者SecretKeySpec构造有问题检查一下长度就好。Python那边相对简单用pycryptodome库时指定AES.MODE_CBC密钥和IV同样按字节处理。只要大家统一字节数组 模式 填充 IV传递格式这四个要素跨语言互通基本不会出大问题。4. 卡密与接口场景AEShex实战应用4.1 充值卡密生成与校验的具体实现卡密系统是我最开始做AES加密的动机也是AEShex这套组合最典型的落地场景。整个流程可以拆成三块生成、加密存储、校验兑换。生成环节保证卡密本身的随机性和不可预测性function generateCard(int $length 16): string { $chars ABCDEFGHJKLMNPQRSTUVWXYZ23456789; $max strlen($chars) - 1; $card ; for ($i 0; $i $length; $i) { $card . $chars[random_int(0, $max)]; } return $card; }这里去掉了容易混淆的0/O、1/I卡密默认是16位大写字母和数字。存储环节就是本文主角登场的地方。卡密不能明文入库否则数据库泄露等于卡密全丢。我采用的方案是取一个全局密钥对每张卡片生成时动态创建随机IV加密卡密得到hex密文然后把IV和密文拼接存储$key getenv(APP_CARD_KEY); $card generateCard(); $iv random_bytes(16); $cipher bin2hex(openssl_encrypt($card, AES-256-CBC, $key, OPENSSL_RAW_DATA, $iv)); // 存储字段: card_cipher_hex $cipher, iv_hex bin2hex($iv) // 数据库里再存一个 card_hash 用于索引查询对明文卡密做hash校验兑换环节用户提交卡密后不是直接把用户输入和库里密文比对而是先把用户输入重新加密成密文再比对或者把库里密文解密成明文再比对。哪种更好方案A用户输入 - AES加密 - 与库中cipher比对。好处是解密操作更少缺点是数据库里存的是密文索引查询困难。方案B先用卡密hash去库里查到对应记录再解密记录里的密文比对明文是否一致。优点是可以用hash做唯一索引查起来快。我实际用的是方案B变种库表存card_hash对明文卡密做sha256、card_cipher_hex、iv_hex三个字段。用户输入卡密后先用hash查记录存在则解密cipher和用户输入字符串比对。这样即使密文被部分破坏也不会影响查询效率。这里有个细节很多人会漏同一张卡密如果通过加密后比对由于每次加密IV不同密文每次都不一样所以不能用密文直接做where条件必须通过hash锁定记录再做明文比对。这也是为什么我特意把card_hash字段加进去的原因。4.2 接口签名与敏感数据传输除了卡密AEShex还经常用于服务端之间的数据传输。比如订单系统要回调通知另一个财务系统传输的金额、订单号、手机号都属于敏感数据。简单做法是把关键字段拼成一个JSON字符串用AES加密后转成hex放进POST请求体里$data json_encode([ orderId 20250101001, amount 199.00, userId 88888, ], JSON_UNESCAPED_UNICODE); $iv random_bytes(16); $cipher bin2hex(openssl_encrypt($data, AES-256-CBC, $apiKey, OPENSSL_RAW_DATA, $iv)); $payload [ iv bin2hex($iv), data $cipher, timestamp time(), ]; // 再把整个数组用HMAC签名防止篡改 $sign hash_hmac(sha256, $cipher . $payload[timestamp], $apiKey); $payload[sign] $sign;接收方收到后先校验sign再取iv和data解密最后验证timestamp时间窗防重放。这套链路里AES负责保密性HMAC负责完整性timestamp负责时效性三者缺一不可。我见过不少项目只做加密不签名结果攻击者把整个密文替换掉系统解密后得到一段格式正确的伪造JSON照样被骗。加密和签名是两个维度别混为一谈。补充一句如果你要加密的是对象数组而不是简单字符串建议先序列化再加密。PHP里就是serialize($array)或者json_encode序列化后得到的字符串再走AES流程解密回来再反序列化。但要注意在对外接口场景优先用JSON而不是PHP的serialize因为对方未必是PHP技术栈JSON是跨语言通用的格式。5. 常见问题与排查技巧实录5.1 openssl_decrypt返回false的排查路径openssl_decrypt返回false是最常见的报错。遇到这种情况我的排查顺序是这样的检查密文是否完整。hex2bin之前确认hex字符串长度是否为偶数是否包含非hex字符。数据在传输过程中被截断、被URL编码搞坏的情况很常见。检查密钥和IV是否和解密时传入的一致。特别是密钥字符串末尾有空格、换行这类隐形字符时肉眼根本看不出来。建议在封装函数里统一做trim处理。检查算法名是否一致。加密时用AES-256-CBC解密时却写错成AES-128-CBC或AES-256-ECB必然失败。检查options参数是否一致。加密时用了OPENSSL_RAW_DATA解密时也必须用OPENSSL_RAW_DATA。一个用了默认的base64输出另一个按raw处理数据就对不上。把上面四项全查一遍九成以上的false问题都能定位。剩下少数情况多半是密钥本身有非法字符编码问题UTF-8和GBK混用导致的字节不一致。5.2 密钥和IV长度不匹配引发的隐患openssl_encrypt对key和iv的长度校验比较严格。AES-256-CBC要求key刚好32字节iv刚好16字节。长度不对时PHP会抛warning并在某些版本直接返回false。实际项目中我发现很多人会用截断来适配长度比如substr($key, 0, 32)。这在功能上是可行的但有个隐患如果你接入的第三方系统改成了AES-128你没有同步调整截断长度仍然substr成32字节传给AES-128-CBC而它要求密钥长度是16字节这时就会失败。所以我在封装类里强调长度校验逻辑必须和算法名绑定不要写一个通用的substr。另一个隐患是IV复用。有些开发图省事把IV当成全局常量所有卡密共用同一个IV。CBC模式下IV固定会带来两个问题相同的明文得到相同的密文攻击者可以从密文重复推断明文关系连续使用固定IV还可能降低整体安全性。加密操作的理想状态是每次加密都生成新的随机IV并安全传递。5.3 踩过的坑加解密结果不一致的几个典型原因我接入第三方系统时踩过一个特别隐蔽的坑对方用Java加密返回base64密文我在PHP端用hex2bin去还原结果解出来全是乱码。后来发现他们用的是base64_encode不是bin2hex密文里的号在URL传输时还被解码成了空格多层污染叠加排错排了大半天。吃过大亏之后我现在做跨系统加密对接第一件事就是确认三件事密文的编码格式hex还是base64谁负责编码、谁负责解码密钥的编码格式原始字符串还是hex双方字节是否一致IV的传递方式独立字段还是拼接在密文前截取长度是否按字节计算把这三个问题写进技术方案文档里能省掉大量联调成本。另外一个我多次遇到的问题加密用的字符串和存储用的字符串编码不一致。比如加密时传入的是UTF-8的中文解密端用iconv转成了GBK再解密结果也是乱码。加密算法不关心字符编码但解密出来的字节要还原成和业务预期一致的编码这一点很多人容易忽略尤其是中文环境下的项目。还有一点关于日志的提醒别把完整的密文直接打进日志尤其是生产环境。密文虽然不可读但万一攻击者拿到日志和密钥你的密文就等于明文。我一般只记录密文的前8位作为关联凭证既能排查问题又不至于泄露完整数据。最后再分享一个小技巧调试加解密逻辑时用一个已知的测试向量来验证你的工具类是否正确。比如固定key和IV对hello world加密得到的hex结果应该是一串稳定的值你可以先手动在命令行里验证一遍再把它写进单元测试。这样一来后续就算改了代码只要测试向量不变加解密正确性就永远不会悄悄被破坏。我个人在实际操作中的体会是AES加密本身不难难的是把密钥、IV、编码格式、算法模式这些细节统一好。你手里这套PHP AES-256-CBC bin2hex/hex2bin的组合代码逻辑很简单但真正跑起来你会不断遇到模式选错、密钥长度不对、跨语言编码不一致之类的问题。上面这些坑我基本都踩过一遍希望看完之后你能少走弯路。后续如果要进一步升级可以考虑把CBC换成AES-256-GCM加上认证能力或者把密钥托管到专门的密钥管理服务那些就是另一个话题了。