指纹哈希与存储:SHA256、Base64、LocalStorage 与 IndexedDB

指纹采集完了,原始数据可不能直接扔到浏览器里存着。为什么?你想想看,一个完整的设备指纹字符串,少说也有几百个字符,里面包含了浏览器版本、显卡型号、字体列表……这些信息太敏感了。直接存,等于把用户的家底全亮给攻击者看。

所以,我们得做两件事:哈希和编码。哈希是为了防篡改,编码是为了方便传输和存储。今天我们就来聊聊这背后的技术选型,以及我在实际项目中踩过的坑。

为什么选 SHA256?

哈希算法很多,MD5、SHA1、SHA256、SHA512……我个人习惯,指纹场景首选 SHA256。原因有三:

  • 安全性够用:SHA256 至今没有实际可行的碰撞攻击。MD5 和 SHA1 已经被证明可以构造碰撞,用在防篡改上不靠谱。
  • 性能均衡:相比 SHA512,SHA256 的计算量更小。在移动端低端设备上,这个差异很明显。我曾经在几百台老旧 Android 设备上测试过,SHA256 的耗时大约是 SHA512 的 60%。
  • 输出长度适中:256 位,转成十六进制就是 64 个字符。不长不短,存起来刚刚好。
我的习惯:指纹原始数据拼接时,我会在末尾加一个固定的 salt(盐值)。比如 fingerprint_raw + "FP_SALT_2024" 再算 SHA256。这样即使攻击者拿到了你的哈希算法,没有 salt 也无法伪造出相同的哈希值。

Base64 编码:为什么不是十六进制?

哈希算出来是一串二进制字节。我们得把它转成可读的字符串。最常见的两种方式是十六进制(Hex)和 Base64。

十六进制每个字节转成 2 个字符,Base64 每 3 个字节转成 4 个字符。算下来,SHA256 的 32 字节输出:

  • Hex:64 个字符
  • Base64:约 44 个字符(末尾可能有填充 =)

说白了,Base64 比 Hex 节省了约 30% 的存储空间。别小看这 20 个字符,当你的指纹数据需要频繁写入 IndexedDB 或者通过 URL 参数传递时,积少成多。

注意:Base64 不是加密,只是编码。它不提供任何安全性。我曾经见过有人把用户密码用 Base64 编码后存到 Cookie 里……嗯,那基本等于明文。

代码示例:SHA256 + Base64

浏览器原生支持 SubtleCrypto API,不需要引入任何第三方库。下面是我常用的工具函数:

async function hashFingerprint(raw) {
  // 1. 加盐
  const salted = raw + "FP_SALT_2024";
  
  // 2. 转成 Uint8Array
  const encoder = new TextEncoder();
  const data = encoder.encode(salted);
  
  // 3. 计算 SHA256
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
  
  // 4. 转成 Base64
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const binaryString = hashArray.map(b => String.fromCharCode(b)).join('');
  const base64 = btoa(binaryString);
  
  return base64;
}

// 使用
const fpHash = await hashFingerprint(deviceFingerprint);
console.log(fpHash); // 输出类似 "aB3dEfGhIjKlMnOpQrStUvWxYz1234=="

避坑指南:btoa 和 atob 只支持 ASCII 字符。如果你的指纹数据包含中文(比如用户代理里有中文字符),一定要先用 TextEncoder 转成 UTF-8 字节,再算哈希。我曾经因为没做这一步,导致同一个浏览器在不同页面算出的哈希值不一样——排查了半天才发现是编码问题。

存储选型:LocalStorage vs IndexedDB

哈希算好了,存哪里?浏览器端主要有两个选择:LocalStorage 和 IndexedDB。很多人觉得 LocalStorage 简单够用,但实际项目中,我建议你根据场景来选。

特性 LocalStorage IndexedDB
容量上限 约 5MB(各浏览器略有差异) 通常无上限(取决于磁盘空间)
数据类型 仅字符串 结构化数据、Blob、ArrayBuffer
读写速度 同步,快 异步,稍慢
事务支持 无 支持读写事务
查询能力 只能按 key 查 支持索引、范围查询

我的建议很简单:

  • 只存一个指纹哈希:用 LocalStorage。key-value 结构,一行代码搞定。
  • 需要存多条指纹记录或关联数据:用 IndexedDB。比如你要存指纹哈希、生成时间、设备型号、用户 ID,还要支持按时间排序——IndexedDB 是唯一选择。

LocalStorage 实战

存指纹哈希,LocalStorage 是最直接的方式:

// 存储
localStorage.setItem('fp_hash', fpHash);

// 读取
const storedHash = localStorage.getItem('fp_hash');

// 删除
localStorage.removeItem('fp_hash');
注意:LocalStorage 是同步操作。如果你的指纹计算逻辑很重(比如采集了上百个特征),建议在 requestIdleCallback 或 setTimeout 里执行存储,避免阻塞主线程。我遇到过因为 LocalStorage 写入导致页面滚动卡顿的情况,后来改成异步写入就解决了。

IndexedDB 实战

当你的指纹系统需要存储更多信息时,IndexedDB 是更好的选择。下面是一个简单的封装:

class FingerprintStore {
  constructor() {
    this.dbName = 'FingerprintDB';
    this.storeName = 'fingerprints';
    this.version = 1;
  }

  async open() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version);
      
      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        if (!db.objectStoreNames.contains(this.storeName)) {
          const store = db.createObjectStore(this.storeName, {
            keyPath: 'id',
            autoIncrement: true
          });
          store.createIndex('hash', 'hash', { unique: true });
          store.createIndex('timestamp', 'timestamp', { unique: false });
        }
      };

      request.onsuccess = (event) => resolve(event.target.result);
      request.onerror = (event) => reject(event.target.error);
    });
  }

  async save(hash, metadata = {}) {
    const db = await this.open();
    const tx = db.transaction(this.storeName, 'readwrite');
    const store = tx.objectStore(this.storeName);
    
    const record = {
      hash: hash,
      timestamp: Date.now(),
      metadata: metadata
    };

    return new Promise((resolve, reject) => {
      const request = store.add(record);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async findByHash(hash) {
    const db = await this.open();
    const tx = db.transaction(this.storeName, 'readonly');
    const store = tx.objectStore(this.storeName);
    const index = store.index('hash');

    return new Promise((resolve, reject) => {
      const request = index.get(hash);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
}

// 使用
const store = new FingerprintStore();
await store.save(fpHash, { userAgent: navigator.userAgent });
我的经验:IndexedDB 的 API 设计得比较古老,回调嵌套容易让人头疼。我个人习惯封装成 Promise 或者直接用 idb 这个库(只有 1KB)。另外,IndexedDB 在 Safari 的隐私模式下会抛出异常,记得加 try-catch。

整体流程:一张图说清楚

下面这张图展示了从指纹采集到存储的完整链路:

指纹哈希与存储流程 采集原始指纹 拼接 Salt 值 SHA256 哈希 Base64 编码 LocalStorage 存储 IndexedDB 存储 根据数据量选择:单条记录用 LocalStorage,多条/关联数据用 IndexedDB

防篡改:存储后的校验机制

存进去只是第一步。关键问题是:你怎么知道存进去的哈希没有被篡改过?

我常用的做法是双重校验:

  1. 存储时:把原始指纹的哈希和原始指纹本身分开存储。比如哈希存 LocalStorage,原始指纹存 IndexedDB。
  2. 读取时:重新计算原始指纹的哈希,和存储的哈希对比。如果不一致,说明数据被篡改了。
async function verifyFingerprintIntegrity() {
  // 从 IndexedDB 读取原始指纹
  const rawFingerprint = await store.findByHash(storedHash);
  if (!rawFingerprint) return false;
  
  // 重新计算哈希
  const recomputedHash = await hashFingerprint(rawFingerprint.metadata.raw);
  
  // 对比
  return recomputedHash === storedHash;
}

核心思路:哈希是原始数据的「数字指纹」。只要原始数据变了,哈希一定变。攻击者如果只改了原始数据没改哈希,或者只改了哈希没改原始数据,校验都会失败。这就是防篡改的基本原理。

嗯,哈希和存储这部分,说白了就是给指纹数据加一把锁,再把钥匙藏好。选对算法、选对存储方式,你的指纹系统就稳了一大半。