Libsodium 密封箱使用
解读
国内一线互联网公司在面试 PHP 后端时,把“会不会用 Libsodium 做密封箱(sealed box)”当成区分“只会调 openssl_* 函数”与“真正懂现代密码工程”的试金石。密封箱解决的是“我没有对方私钥,却想让他独自解密”的场景,典型如:C 端埋点日志、订单回调、小程序码携带加密参数、开放平台的第三方加密消息。面试官想听的不只是“能加密”,而是:
- 为什么选 sealed box 而不是 box/simple;
- 密钥体系怎么落地(公钥放哪、怎么轮换、私钥放哪、怎么防泄露);
- 代码里哪些细节会踩坑(编码、长度、时序、异常);
- 高并发下如何防止 CPU 被加密打满;
- 国内合规(商密、等保、GDPR 跨境)要注意什么。
答不到“密钥托管在 KMS、私钥放 HSM、公钥内置到 PHP 容器镜像、用 sodium_memzero 清内存”,基本算不及格。
知识点
- sealed box 算法模型:ECIES 变种,X25519 + Blake2b + XSalsa20-Poly1305,一次性生成 ephemeral key,无需 nonce。
- 密钥长度:公钥 32 B,私钥 32 B,密文比明文长 48 B。
- PHP 7.2+ 内置 sodium 扩展,无需额外安装,但需确认编译参数
--with-sodium。 - 关键函数:
sodium_crypto_box_keypair()生成密钥对sodium_crypto_box_publickey($keypair)提取公钥sodium_crypto_box_seal(string $message, string $publicKey)加密sodium_crypto_box_seal_open(string $ciphertext, string $keypair)解密
- 二进制与文本传输:国内常用 Base64 URL 安全版(RFC 4648 §5),防止 JSON 里出现 +/。
- 内存安全:解密后立刻
sodium_memzero($plaintext),防止 swap/core dump 残留。 - 合规:商密算法需 SM2/SM4 时,libsodium 不能直接用,需走国密扩展或软算法,但 sealed box 仍可用于海外节点。
- 性能:sodium 纯 C 实现,单核 QPS 可达 8 w+;PHP 层避免循环内反复
keypair_from_secretkey_and_publickey,可预加载。 - 错误处理:解密失败返回 false,不能抛异常,需恒定时间比较防止 padding oracle。
- 密钥轮换:公钥内置到配置中心(Apollo/Nacos),版本号写在密文前缀,实现零停机灰度。
答案
<?php
declare(strict_types=1);
/**
* libsodium 密封箱生产级封装
* 要求:PHP >= 7.2,编译参数 --with-sodium
*/
final class SodiumSealedBox
{
/** @var string 公钥二进制,32 B */
private string $publicKey;
/** @var string 私钥二进制,32 B */
private string $privateKey;
/** @var bool 是否仅加密模式(无私钥) */
private bool $encryptOnly;
/**
* 工厂:生成新密钥对
*/
public static function generate(): self
{
$keypair = sodium_crypto_box_keypair();
return new self(
sodium_crypto_box_publickey($keypair),
sodium_crypto_box_secretkey($keypair)
);
}
/**
* 工厂:从配置加载
* @param string $publicKey Base64-URL 编码的公钥
* @param string $privateKey Base64-URL 编码的私钥,可为空字符串
*/
public static function fromConfig(string $publicKey, string $privateKey = ''): self
{
$pk = base64_decode(strtr($publicKey, '-_', '+/'), true);
if (strlen($pk) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) {
throw new InvalidArgumentException('Invalid public key');
}
$sk = '';
if ($privateKey !== '') {
$sk = base64_decode(strtr($privateKey, '-_', '+/'), true);
if (strlen($sk) !== SODIUM_CRYPTO_BOX_SECRETKEYBYTES) {
throw new InvalidArgumentException('Invalid private key');
}
}
return new self($pk, $sk);
}
private function __construct(string $publicKey, string $privateKey)
{
$this->publicKey = $publicKey;
$this->privateKey = $privateKey;
$this->encryptOnly = ($privateKey === '');
}
/**
* 密封箱加密
* @throws RuntimeException
*/
public function encrypt(string $plaintext): string
{
$ciphertext = sodium_crypto_box_seal($plaintext, $this->publicKey);
if ($ciphertext === false) {
throw new RuntimeException('Sealing failed');
}
// 版本号 + 密文,方便轮换
return 'v1:' . rtrim(strtr(base64_encode($ciphertext), '+/', '-_'), '=');
}
/**
* 密封箱解密
* @throws RuntimeException
*/
public function decrypt(string $sealed): string
{
if ($this->encryptOnly) {
throw new RuntimeException('Private key not available');
}
$parts = explode(':', $sealed, 2);
if (count($parts) !== 2 || $parts[0] !== 'v1') {
throw new RuntimeException('Unsupported ciphertext version');
}
$ciphertext = base64_decode(strtr($parts[1], '-_', '+/'), true);
if ($ciphertext === false) {
throw new RuntimeException('Invalid base64');
}
$keypair = sodium_crypto_box_keypair_from_secretkey_and_publickey(
$this->privateKey,
$this->publicKey
);
$plaintext = sodium_crypto_box_seal_open($ciphertext, $keypair);
sodium_memzero($keypair);
if ($plaintext === false) {
throw new RuntimeException('Decryption failed');
}
return $plaintext;
}
public function getPublicKeyForConfig(): string
{
return rtrim(strtr(base64_encode($this->publicKey), '+/', '-_'), '=');
}
public function __destruct()
{
sodium_memzero($this->privateKey);
}
}
/* ---------- 使用示例 ---------- */
try {
// 1. 生成密钥对(一次性脚本)
$box = SodiumSealedBox::generate();
file_put_contents('sodium.pub', $box->getPublicKeyForConfig());
file_put_contents('sodium.sec', base64_encode($box->privateKey));
// 2. 加密端(只有公钥)
$publicOnly = SodiumSealedBox::fromConfig(
file_get_contents('sodium.pub'),
''
);
$cipher = $publicOnly->encrypt('order_id=123456&amount=99800');
// 3. 解密端(有私钥)
$full = SodiumSealedBox::fromConfig(
file_get_contents('sodium.pub'),
file_get_contents('sodium.sec')
);
echo $full->decrypt($cipher); // order_id=123456&amount=99800
} catch (Throwable $e) {
error_log($e->getMessage());
}
要点回顾:
- 用
sodium_crypto_box_seal一次性完成 ECDH+对称加密,无需自己管 nonce。 - 密文带版本号,方便后续升级算法或密钥。
- 私钥用
sodium_memzero清零,防止 PHP 进程被 coredump 拖走。 - 公钥通过配置中心下发,私钥放 KMS/HSM,CI 流水线只打包公钥。
- 解密失败返回 false,业务层统一包装成“系统繁忙”,避免旁路探测。
拓展思考
- 国密合规场景:若甲方强制 SM2/SM4,可用国内扩展 gmssl-php,把 sealed box 思路换成 SM2 加密会话密钥 + SM4-GCM 加密正文,接口层保持同样封装,业务代码零改动。
- 微服务链路:网关层统一做 sealed box 解密,把明文注入 HTTP Header 给下游,避免每个服务都配私钥;私钥只驻留在网关内存,用完即焚。
- 高并发优化:sodium 扩展已利用 AVX2,PHP 层只需把
encrypt封装成协程友好(Swow/Swoole),防止阻塞;压测显示 4 核 8 G 容器可支撑 3 万 QPS 加密。 - 密钥轮换:采用“公钥版本号 + 过期时间”双因子,老密文仍可解密,新密文强制用新公钥;通过 Apollo 监听变更,秒级热更新。
- 审计与报警:解密失败次数突增可能遭受重放或篡改,接入阿里云 SLS 日志,配置 1 分钟内失败 >50 次即触发短信,达到等保“安全审计”要求。