前端知库

用了 HTTPS 还要在前端加密吗?接口加签和防重放怎样设计?

HTTPS 已经保证传输中的数据不被偷看和篡改,前端再加密一层防不住用户自己、恶意脚本和拿到代码的人,因为前端代码和里面的密钥都会被下载到用户手里。接口签名是按约定把参数、时间戳和随机 nonce 拼成字符串算签名,服务端校验签名、拒绝过期时间戳和重复 nonce,用来防篡改和防重放。能签名的密钥必须在服务端或用户自己的会话里,不能写死在前端代码中。

参考答案

简答

常有人问:登录接口的密码要不要在前端先用 MD5 或 RSA 加密一下再发?如果网站已经是 HTTPS,传输这一段本来就是加密的,OWASP 的说法是 TLS 提供机密性(别人读不到)和完整性(别人改不了、也不能把截获的请求重放给服务器)。

前端再加一层,防不住真正的风险。加密代码和密钥都会下载到浏览器里,用户自己、页面里的恶意脚本、拿到代码的人都能直接调用它,伪造出“合法”的密文。Vite 文档也明确说,VITE_ 开头的环境变量会被打包进前端代码,不能放 API 密钥。

接口加签解决的是另一件事:在 HTTPS 之外,让服务端确认请求是持有密钥的一方发出的、参数没有被改过、而且不是旧请求的重放。做法是把方法、路径、排序后的参数、时间戳和一个随机 nonce 拼成固定格式的字符串,用密钥算 HMAC 签名。服务端用同样的规则重算,签名不一致就拒绝;时间戳超出允许窗口、或者 nonce 在窗口内出现过,也拒绝。

关键是密钥放在哪里。签名的价值全在于攻击者拿不到密钥,所以服务端之间调用时密钥放在服务端;浏览器里调用时,密钥只能是登录后下发给这个用户、能随时吊销的会话密钥,不能是写死在代码里、所有用户共用的固定密钥。

HTTPS 已经解决了什么

OWASP 的 TLS 速查表列出了 TLS 正确使用时的三项保障:机密性,攻击者读不到流量内容;完整性,攻击者不能篡改流量,也不能把截获的请求重放给服务器;认证,客户端能确认连上的是真正的服务器。所以“传输途中被人看到密码”“请求在路上被改掉”这两类担心,HTTPS 已经处理了。中间人是怎样被防住的,见《HTTPS 握手中,中间人攻击是怎样被防住的?》。

OWASP 同时提醒,客户端接受了攻击者的证书时,攻击者就能读到数据。这种情况下前端加密也帮不上忙,因为攻击者既然能当中间人,也能把下发的加密脚本替换掉。

前端加密为什么防不了

问题在于前端的一切都在用户手里。加密用的算法、公钥或者对称密钥,都随 JS 下载到浏览器,打开开发者工具就能看到。用户自己想绕过限制,可以直接调用页面里的加密函数;页面里混入了恶意脚本,也能在加密之前读到明文。所以前端加密既挡不住用户,也挡不住 XSS。

Vite 的文档把这件事说得很具体:VITE_ 前缀的变量在构建时打包进前端代码,不应该包含 API 密钥这类敏感信息,生产环境应该交给后端服务或者 serverless、边缘函数来保护密钥。其他构建工具的前端环境变量也是同样的道理。

签名怎样防篡改和重放

签名的思路是:双方约定好一个密钥和一套拼接规则,发送方把请求的关键内容拼成字符串,用密钥算出 HMAC,作为签名一起发出。HMAC 是一种带密钥的摘要,没有密钥算不出正确的值,内容改了一个字符结果就完全不同。

拼接规则必须是确定的。同样的参数,不管对象里的键是什么顺序,都要拼出同样的字符串,否则双方算出来的签名对不上。所以通常按键名排序后再拼接。

光有签名还挡不住重放:攻击者把一个完整的合法请求原样再发一遍,签名照样正确。所以签名里还要带上时间戳和 nonce。RFC 5849(OAuth 1.0)对这两个字段的解释可以直接借用:nonce 是客户端生成的唯一随机串,服务端用它判断请求以前没出现过,从而防止重放;为了不用永久保存所有 nonce,服务端可以拒绝时间戳太旧的请求,只记住时间窗口内出现过的 nonce,这要求客户端和服务端的时钟大致同步。

下面的例子模拟服务端的校验流程。签名函数换成了一个假的 fakeHmac,只为了让例子不依赖加密库就能运行,真实场景要用 HMAC-SHA256:

JavaScript
function canonical({ method, path, params, timestamp, nonce }) {
  const query = Object.keys(params)
    .sort()
    .map((key) => key + '=' + params[key])
    .join('&');
  return [method, path, query, timestamp, nonce].join('\n');
}

const fakeHmac = (key, text) => key + ':' + text.length;

function createVerifier(key, windowSeconds) {
  const seen = new Set();
  return function verify(request, now) {
    if (Math.abs(now - request.timestamp) > windowSeconds) return 'expired';
    if (seen.has(request.nonce)) return 'replayed';
    if (fakeHmac(key, canonical(request)) !== request.signature) return 'bad-signature';
    seen.add(request.nonce);
    return 'ok';
  };
}

const a = canonical({ method: 'POST', path: '/pay', params: { b: 2, a: 1 }, timestamp: 100, nonce: 'n1' });
const b = canonical({ method: 'POST', path: '/pay', params: { a: 1, b: 2 }, timestamp: 100, nonce: 'n1' });
console.log(a === b ? 'same' : 'different'); // same

const verify = createVerifier('k', 300);
const request = { method: 'POST', path: '/pay', params: { amount: 100 }, timestamp: 1000, nonce: 'n1' };
request.signature = fakeHmac('k', canonical(request));

console.log(verify(request, 1010)); // ok
console.log(verify(request, 1020)); // replayed
console.log(verify({ ...request, nonce: 'n2' }, 2000)); // expired
console.log(verify({ ...request, nonce: 'n3', params: { amount: 1000 } }, 1030)); // bad-signature

第一行说明了为什么要排序:参数对象写成 { b: 2, a: 1 } 还是 { a: 1, b: 2 },排序后拼出的字符串一样,双方算的签名才对得上。

后面四行对应四种情况。第一次请求通过,并把 n1 记下来;同一个请求再发一次,nonce 已经见过,判为重放;换了 nonce 但时间戳离现在超过 300 秒,判为过期;把金额从 100 改成 1000,签名对不上。注意校验的顺序:签名通过以后才记录 nonce,否则攻击者随便发一些签名错误的请求,就能把正常请求要用的 nonce 占掉。

为什么时间戳也要放进签名里?如果不签,攻击者可以把旧请求的时间戳改成现在,绕过过期检查。时间戳和 nonce 都参与签名,改了任何一个,签名就不对了。

浏览器里生成 nonce 可以用 crypto.randomUUID(),MDN 说明它用密码学安全的随机数生成器生成 v4 UUID。算 HMAC 可以用 Web Crypto 的 crypto.subtle.sign:

JavaScript
async function sign(key, text) {
  // key 是 importKey 导入的 HMAC 密钥,算法为 HMAC + SHA-256
  const data = new TextEncoder().encode(text);
  const signature = await crypto.subtle.sign('HMAC', key, data);
  return btoa(String.fromCharCode(...new Uint8Array(signature)));
}

const nonce = crypto.randomUUID();
const timestamp = Math.floor(Date.now() / 1000);

密钥应该放在哪里

签名有用的前提是攻击者没有密钥。把一个所有用户共用的固定密钥写进前端代码,任何人都能从打包后的 JS 里找到它,然后给任意请求算出正确的签名,签名就失去了意义。混淆代码只能增加查找的时间,不能让密钥保密。

所以要看调用方是谁。服务端调用开放平台接口时,密钥放在服务端的环境变量或密钥管理服务里,浏览器完全接触不到。浏览器必须签名时,只能用和当前用户绑定的密钥:登录后由服务端下发,或者在浏览器里生成后把公钥登记到服务端,这样泄露了也只影响这一个用户,并且可以随时吊销。在浏览器里保存这类密钥,可以用 Web Crypto 生成不可导出的密钥,见《Web Crypto API 如何正确管理浏览器端密钥?》。

即使这样,签名防的也是“请求在别处被伪造或重放”,防不了用户本人。用户自己的浏览器里有自己的密钥,他总能签出自己想发的请求。所以金额、权限、库存这些规则,最终都要在服务端按业务校验,不能因为请求带了合法签名就相信里面的参数。

常见追问

登录密码用 RSA 在前端加密一下有没有用?

在 HTTPS 下,防窃听这件事 TLS 已经做了,前端再加密不增加这方面的保护。它也防不住页面里的恶意脚本,脚本可以在加密之前读到输入框里的明文。真正要做的是全站 HTTPS,服务端用 Argon2id、bcrypt 这类专门的密码哈希算法存储密码。

nonce 要在服务端保存多久?

保存时间和时间戳窗口一样长就够了。超出窗口的请求本来就会因为过期被拒绝,没必要再记它的 nonce。RFC 5849 也是这样解释的:服务端拒绝时间戳太旧的请求,就不用无限期地保存所有 nonce。

客户端时间不准怎么办?

时间戳校验要求双方时钟大致同步。RFC 5849 提到,服务端可以提供让客户端同步时钟的方式,或者双方都和可信的时间服务同步。前端常见的做法是服务端在响应里返回当前时间,客户端记下和本地时间的差值,生成时间戳时加上这个差值。