浏览器机器指纹生成原理详解
从授权码绑定的实际问题说起
授权码绑定的核心前提是:能找到一台设备上稳定且唯一的“机器指纹”。稳定指同一台机器每次生成结果一致,唯一指不同机器结果不同。否则就会出现授权码绑错了、换浏览器就失效、或者被复制到别的机器上照样能用的问题。下面记录当前方案的具体实现和取舍。
一、指纹由哪几类信息构成
整体公式:
指纹 = Hash(Navigator信息 + Screen信息 + Canvas指纹 + 时区)
这四类信息共同决定一个设备的外观。
1. Navigator 属性
采集以下值:
navigator.userAgent // "Mozilla/5.0 (Windows NT 10.0; Win64; x64)..."
navigator.language // "zh-CN"
navigator.languages // ["zh-CN","zh","en"]
navigator.platform // "Win32"
navigator.hardwareConcurrency // 8
navigator.deviceMemory // 8(部分浏览器不支持)
navigator.cookieEnabled // true
navigator.doNotTrack // "1" / "0" / "unspecified"
稳定性依据:同一台机器、同一个浏览器,这些值基本不变。UA 会包含操作系统和浏览器版本,只有换浏览器或大版本升级才变化。
2. Screen 属性
screen.width + 'x' + screen.height // "1920x1080"
screen.colorDepth // 24
screen.pixelDepth // 24
稳定性依据:用户很少修改屏幕分辨率;色深和像素深度更是极少变动。
3. Canvas 指纹(最关键的一环)
核心代码:
var canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 50;
var ctx = canvas.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillStyle = '#f60';
ctx.fillRect(0, 0, 100, 20);
ctx.fillStyle = '#069';
ctx.fillText('auth_fp_' + navigator.userAgent.slice(-10), 2, 15);
ctx.fillStyle = 'rgba(102,204,0,0.7)';
ctx.fillText('auth_fp', 4, 17);
// 关键:将 canvas 内容转为 base64 编码的 PNG
components.push(canvas.toDataURL());
为什么它是最可靠的区分手段:同样的绘图指令,在不同机器上渲染出的像素结果并不完全一样。差异来源包括:
| 差异来源 | 说明 |
|---|---|
| GPU | 不同显卡的渲染管线、抗锯齿算法不同 |
| 字体渲染引擎 | Windows 用 ClearType,Mac 用 Quartz,Linux 用 FreeType |
| 字体可用性 | 同样是 "14px Arial",不同系统可能匹配到不同物理字体文件 |
| 驱动版本 | 显卡驱动更新可能改变渲染结果 |
因此,Windows + Chrome 输出的 PNG 和 Mac + Safari 输出的 PNG 在像素级别不同,toDataURL() 得到的 base64 字符串也不同。
稳定性依据:同一台机器、同一个浏览器、同一套字体和驱动,渲染结果完全一致。除非更换显卡、重装系统、或浏览器大版本升级改变渲染引擎。
4. 时区
Intl.DateTimeFormat().resolvedOptions().timeZone // "Asia/Shanghai"
稳定性依据:用户一般不改时区。
二、如何转成一个固定长度的短字符串
1. 拼接
把所有信息用 | 分隔:
Mozilla/5.0...|zh-CN|zh-CN,zh,en|Win32|8|8|true|0|1920x1080|24|24|data:image/png;base64,iVBORw0KG...|Asia/Shanghai
2. 选用 DJB2 变体 hash 的原因
当前代码用的是 DJB2 变体,而不是 Web Crypto 的 SHA256。原因很实际:Web Crypto API 的 SHA256 是异步的,而指纹生成流程需要同步返回结果,引入一个异步函数会打乱整个授权码校验链路。DJB2 实现简单,同步执行,满足当前场景的抗碰撞需求。
var hash = 0;
for (var i = 0; i < str.length; i++) {
var char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char; // 等价于 hash * 31 + char
hash = hash & hash; // 截断为 32 位整数
}
逐行说明:
hash << 5是左移 5 位,即乘以 32;减去 hash 相当于乘以 31;再加上当前字符的 charCode。这样每个字符都参与散列。hash & hash把可能溢出的大数截断到 32 位有符号整数范围(等价于hash | 0或hash >>> 0)。(hash >>> 0).toString(16)把 32 位有符号整数转为无符号,再转十六进制。- 补长到 32 位:
while (hexHash.length < 32) {
hexHash += (hash >>> 0).toString(16);
hexHash = hexHash.substring(0, 32);
}
因为 32 位 hash 的十六进制最多 8 位,重复拼接后截断到 32 位,让指纹看起来更长更像 UUID。
3. 最终结果
e09b0599e09b0599e09b0599e09b0599
这就是最终看到的 32 位机器指纹。
三、完整流程
浏览器加载脚本
├─ 收集 Navigator 属性
├─ 收集 Screen 属性
├─ 创建 Canvas 画固定图形,toDataURL() 得到 base64
├─ 收集时区
▼
用 "|" 拼接所有信息
▼
DJB2 Hash → 32位整数 → 十六进制 → 补长到32位
▼
最终指纹(如 e09b0599e09b0599e09b0599e09b0599)
▼
随授权请求发送到服务端
▼
服务端:首次激活存库,后续比对;不匹配则返回“设备不匹配”
四、刷新不变、换机器变的验证逻辑
刷新后,UA、语言、分辨率、Canvas 渲染结果、时区均不变,所有输入都不变,hash 自然不变。
换机器后,至少以下之一不同:
- UA(操作系统/浏览器版本不同)
- 屏幕分辨率
- Canvas 渲染(不同显卡/字体渲染引擎,这是最可靠的区分手段)
- CPU 核心数
任一输入变化,hash 结果就完全不同。
五、方案的优缺点与边界
优点
- 纯前端实现,不需要后端采集。
- 无需用户操作,自动采集。
- Canvas 指纹难以手动伪造。
- 实现简单,不到 60 行。
缺点与已知边界
| 缺点 | 说明 | 改进方向 |
|---|---|---|
| 浏览器升级后可能变化 | UA 变了,或者 Canvas 渲染引擎变了 | 只取 UA 的操作系统部分,忽略浏览器版本 |
| 隐私模式可能不同 | 部分浏览器隐私模式下 Canvas 渲染会加噪声 | 提示用户不要用隐私模式 |
| hash 碰撞理论存在 | DJB2 不是加密级 hash | 改用同步的 SHA256 库(如 jsSHA) |
| 字体影响 Canvas | 安装/卸载字体可能改变渲染结果 | 用更多维度降低单一维度影响权重 |
可选的进一步提升方向
- 加入 WebRTC 获取内网 IP。
- 加入 AudioContext 指纹,不同声卡处理同一音频的输出有微小差异。
- 加入 WebGL 指纹,通过渲染器信息获取显卡型号。
- 用同步 SHA256 库替代 DJB2。
当前方案用于“授权码绑定设备”已经够用,不需要追求银行级唯一性。