news 2026/9/30 3:02:36

php 实现【ECDH + AES-GCM】接口数据加密全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
php 实现【ECDH + AES-GCM】接口数据加密全流程

上一篇我们讲了经典的 AES + RSA 混合加密:发送方生成 AES 密钥加密数据,再用 RSA 公钥加密 AES 密钥传给接收方。

这篇介绍一种更现代的方案——ECDH 密钥协商 + AES-GCM 会话加密,也是 TLS 1.3 使用的核心思路。与 RSA"把密钥包起来寄过去"不同,ECDH 能让通信双方在网络上完全不传输密钥的前提下,各自算出同一把密钥,并天然具备前向安全。

本文使用PHP 8(openssl 扩展)+ 原生 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 私钥有状态(会话密钥存 Redis,2 小时过期)
前向安全❌ 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> │ │ 请求体:明文 JSON(HTTPS 保护) │ │ ───────────────────────────────────► │ 正常处理业务 │ │ 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.cafile="D:/phpstudy_pro/Extensions/php/php8.x.x/extras/ssl/cacert.pem"

并确认同目录下有可用的openssl.cnf(PHP 会按默认路径查找)。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. 生成 sessionId(16 随机字节 = 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-GCM:PHP 的 $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; charset=utf-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}。我们只加密data,code/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 特有的坑(重点)

  1. 前端给的是裸 base64(SPKI DER),openssl 只认 PEM。必须先加上-----BEGIN PUBLIC KEY-----头尾(见der_to_pem),直接把 base64 喂给openssl_pkey_get_public会失败。
  2. GCM 的 tag 在 PHP 里是单独输出的。openssl_encrypt()返回的密文不含tag,必须手动$cipherRaw . $tag拼在尾部,才能和 WebCrypto/Go 的"密文含 tag"约定对上,否则前端解密必然报OperationError。
  3. openssl_pkey_derive参数顺序是(对方公钥, 本方私钥),写反了会得到错误结果或直接报错。
  4. 大端打包格式:长度用pack('N')(32 位无符号大端),毫秒时间戳用pack('J')(64 位无符号大端,需要 64 位 PHP),对应 JSDataView的默认大端;千万不要用成V/P(小端)。
  5. HKDF 的 salt 要传空字符串''而不是null:RFC 5869 规定 salt 缺省时用"哈希长度的全 0",PHP 传''正是这个行为,与浏览器new Uint8Array(0)、Gonilsalt 完全一致。
  6. Windows/phpstudy 配置 openssl.cnf(见第三节),否则openssl_pkey_new可能返回 false。
  7. 二进制密钥存 Redis/JSON 前先 base64,不要把原始 32 字节直接塞进 JSON,会产生不可见字符导致长度变化、密钥损坏。
  8. random_bytes()是密码学安全随机源,IV 和 sessionId 都用它,禁止用rand()/uniqid()。

6.2 密钥管理(核心)

  1. 绝不硬编码对称密钥。新手常前后端写死一个 AES key,前端代码对用户完全可见,等于没加密。本方案所有密钥都是临时协商、暂存、2 小时自动销毁。
  2. 会话密钥 TTL + 滑动续期。即使 sessionId 泄露,危害窗口也被限制在 2 小时内;活跃用户每次请求自动续期不影响体验。
  3. Redis 设密码、不暴露公网;没有 Redis 时可用文件/APCu 缓存兜底,但多机部署会出现会话漂移。
  4. HKDF 标签区分用途,加密密钥和签名密钥物理隔离(一把钥匙只干一件事)。

6.3 算法参数选择

项目选择说明
曲线ECDHP-256(prime256v1/secp256r1)WebCrypto 支持最好,三端命名不同但同一条曲线
对称算法AES-256-GCM必须用 GCM/CCM 这类 AEAD,别用裸 CBC(无完整性校验)
IV12 字节随机,每密文一换GCM 标准长度;禁止固定 IV、禁止 IV 复用
派生HKDF-SHA256(hash_hkdf)info 标签区分密钥
签名HMAC-SHA256,常量时间比较防篡改;前端比较不能用===
时间戳毫秒,纳入 HMAC防重放,服务端可按需校验时间窗
编码二进制信封整体 Base64避免二进制在 JSON 中丢失

注意曲线的三端命名:PHP/OpenSSL 叫prime256v1,浏览器叫P-256,Java 叫secp256r1,是同一条曲线,互通没有问题。

6.4 兼容性与降级

  1. 必须 HTTPS。crypto.subtle在非安全上下文(http 且非 localhost)下是undefined,前端先判断能力,不支持就明文降级。
  2. 握手接口自身豁免加密。
  3. 解密失败自动重握手 + 只重发一次,应对 Redis 清空/PHP-FPM 重启后的会话失效,防止死循环。
  4. 管理端/第三方客户端不带会话头,继续明文。
  5. 并发请求共享同一个握手 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 分片加密单独保护。

九、实际应用场景

  1. Web/H5 API 响应防爬:课程、商品、资讯等列表/详情数据,提升批量抓取门槛(本文场景)。
  2. 敏感字段传输加固:对手机号、证件号等字段做端到端加密,即使内部链路被审计也无明文。
  3. 需要前向安全的长连接:WebSocket 会话密钥定期用 ECDH 轮换(rekey),历史消息即使长期密钥泄露也无法解密。
  4. 多语言互通:协议只约定"SPKI 公钥 + JSON 字段名 + 二进制信封布局",PHP/Go/Java/Python 后端只要按同一格式实现即可互通。

十、与 AES+RSA 方案怎么选

  • 选 ECDH(本文):长期运行的 API 会话、需要前向安全、不想保管 RSA 私钥文件、愿意引入会话存储(Redis)。
  • 选 RSA(上一篇):要求服务端完全无状态(多实例不共享存储)、一次性单报文加密、加密上行敏感数据、运行环境不支持现代加密 API。

两者都是工业标准:TLS 1.2 主要是 RSA 密钥传输思路,TLS 1.3 已全面转向 ECDHE 临时协商——这也是本文方案的方向。

十一、总结

  1. 接口层混合加密的核心是ECDH 协商密钥 + HKDF 派生 + AES-GCM 加密数据 + HMAC 防篡改,密钥从不在网络上传输;
  2. ECDH 临时密钥对带来前向安全,这是相比 RSA 密钥传输最大的安全升级;
  3. PHP 落地的关键细节:DER↔PEM 转换、GCM tag 手动拼接、pack('N'/'J')大端、hash_hkdf空 salt、Windows 的 openssl.cnf;
  4. 工程上做好:握手豁免、会话 TTL、失败降级与重握手,认清"防随手抓接口、不防驱动浏览器"的边界,高价值资产再配合签名 URL、风控分层防护。

希望这套方案对你的项目有帮助 :)

技术栈:PHP 8.x / OpenSSL(ECDH prime256v1、AES-256-GCM、HMAC-SHA256、hash_hkdf)+ Redis + Web Crypto API(ECDH P-256、HKDF、AES-256-GCM、HMAC-SHA256)

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 3:01:45

CTF夺旗赛全题型解题指南:Web渗透、逆向、密码学与杂项实战

简介&#xff1a;这是一份面向CTF入门与进阶选手的题型梳理文档&#xff0c;围绕网络安全竞赛中常见的Web、密码学、逆向、PWN与杂项五大方向&#xff0c;系统整理了解题思路与关键知识点。内容涵盖基础爆破、SQL注入与报错注入、文件上传与包含、代码审计&#xff0c;以及替换…

作者头像 李华
网站建设 2026/9/30 2:59:59

AIGC检测原理与10款实测降AI工具:从困惑度到人工重写

1. 先弄明白&#xff1a;AIGC检测到底在揪什么1.1 三个核心指标&#xff1a;困惑度、突发度与句式指纹2025年&#xff0c;很多本科生收到论文送审或者软著补正通知时&#xff0c;都会被一句话卡住&#xff1a;“AIGC检出率高”。我见过太多人拿着通知单一头雾水&#xff0c;以为…

作者头像 李华
网站建设 2026/9/30 2:59:55

从MCP协议到实操:kiro配置谷歌浏览器调试全指南

很多人第一次听到“kiro 配置谷歌浏览器调试的 MCP”这个说法时&#xff0c;第一反应是&#xff1a;这不就是让 AI 帮我写个脚本、打开网页看看效果吗&#xff1f;实际用过之后你会发现&#xff0c;事情没那么简单&#xff0c;但也比想象中有意思得多。kiro 这类 AI Agent 客户…

作者头像 李华
网站建设 2026/9/30 2:59:27

Unity ShaderGraph特效案例教程:从节点原理到移动端性能优化实战

简介&#xff1a;本资源为 Unity ShaderGraph 特效案例教程文档&#xff0c;面向具备一定 Unity 基础、希望掌握可视化着色器编程的游戏开发学习者与美术人员&#xff0c;帮助解决发光材质与动态特效的实现问题。包内共 1 个 docx 文件&#xff0c;约 16KB&#xff0c;内容以图…

作者头像 李华
网站建设 2026/9/30 2:58:40

赛事结算防偷鸡:幂等、Redis原子计数与状态机实践

相信不少玩竞技游戏的朋友都遇到过这种场面&#xff1a;你这边一路顺风&#xff0c;打出7杀&#xff0c;对面眼看就要崩盘&#xff0c;这局稳了&#xff0c;1000奖励几乎已经是囊中之物。结果对面一波gank突然提速&#xff0c;直接把节奏打乱&#xff0c;最后结算时反倒被“偷鸡…

作者头像 李华
网站建设 2026/9/30 2:58:09

Cisco IOS SIP语音网关配置与排障:从dial-peer到IMS对接

简介&#xff1a;面向网络工程师与VoIP运维人员的技术文档&#xff0c;介绍采用SIP协议的Cisco IOS语音网关在企业IP通信中的角色与部署要点。内容涵盖PSTN与IP网络之间的信令转换、SIP中继、PBX互联&#xff0c;以及QoS、呼叫准入控制、会话边界控制器、应急故障切换等关键特性…

作者头像 李华