上一篇我们讲了经典的 AES RSA 混合加密发送方生成 AES 密钥加密数据再用 RSA 公钥加密 AES 密钥传给接收方。这篇介绍一种更现代的方案——ECDH 密钥协商 AES-GCM 会话加密也是 TLS 1.3 使用的核心思路。与 RSA把密钥包起来寄过去不同ECDH 能让通信双方在网络上完全不传输密钥的前提下各自算出同一把密钥并天然具备前向安全。本文使用PHP 8openssl 扩展 原生 Web Crypto API 前端实现不依赖 Composer 加密库包含会话管理、防篡改、防重放、失败降级等完整工程细节协议与语言无关文末附与 Go/Java 互通的注意点。一、为什么需要接口加密先明确目标有同学会问网站不是已经上 HTTPS 了吗为什么还要在应用层再加密一次先理清 HTTPS 的保护边界威胁HTTPS 能否防护公共 WiFi、运营商链路等传输途中被窃听/篡改✅ 能用户自己按 F12 看 Network、用 Postman/curl 直接请求接口❌ 不能写脚本批量抓取接口 JSON 数据❌ 不能HTTPS 保证快递在路上不被拆但数据到达浏览器后就是明文。对于课程、商品、订单这类有价值的数据任何人 F12 就能把接口返回扒得一干二净。应用层加密的目标是接口照常返回 JSON但data字段是密文只有前端 JS 能解开从而把随手抓接口的爬虫挡在门外。需要强调它是反爬/反调试门槛不是 DRM 或绝对安全。能驱动浏览器的攻击者Playwright 等依然能在解密之后拿到明文这点文末会专门讲。二、核心原理RSA 传输 vs ECDH 协商2.1 两种保护对称密钥的思路对称加密AES速度快但密钥不好传输非对称加密慢但能解决密钥分发。工业界都用混合加密但非对称部分有两种玩法对比项RSA 密钥传输上一篇方案ECDH 密钥协商本文方案密钥怎么来客户端随机生成 AES 密钥用 RSA 公钥加密后传给服务器双方各出临时密钥对数学协商出共享密钥密钥不上网密钥对RSA-2048 静态密钥对pem 文件长期保管ECDH P-256临时密钥对每次握手新建用完即弃对称算法AES-128-CBC无完整性校验AES-256-GCM自带认证防篡改派生函数无直接用随机密钥HKDF-SHA256 派生加密/签名两把密钥服务端状态无状态只靠 RSA 私钥有状态会话密钥存 Redis2 小时过期前向安全❌ RSA 私钥泄露历史抓包全部可解✅ 临时密钥用完销毁长期密钥泄露不影响历史性能每条消息一次 RSA 运算握手一次 ECDH之后全是对称运算前向安全Forward Secrecy是 ECDH 最核心的优势即使未来服务器的长期秘密泄露攻击者也无法解密以前记录下来的流量因为每个历史会话用的都是一次性、已销毁的临时密钥。2.2 用调颜料理解 ECDH这是理解 ECDH 最直观的比喻1. 双方公开约定一个起始颜色黄色——对应公开的曲线参数 P-256 2. 小明私选红色小红私选蓝色——对应各自的临时私钥永不公开 3. 小明把 黄红 混成橙色寄出小红把 黄蓝 混成绿色寄出 ——对应交换公钥公钥被看到无所谓 4. 偷窥者能看到黄色、橙色、绿色但颜料混合容易拆分不可能 无法反推出红或蓝——对应椭圆曲线上的离散对数难题 5. 小明在绿色里加自己的红小红在橙色里加自己的蓝 小明黄蓝红 小红黄红蓝 双方得到完全相同的颜色——对应 32 字节共享密钥关键点共享密钥从未在网络上出现过是双方各自在本地算出来的。2.3 完整流程示意图【阶段一握手打开网站时一次】 浏览器(Web Crypto) 服务器(PHP) │ ① 生成临时 ECDH P-256 密钥对 │ │ ② POST /api/crypto/session │ ③ 生成服务端临时密钥对 │ { clientPublicKey: base64(SPKI) } │ ECDH 算出共享密钥 │ ───────────────────────────────────► │ HKDF 派生 aesKey/hmacKey │ │ 密钥存 Redis生成 sessionId │ ④ { sessionId, serverPublicKey } │ │ ◄─────────────────────────────────── │ │ ⑤ ECDH 算出同一个共享密钥 │ │ ⑥ HKDF 派生两把密钥本地缓存 │ 【阶段二业务请求每次】 │ Header: X-Crypto-Session: id │ │ 请求体明文 JSONHTTPS 保护 │ │ ───────────────────────────────────► │ 正常处理业务 │ │ AES-256-GCM 加密 data │ │ HMAC 签名 时间戳 │ Header: X-Encrypted: 2 │ │ { code, msg, data:base64密文信封 } │ │ ◄─────────────────────────────────── │ │ ⑦ 验 HMAC → AES-GCM 解密 → 渲染 │我们做了一个取舍只加密响应不加密请求。请求是用户自己发出的数据页码、搜索词由 HTTPS 保护即可响应里才是有价值的业务数据。少一个方向复杂度大幅降低。三、前置准备与环境检查PHP8.0核心函数openssl_pkey_derive从 7.3.1 起提供8.x 最稳妥开启扩展openssl、mbstring可选redis前端支持Web Crypto API的浏览器页面必须运行在https 或 localhost安全上下文不需要生成任何密钥文件不需要 Composer 第三方加密库先确认环境php -m | grep -i openssl php -r var_dump(PHP_VERSION_ID, function_exists(openssl_pkey_derive), function_exists(hash_hkdf)); # 期望: int(80xxx) bool(true) bool(true)Windows / phpstudy 特别注意openssl_pkey_new()在 Windows 下依赖openssl.cnf配置文件如果报error:0E065068:configuration file routines之类错误需要在php.ini中指定ini复制代码openssl.cafileD:/phpstudy_pro/Extensions/php/php8.x.x/extras/ssl/cacert.pem并确认同目录下有可用的openssl.cnfPHP 会按默认路径查找。Linux 环境一般开箱即用。四、PHP 后端实现4.1 密钥协商与加密封装crypto.php新建crypto.php包含ECDH 握手、HKDF 派生、AES-GCM 信封加密、会话读写。?php // crypto.php —— ECDH(P-256) HKDF-SHA256 AES-256-GCM HMAC-SHA256 const CRYPTO_VERSION 2; const CRYPTO_KEY_LEN 32; // AES-256 / HMAC-SHA256 密钥长度 const CRYPTO_IV_LEN 12; // GCM 标准 nonce const CRYPTO_TAG_LEN 16; // GCM 认证标签 const CRYPTO_MAC_LEN 32; const CRYPTO_SESSION_TTL 7200; // 2 小时 const CRYPTO_LABEL_AES video-aes-v2; // HKDF 派生标签需与前端一致 const CRYPTO_LABEL_HMAC video-hmac-v2; /** 把前端传来的 base64(SPKI DER) 公钥包装成 openssl 可用的 PEM */ function der_to_pem(string $der, string $type PUBLIC KEY): string { return -----BEGIN {$type}-----\n . chunk_split(base64_encode($der), 64, \n) . -----END {$type}-----\n; } /** 把 PEM 公钥转回 base64(SPKI DER)下发给前端 */ function pem_to_der_b64(string $pem): string { $b64 str_replace([-----BEGIN PUBLIC KEY-----, -----END PUBLIC KEY-----, \r, \n], , $pem); return $b64; } /** * 握手用客户端公钥完成 ECDH返回 [sessionId, 服务端公钥(base64 SPKI)] * 同时把派生密钥写入 Redis */ function crypto_create_session(Redis $redis, string $clientPubB64): array { $clientDer base64_decode($clientPubB64, true); if ($clientDer false) { throw new InvalidArgumentException(客户端公钥格式错误); } // 1. 导入客户端 P-256 公钥SPKI PEM $clientPub openssl_pkey_get_public(der_to_pem($clientDer)); if ($clientPub false) { throw new RuntimeException(客户端公钥导入失败 . openssl_error_string()); } $clientDetails openssl_pkey_get_details($clientPub); if (($clientDetails[ec][curve_name] ?? ) ! prime256v1) { throw new InvalidArgumentException(仅支持 P-256(prime256v1) ECDH 公钥); } // 2. 服务端生成【临时】P-256 密钥对每次握手新建用完即弃 $serverKey openssl_pkey_new([ private_key_type OPENSSL_KEYTYPE_EC, curve_name prime256v1, // 即 secp256r1 / P-256 ]); if ($serverKey false) { throw new RuntimeException(服务端密钥对生成失败 . openssl_error_string()); } $serverDetails openssl_pkey_get_details($serverKey); // 3. ECDH 协商共享密钥32 字节。双方结果一致但密钥永不上网 // 注意参数顺序openssl_pkey_derive(对方公钥, 本方私钥 [, 长度]) $shared openssl_pkey_derive($clientPub, $serverKey, CRYPTO_KEY_LEN); if ($shared false || strlen($shared) ! CRYPTO_KEY_LEN) { throw new RuntimeException(ECDH 协商失败 . openssl_error_string()); } // 4. HKDF-SHA256 派生两把用途独立的密钥 // 第 5 个参数 salt 传 等价于 RFC5869 的「全 0 salt」与 Go hkdf.New(.,.,nil,.) 一致 $aesKey hash_hkdf(sha256, $shared, CRYPTO_KEY_LEN, CRYPTO_LABEL_AES, ); $hmacKey hash_hkdf(sha256, $shared, CRYPTO_KEY_LEN, CRYPTO_LABEL_HMAC, ); // 5. 生成 sessionId16 随机字节 32 位十六进制密钥写 Redis $sessionId bin2hex(random_bytes(16)); $redis-setex(crypto:session: . $sessionId, CRYPTO_SESSION_TTL, json_encode([ a base64_encode($aesKey), h base64_encode($hmacKey), ])); // 6. 服务端公钥以 base64(SPKI DER) 返回details[key] 是公钥 PEM return [$sessionId, pem_to_der_b64($serverDetails[key])] } /** 读取会话密钥命中即滑动续期。返回 [aes..,hmac..] 或 null */ function crypto_get_session(Redis $redis, string $sessionId): ?array { if ($sessionId ) { return null; } $raw $redis-get(crypto:session: . $sessionId); if (!$raw) { return null; } $j json_decode($raw, true); if (!isset($j[a], $j[h])) { return null; } // 滑动过期每次访问重置 TTL $redis-expire(crypto:session: . $sessionId, CRYPTO_SESSION_TTL); return [aes base64_decode($j[a]), hmac base64_decode($j[h])]; } /** * 加密响应明文输出 base64 信封 * 布局version(1) | iv(12) | 密文长度(4,大端) | GCM密文(含16字节tag) * | HMAC(32) | 时间戳(8,大端) | MD5(16) */ function crypto_encrypt_b64(array $keys, string $plaintext): string { $iv random_bytes(CRYPTO_IV_LEN); // 12 字节随机 nonce每次必须不同 // AES-256-GCMPHP 的 $tag 是单独输出的需要手动拼到密文尾部 // Go gcm.Seal、WebCrypto decrypt 都约定 tag 跟在密文后面 $tag ; $cipherRaw openssl_encrypt( $plaintext, aes-256-gcm, $keys[aes], OPENSSL_RAW_DATA, $iv, $tag, , CRYPTO_TAG_LEN ); if ($cipherRaw false) { throw new RuntimeException(AES-GCM 加密失败 . crypto_openssl_error()); } $ciphertext $cipherRaw . $tag; // ★ 关键补齐 GCM tag $md5 md5($plaintext, true); // 仅作校验辅助 $ts pack(J, (int)(microtime(true) * 1000)); // 8 字节大端毫秒时间戳防重放 $mac hash_hmac(sha256, $iv . $ciphertext . $ts . $md5, $keys[hmac], true); $envelope chr(CRYPTO_VERSION) . $iv . pack(N, strlen($ciphertext)) // 4 字节大端长度 . $ciphertext . $mac . $ts . $md5; return base64_encode($envelope); } /** 汇总 openssl 错误队列函数名不要与内置 openssl_error_string 同名否则会报无法重复声明 */ function crypto_openssl_error(): string { $msg ; while ($e openssl_error_string()) { $msg . $e . ; ; } return $msg; }注意自定义错误辅助函数不要命名为openssl_error_string()——当环境中已存在该内置函数时会直接报 Cannot redeclare function 致命错误所以示例里用了crypto_openssl_error()这个名字。信封结构图前后端约定的协议┌────────┬──────┬──────────┬──────────────┬──────────┬────────┬─────────┐ │ 版本号 │ IV │ 密文长度 │ AES-GCM 密文 │ HMAC签名 │ 时间戳 │ MD5摘要 │ │ 1 字节 │12字节│ 4字节BE │ 变长(含tag) │ 32字节 │ 8字节 │ 16字节 │ └────────┴──────┴──────────┴──────────────┴──────────┴────────┴─────────┘4.2 握手接口session.php?php // api/crypto/session.php —— 公开接口无需登录 require __DIR__ . /../crypto.php; header(Content-Type: application/json; charsetutf-8); function json_out(int $code, string $msg, $data null): void { echo json_encode([code $code, msg $msg, data $data], JSON_UNESCAPED_UNICODE); exit; } try { $body json_decode(file_get_contents(php://input), true); $clientPubB64 $body[clientPublicKey] ?? ; if (!$clientPubB64) { json_out(1, 请求参数错误); } $redis new Redis(); $redis-connect(127.0.0.1, 6379, 3); [$sessionId, $serverPubB64] crypto_create_session($redis, $clientPubB64); json_out(0, success, [ sessionId $sessionId, serverPublicKey $serverPubB64, ]); } catch (Throwable $e) { json_out(500, 加密会话建立失败 . $e-getMessage()); }4.3 响应加密中间件思路只加密 data 字段项目所有接口统一返回{code, msg, data}。我们只加密datacode/msg保持明文前端要靠 code 处理业务错误。不管你用原生 PHP、ThinkPHP 还是 Laravel核心都是同一个函数?php // crypto_response.php /** * 对统一响应信封做加密改写 * param string $body 控制器原本要输出的 JSON 字符串 * param string $sessionId 请求头 X-Crypto-Session * return string 处理后的 JSON 字符串 */ function crypto_encrypt_response(Redis $redis, string $body, string $sessionId): string { // 没带合法会话 → 明文降级管理后台/旧客户端/握手失败全部不受影响 $keys crypto_get_session($redis, $sessionId); if (!$keys) { return $body; } $env json_decode($body, true); if (!is_array($env) || !array_key_exists(data, $env)) { return $body; // 非统一信封原样放行 } // 把 data 重新序列化为 JSON 字节后加密 $rawData json_encode($env[data], JSON_UNESCAPED_UNICODE); $env[data] crypto_encrypt_b64($keys, $rawData); // 告知前端本响应需要解密 header(X-Encrypted: 2); return json_encode($env, JSON_UNESCAPED_UNICODE); }原生 PHP 集成方式用输出缓冲包住控制器?php // 入口 index.php 的统一出口处 ob_start(); require __DIR__ . /controllers/ . $controller . .php; // 控制器正常 echo JSON $output ob_get_clean(); $sessionId $_SERVER[HTTP_X_CRYPTO_SESSION] ?? ; // 握手接口自身要在此豁免 if (parse_url($_SERVER[REQUEST_URI], PHP_URL_PATH) ! /api/crypto/session) { $output crypto_encrypt_response($redis, $output, $sessionId); } echo $output;Laravel 集成方式写一个中间件在$response-send()前改写$response-setContent(...)并header(X-Encrypted: 2)ThinkPHP 用$response-setContent()钩子同理。避坑点 1握手接口必须豁免否则陷入还没握手就要解密的死循环。避坑点 2明文降级原则。逻辑是带了合法会话才加密没带就明文加密绝不能把正常业务搞挂。避坑点 3不要加密文件流/SSE。输出缓冲会把整个响应攒在内存里大文件下载、视频流要走独立链路短时效签名 URL不要套这个 JSON 加密。五、前端 JS 实现Web Crypto API前端不引入任何第三方库全部用浏览器原生crypto.subtle。uni-app / 普通 H5 通用和 PHP 端严格按同一协议对接。5.1 握手与密钥派生const LABEL_AES video-aes-v2 const LABEL_HMAC video-hmac-v2 let sessionId let aesKeyBytes null // Uint8Array(32) let hmacKeyBytes null const b64encode (u8) btoa(String.fromCharCode(...u8)) const b64decode (s) Uint8Array.from(atob(s), c c.charCodeAt(0)) async function handshake() { const subtle crypto.subtle // 1. 客户端临时 ECDH P-256 密钥对 const keyPair await subtle.generateKey( { name: ECDH, namedCurve: P-256 }, false, [deriveBits] ) const clientSpki new Uint8Array(await subtle.exportKey(spki, keyPair.publicKey)) // 2. 请求服务器换回 sessionId 和服务端公钥 const res await fetch(/api/crypto/session, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ clientPublicKey: b64encode(clientSpki) }) }).then(r r.json()) const serverSpki b64decode(res.data.serverPublicKey) // 3. ECDH 协商共享密钥 const serverKey await subtle.importKey( spki, serverSpki, { name: ECDH, namedCurve: P-256 }, false, [] ) const shared new Uint8Array( await subtle.deriveBits({ name: ECDH, public: serverKey }, keyPair.privateKey, 256) ) // 4. HKDF-SHA256 派生两把密钥salt 为空info 为标签——与 PHP hash_hkdf 完全对应 const hkdfBase await subtle.importKey(raw, shared, HKDF, false, [deriveBits]) const enc new TextEncoder() const derive (label) subtle.deriveBits({ name: HKDF, hash: SHA-256, salt: new Uint8Array(0), info: enc.encode(label) }, hkdfBase, 256) aesKeyBytes new Uint8Array(await derive(LABEL_AES)) hmacKeyBytes new Uint8Array(await derive(LABEL_HMAC)) sessionId res.data.sessionId }5.2 解密响应信封function concatBytes(parts) { const out new Uint8Array(parts.reduce((n, p) n p.length, 0)) let o 0 for (const p of parts) { out.set(p, o); o p.length } return out } function timingSafeEqual(a, b) { if (a.length ! b.length) return false let diff 0 for (let i 0; i a.length; i) diff | a[i] ^ b[i] return diff 0 } async function decryptEnvelope(envelopeB64) { const subtle crypto.subtle const buf b64decode(envelopeB64) if (buf[0] ! 2) throw new Error(不支持的加密信封版本) // 按与 PHP 相同的布局切分DataView 默认大端等价于 PHP pack(N/J) const view new DataView(buf.buffer, buf.byteOffset, buf.byteLength) const iv buf.subarray(1, 13) const ctLen view.getUint32(13) let off 17 const ciphertext buf.subarray(off, off ctLen); off ctLen const mac buf.subarray(off, off 32); off 32 const timestamp buf.subarray(off, off 8); off 8 const md5Digest buf.subarray(off, off 16) // ① 先验 HMAC 签名不一致直接拒绝防伪造/防篡改 const hmacKey await subtle.importKey( raw, hmacKeyBytes, { name: HMAC, hash: SHA-256 }, false, [sign] ) const expectedMac new Uint8Array(await subtle.sign( HMAC, hmacKey, concatBytes([iv, ciphertext, timestamp, md5Digest]) )) if (!timingSafeEqual(new Uint8Array(mac), expectedMac)) { throw new Error(响应签名校验失败) } // ② AES-256-GCM 解密密文尾部自带 GCM tag const aesKey await subtle.importKey( raw, aesKeyBytes, { name: AES-GCM }, false, [decrypt] ) const plain await subtle.decrypt( { name: AES-GCM, iv: new Uint8Array(iv) }, aesKey, new Uint8Array(ciphertext) ) return new TextDecoder().decode(plain) // 返回 data 原始 JSON 字符串 }5.3 接入请求封装自动带头、自动解密、失败重握手async function request(options) { // 确保会话存在失败则返回空串明文降级不阻断业务 let sid try { sid await ensureSession() } catch (e) { console.warn([Crypto] 会话建立失败明文访问:, e.message) } const res await rawRequest({ ...options, header: { ...options.header, ...(sid ? { X-Crypto-Session: sid } : {}) } }) if (res.statusCode 200) { try { res.data await decryptIfNeeded(res, res.data) } catch (e) { // 解密失败常见于 Redis 重启导致会话失效清会话→重握手→重发一次 clearSession() await ensureSession() if (!options._retried) return request({ ...options, _retried: true }) throw e } } return res.data } // 只有响应头 X-Encrypted: 2 才解密 data async function decryptIfNeeded(res, body) { if (res.header[X-Encrypted] ! 2) return body const plain await decryptEnvelope(body.data) return { ...body, data: JSON.parse(plain) } }ensureSession()建议做三级回退内存态 → 本地缓存sessionStorage→ 重新握手并用一个共享的handshakePromise防止页面初始化时几十个并发请求同时触发多次握手。六、工业级落地避坑指南6.1 PHP 特有的坑重点前端给的是裸 base64(SPKI DER)openssl 只认 PEM。必须先加上-----BEGIN PUBLIC KEY-----头尾见der_to_pem直接把 base64 喂给openssl_pkey_get_public会失败。GCM 的 tag 在 PHP 里是单独输出的。openssl_encrypt()返回的密文不含tag必须手动$cipherRaw . $tag拼在尾部才能和 WebCrypto/Go 的密文含 tag约定对上否则前端解密必然报OperationError。openssl_pkey_derive参数顺序是对方公钥, 本方私钥写反了会得到错误结果或直接报错。大端打包格式长度用pack(N)32 位无符号大端毫秒时间戳用pack(J)64 位无符号大端需要 64 位 PHP对应 JSDataView的默认大端千万不要用成V/P小端。HKDF 的 salt 要传空字符串而不是nullRFC 5869 规定 salt 缺省时用哈希长度的全 0PHP 传正是这个行为与浏览器new Uint8Array(0)、Gonilsalt 完全一致。Windows/phpstudy 配置 openssl.cnf见第三节否则openssl_pkey_new可能返回 false。二进制密钥存 Redis/JSON 前先 base64不要把原始 32 字节直接塞进 JSON会产生不可见字符导致长度变化、密钥损坏。random_bytes()是密码学安全随机源IV 和 sessionId 都用它禁止用rand()/uniqid()。6.2 密钥管理核心绝不硬编码对称密钥。新手常前后端写死一个 AES key前端代码对用户完全可见等于没加密。本方案所有密钥都是临时协商、暂存、2 小时自动销毁。会话密钥 TTL 滑动续期。即使 sessionId 泄露危害窗口也被限制在 2 小时内活跃用户每次请求自动续期不影响体验。Redis 设密码、不暴露公网没有 Redis 时可用文件/APCu 缓存兜底但多机部署会出现会话漂移。HKDF 标签区分用途加密密钥和签名密钥物理隔离一把钥匙只干一件事。6.3 算法参数选择项目选择说明曲线ECDHP-256prime256v1/secp256r1WebCrypto 支持最好三端命名不同但同一条曲线对称算法AES-256-GCM必须用 GCM/CCM 这类 AEAD别用裸 CBC无完整性校验IV12 字节随机每密文一换GCM 标准长度禁止固定 IV、禁止 IV 复用派生HKDF-SHA256hash_hkdfinfo 标签区分密钥签名HMAC-SHA256常量时间比较防篡改前端比较不能用时间戳毫秒纳入 HMAC防重放服务端可按需校验时间窗编码二进制信封整体 Base64避免二进制在 JSON 中丢失注意曲线的三端命名PHP/OpenSSL 叫prime256v1浏览器叫P-256Java 叫secp256r1是同一条曲线互通没有问题。6.4 兼容性与降级必须 HTTPS。crypto.subtle在非安全上下文http 且非 localhost下是undefined前端先判断能力不支持就明文降级。握手接口自身豁免加密。解密失败自动重握手 只重发一次应对 Redis 清空/PHP-FPM 重启后的会话失效防止死循环。管理端/第三方客户端不带会话头继续明文。并发请求共享同一个握手 Promise。6.5 性能ECDH 只在握手时做一次P-256 比 RSA-2048 快得多之后全部是对称运算单次加解密在亚毫秒级开销可忽略。输出缓冲会把整个 JSON 响应攒在内存里只适合普通接口大响应、文件流、SSE 不要套这层。七、本地自测用一行命令验证握手接口写完后可以用 Node.js 模拟浏览器验证 PHP 端是否正确Node 16 内置 WebCrypto// test-crypto.mjs —— node test-crypto.mjs https://你的域名 const base process.argv[2] || http://localhost:8080; const enc new TextEncoder(); const kp await crypto.subtle.generateKey({ name: ECDH, namedCurve: P-256 }, false, [deriveBits]); const clientPub btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.exportKey(spki, kp.publicKey)))); const r await fetch(base /api/crypto/session, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ clientPublicKey: clientPub }) }).then(r r.json()); console.log(握手响应:, r.code 0 ? OK : r, r.data?.sessionId); // 拿到 sessionId / serverPublicKey 后即可按 5.2 的逻辑解密业务接口再配合浏览器 F12 观察普通接口响应头出现X-Encrypted: 2、data为密文而页面正常渲染即说明整条链路打通。八、安全边界它能防什么、不能防什么做安全要诚实明确威胁模型比堆砌算法更重要。✅ 能防F12 直接看明文、curl/Postman 抓接口、简单脚本批量扒数据、被动链路抓包、密文篡改、响应重放。❌ 不能防无头浏览器Playwright/Puppeteer浏览器能解密自动化浏览器就能在解密之后那一层 hookfetch/JSON.parse直接拿明文。这是所有纯前端加密的固有死穴。前端 JS 逆向逻辑都在前端攻击者读完源码即可复刻握手。代码混淆只能提高成本不能根治。中间人替换 JS只能靠 HTTPS 防应用层加密本身防不住。因此它的准确定位是协议层反爬/反调试不是安全边界。进一步加固方向JS 混淆、验证码、设备指纹与行为风控、接口限流对视频等真正值钱的资产用短时效签名 URL / HLS 分片加密单独保护。九、实际应用场景Web/H5 API 响应防爬课程、商品、资讯等列表/详情数据提升批量抓取门槛本文场景。敏感字段传输加固对手机号、证件号等字段做端到端加密即使内部链路被审计也无明文。需要前向安全的长连接WebSocket 会话密钥定期用 ECDH 轮换rekey历史消息即使长期密钥泄露也无法解密。多语言互通协议只约定SPKI 公钥 JSON 字段名 二进制信封布局PHP/Go/Java/Python 后端只要按同一格式实现即可互通。十、与 AESRSA 方案怎么选选 ECDH本文长期运行的 API 会话、需要前向安全、不想保管 RSA 私钥文件、愿意引入会话存储Redis。选 RSA上一篇要求服务端完全无状态多实例不共享存储、一次性单报文加密、加密上行敏感数据、运行环境不支持现代加密 API。两者都是工业标准TLS 1.2 主要是 RSA 密钥传输思路TLS 1.3 已全面转向 ECDHE 临时协商——这也是本文方案的方向。十一、总结接口层混合加密的核心是ECDH 协商密钥 HKDF 派生 AES-GCM 加密数据 HMAC 防篡改密钥从不在网络上传输ECDH 临时密钥对带来前向安全这是相比 RSA 密钥传输最大的安全升级PHP 落地的关键细节DER↔PEM 转换、GCM tag 手动拼接、pack(N/J)大端、hash_hkdf空 salt、Windows 的 openssl.cnf工程上做好握手豁免、会话 TTL、失败降级与重握手认清防随手抓接口、不防驱动浏览器的边界高价值资产再配合签名 URL、风控分层防护。希望这套方案对你的项目有帮助 :)技术栈PHP 8.x / OpenSSLECDH prime256v1、AES-256-GCM、HMAC-SHA256、hash_hkdf Redis Web Crypto APIECDH P-256、HKDF、AES-256-GCM、HMAC-SHA256