Libsodium 密封箱使用

解读

国内一线互联网公司在面试 PHP 后端时,把“会不会用 Libsodium 做密封箱(sealed box)”当成区分“只会调 openssl_* 函数”与“真正懂现代密码工程”的试金石。密封箱解决的是“我没有对方私钥,却想让他独自解密”的场景,典型如:C 端埋点日志、订单回调、小程序码携带加密参数、开放平台的第三方加密消息。面试官想听的不只是“能加密”,而是:

  1. 为什么选 sealed box 而不是 box/simple;
  2. 密钥体系怎么落地(公钥放哪、怎么轮换、私钥放哪、怎么防泄露);
  3. 代码里哪些细节会踩坑(编码、长度、时序、异常);
  4. 高并发下如何防止 CPU 被加密打满;
  5. 国内合规(商密、等保、GDPR 跨境)要注意什么。

答不到“密钥托管在 KMS、私钥放 HSM、公钥内置到 PHP 容器镜像、用 sodium_memzero 清内存”,基本算不及格。

知识点

  1. sealed box 算法模型:ECIES 变种,X25519 + Blake2b + XSalsa20-Poly1305,一次性生成 ephemeral key,无需 nonce。
  2. 密钥长度:公钥 32 B,私钥 32 B,密文比明文长 48 B。
  3. PHP 7.2+ 内置 sodium 扩展,无需额外安装,但需确认编译参数 --with-sodium
  4. 关键函数:
    • 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) 解密
  5. 二进制与文本传输:国内常用 Base64 URL 安全版(RFC 4648 §5),防止 JSON 里出现 +/。
  6. 内存安全:解密后立刻 sodium_memzero($plaintext),防止 swap/core dump 残留。
  7. 合规:商密算法需 SM2/SM4 时,libsodium 不能直接用,需走国密扩展或软算法,但 sealed box 仍可用于海外节点。
  8. 性能:sodium 纯 C 实现,单核 QPS 可达 8 w+;PHP 层避免循环内反复 keypair_from_secretkey_and_publickey,可预加载。
  9. 错误处理:解密失败返回 false,不能抛异常,需恒定时间比较防止 padding oracle。
  10. 密钥轮换:公钥内置到配置中心(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,业务层统一包装成“系统繁忙”,避免旁路探测。

拓展思考

  1. 国密合规场景:若甲方强制 SM2/SM4,可用国内扩展 gmssl-php,把 sealed box 思路换成 SM2 加密会话密钥 + SM4-GCM 加密正文,接口层保持同样封装,业务代码零改动。
  2. 微服务链路:网关层统一做 sealed box 解密,把明文注入 HTTP Header 给下游,避免每个服务都配私钥;私钥只驻留在网关内存,用完即焚。
  3. 高并发优化:sodium 扩展已利用 AVX2,PHP 层只需把 encrypt 封装成协程友好(Swow/Swoole),防止阻塞;压测显示 4 核 8 G 容器可支撑 3 万 QPS 加密。
  4. 密钥轮换:采用“公钥版本号 + 过期时间”双因子,老密文仍可解密,新密文强制用新公钥;通过 Apollo 监听变更,秒级热更新。
  5. 审计与报警:解密失败次数突增可能遭受重放或篡改,接入阿里云 SLS 日志,配置 1 分钟内失败 >50 次即触发短信,达到等保“安全审计”要求。