拆解 2FA 认证器背后的三层加密

· 约 4 分钟读完

一个问题引出三层加密

云端 2FA 认证器要同时解决三件事:把用户密码变成可用的密钥、用密钥加密数据存到服务端、在本地实时算出 6 位验证码。这三件事分别对应三个独立的密码学机制——PBKDF2 密钥派生、AES-256-GCM 对称加密、HMAC-SHA1 动态口令。

密钥派生:PBKDF2

用户输入的密码通常很短,直接拿来做加密密钥强度不够,而且容易被彩虹表攻击。PBKDF2 的作用就是把一个弱密码”拉伸”成一个高强度的密钥。

核心参数有三个:盐(salt)、迭代次数、哈希算法。

const keyMaterial = await crypto.subtle.importKey(
  'raw',
  new TextEncoder().encode(password),
  'PBKDF2',
  false,
  ['deriveKey']
);

const key = await crypto.subtle.deriveKey(
  {
    name: 'PBKDF2',
    salt: salt,           // 16 字节随机值
    iterations: 600000,   // 迭代次数
    hash: 'SHA-256'
  },
  keyMaterial,
  { name: 'AES-GCM', length: 256 },
  true,
  ['encrypt', 'decrypt']
);

几个关键设计:

  • 盐(salt):16 字节的随机数据,每个用户独立生成。作用是让相同的密码派生出不同的密钥,彩虹表对它无效——攻击者必须针对每个盐单独计算。
  • 迭代次数:60 万次。每次派生都要跑 60 万轮 SHA-256,一个普通 CPU 核跑一次大约要几百毫秒。对用户来说几乎无感,但暴力破解者要为每个猜测付出同样的代价。
  • 派生结果:直接输出一个 AES-256 的 CryptoKey 对象,可以立刻拿来加解密,不需要再做任何转换。

实际应用中,同一个密码通常会派生两样东西:一个用固定盐生成的哈希值当”用户标识”(相同密码 = 相同标识 = 同一个账户),一个用随机盐生成的密钥做数据加密。前者让服务端能找到对应的数据,后者保证服务端解不开内容。

数据加密:AES-256-GCM

拿到 PBKDF2 派生的密钥后,就可以加密用户数据了。AES-256-GCM 是当前最主流的对称加密方案,256 位密钥长度、GCM 工作模式。

加密过程

async function encrypt(data, key) {
  // 每次加密生成一个新的随机 IV
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encoded = new TextEncoder().encode(JSON.stringify(data));

  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv: iv },
    key,
    encoded
  );

  // 把 IV 和密文拼在一起存储
  const result = new Uint8Array(iv.length + ciphertext.byteLength);
  result.set(iv);
  result.set(new Uint8Array(ciphertext), iv.length);
  return btoa(String.fromCharCode(...result));
}

解密过程

async function decrypt(encryptedData, key) {
  const combined = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0));
  // 前 12 字节是 IV,剩下的是密文
  const iv = combined.slice(0, 12);
  const ciphertext = combined.slice(12);

  const decrypted = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: iv },
    key,
    ciphertext
  );
  return JSON.parse(new TextDecoder().decode(decrypted));
}

为什么选 GCM 模式

AES 本身是分组密码,需要一个工作模式来处理任意长度的数据。常见的有 CBC、CTR、GCM 等,GCM 的优势在于自带认证(Authenticated Encryption):加密的同时会生成一个认证标签,解密时自动校验。如果密文被篡改过哪怕一个 bit,解密会直接报错,不需要额外加 HMAC。

IV(初始化向量) 是 12 字节的随机值,保证同样的明文每次加密出来的密文都不同。IV 不需要保密,所以直接拼在密文前面一起存储。

存到服务端的数据大概长这样:

字段内容
encryptedDataBase64 编码的 IV + 密文
saltBase64 编码的 16 字节随机盐
version加密版本号,用于未来升级迭代次数

服务端全程只看到密文和盐,没有密码、没有密钥、没有明文。

TOTP 生成:HMAC-SHA1

TOTP(基于时间的一次性密码)是 RFC 6238 定义的标准协议,Google Authenticator、Authy 等应用都遵循同一套算法。它跟前面两层加密是独立的——TOTP 处理的是”怎么用一个密钥和当前时间算出 6 位验证码”。

算法步骤

async function generateTOTP(secretBase32) {
  // 1. 时间戳除以 30 秒,得到一个递增计数器
  const counter = Math.floor(Date.now() / 30000);

  // 2. 计数器转成 8 字节大端序
  const counterBytes = new Uint8Array(8);
  let temp = counter;
  for (let i = 7; i >= 0; i--) {
    counterBytes[i] = temp & 0xff;
    temp = Math.floor(temp / 256);
  }

  // 3. 用 HMAC-SHA1 签名
  const keyData = base32Decode(secretBase32);
  const key = await crypto.subtle.importKey(
    'raw', keyData,
    { name: 'HMAC', hash: 'SHA-1' },
    false, ['sign']
  );
  const signature = await crypto.subtle.sign('HMAC', key, counterBytes);
  const hash = new Uint8Array(signature);

  // 4. 动态截断:取最后一个字节的低 4 位做偏移
  const offset = hash[hash.length - 1] & 0x0f;
  const binary =
    ((hash[offset] & 0x7f) << 24) |
    ((hash[offset + 1] & 0xff) << 16) |
    ((hash[offset + 2] & 0xff) << 8) |
    (hash[offset + 3] & 0xff);

  // 5. 取模得到 6 位数字
  return (binary % 1000000).toString().padStart(6, '0');
}

拆开来看:

第一步,时间窗口。当前 Unix 时间戳(毫秒)除以 30000,向下取整。这意味着每 30 秒 counter 变一次,所以验证码也 30 秒刷新一次。服务端验证时通常会接受前后各一个窗口,容忍轻微的时钟偏差。

第二步,HMAC-SHA1 签名。把 counter 的 8 字节大端表示作为消息,用密钥做 HMAC-SHA1,得到 20 字节的哈希值。这里用 SHA-1 不是因为它安全(SHA-1 的碰撞抗性早已被攻破),而是因为 HMAC 的安全性不依赖哈希函数的碰撞抗性,依赖的是它的 PRF(伪随机函数)性质,SHA-1 在这方面仍然足够。RFC 6238 也允许 SHA-256 和 SHA-512,但绝大多数服务只实现了 SHA-1。

第三步,动态截断。20 字节里只需要取 4 字节来算验证码。取哪 4 个?看最后一个字节的低 4 位(值 0-15),把它当作偏移量,从这个位置开始取 4 字节。这个设计让截断位置不可预测,增加了攻击难度。

第四步,取模。4 字节整数对 1000000 取模,左边补零到 6 位。

密钥从哪来

TOTP 的密钥通常是服务商在你开启两步验证时生成的,通过二维码(otpauth://totp/...?secret=JBSWY3DPEHPK3PXP)传给客户端。密钥用 Base32 编码,解码后是原始字节。这个密钥只在初始注册时传输一次,之后客户端和服务端各自保存一份,再也不需要网络通信就能独立算出相同的验证码。

三层怎么串起来

这三层解决的是两件不同的事。

前两层保护你自己的数据。你在各个网站开启两步验证时,会拿到一堆 TOTP 密钥(每个网站一个)。这些密钥需要存起来,而且最好能跨设备同步。问题是:存到云端就意味着服务端能看到你的密钥,一旦被拖库全部泄露。所以认证器先用 PBKDF2 从你的主密码派生出一把 AES 密钥,再用这把密钥把所有 TOTP 密钥加密成密文,只把密文传给服务端。服务端自始至终看不到明文。

第三层才是两步验证本身。当你登录 GitHub、Google 这些网站时,需要输入一个 6 位验证码来证明”我确实是账户主人”。这个验证码由 TOTP 算法生成——你的客户端和对方的服务端各持一份相同的密钥,靠当前时间独立算出同一个数字,不需要任何网络通信。

换句话说,去掉前两层,TOTP 验证码照样能算——你把密钥明文存在本地也行。但加了前两层之后,密钥可以安全地存到云端、跨设备同步,而不用担心服务端被攻破。

代价也很直接:忘记主密码就彻底丢失数据,没有”找回密码”这个选项。