Web端如何用SHA-256计算文件哈希值

1,935 阅读8分钟

Web端如何用SHA-256计算文件哈希值

背景:业务要求对需要上链的文件使用SHA-256计算哈希值,小文件都是可以直接通过 加密算法库 crypto-js 中的 SHA256方法 进行计算 ,但在文件大小超过一定的值(10-50Mb)会出现浏览器页面会 内存溢出 ,甚至页面崩溃。那如何在浏览器计算大文件的哈希值呢?

下面的代码有以下问题:

  1. 文件过大时会网页会内存溢出;
  2. 阻碍了主线程的文件上传
import CryptoJS from 'crypto-js'
//...
const files = e.target.files[0]
const fileReads = new FileReader()
fileReads.readAsArrayBuffer(files)
fileReads.onload = () =>{
  const wordArray = CryptoJS.lib.WordArray.create(fileReads.result)
  const sha256 =  CryptoJS.SHA256(wordArray)
  const hash = sha256.toString()//转16进制
 }

网页为什么会内存溢出

是指网页中存在无法回收的内存或使用的内存过多,最终使得浏览器运行要用到的内存大于能提供的最大内存。此时浏览器就运行不了,浏览器会提示页面崩溃,有时候会自动关闭浏览器;

计算文件时内存溢出的原因

上面代码中用到了CryptoJS.lib .WordArray.create() ,这个方法短时间内会不断的开辟新的内存来保存文件的二进制数组,但长时间得不到释放,占用内存越来越多,从而导致内存溢出。

image.png

要使用 CryptoJS加密库对文件计算,需要转换文件暂存区的ArrayBuffer 转化为 CryptoJS需要的wordArray格式,拿就需要对这个数据for循环进行转换,一个14.3KB大小的文件都要创造出14732长度的数组,那10MB就有千万长度的数组了......,所以内存溢出是必然的。

阻碍主线程文件上传的原因就不说了,JS是单线程懂的都懂;

问题一:解决思路

首先想到的是文件切块,等分切块去计算文件。这样就不会有内存溢出了。但是问题立马就出现了,每一块文件都是独立的,得到的数据难道单独加密,那就得不到整个文件的哈希值了。那怎么办?难道计算每个小块的哈希再合并?NO,这里需要介绍SHA-256算法原理:

SHA-256简单介绍

算法基本描述:(详细请看文末-参考资料)
  • 补位
  • 补长度
  • 使用的常量
  • 使用的主要函数
  • 计算消息摘要
算法基本流程:
  • 哈希初值H(0)
const hash = [
  0x6A09E667, 0xBB67AE85,
  0x3C6EF372, 0xA54FF53A, 
  0x510E527F, 0x9B05688C, 
  0x1F83D9AB, 0x5BE0CD19 
]
  • 工作流程
  1. 将原始消息分为N个512bit的消息块。每个消息块分成16个32bit的字标记为M(i)0、M(i)1、M(i)2、…、M(i)15然后对这N个消息块依次进行主循环进行处理,从而影响哈希初始值
  2. 最后对N个消息块依次进行以上四步操作后将最终得到的H(N)0、H(N)1、H(N)2、…、H(N)7 串联起来即可得到最后的256bit消息摘要。详细的串联代码可以在文末的代码仓库或crypto-js库中的sha256.js源码中可以看到

image.png

基本流程

从上面我们得知可以在只需要在工作流程的第一届阶段,把文件分成小块传给主循环函数便可以,我们需要的是从算法库里面独立出来第一阶段和第二阶段,并对文件的每一块进行第一阶段的主循环,来影响H0-H7,最终在文件最后一块的时候在进行第二阶段的计算,最终得到我们想要的哈希值,也就是消息摘要。

文件切块(部分代码)

let bufferSize = 1024*1024
let block = {
  fileSize: file.size,
  start: 0,
  end: bufferSize
}
let reader = new FileReader();
let blob = file.slice(block.start, block.end);
reader.readAsArrayBuffer(blob);
reader.onload = ()=>{
 //将拿到的传给计算函数 之后继续调用 start=file.size  file.size += bufferSize
};

计算逻辑(部分代码)

需要将文件缓存的数据转成可以操作的数据类型

let hash = [ 0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19 ];

function sha256(m, H) {
    var w = [],
        a, b, c, d, e, f, g, h, i, j,
        t1, t2;
    for (var i = 0; i < m.length; i += 16) {
        a = H[0];
        b = H[1];
        c = H[2];
        d = H[3];
        e = H[4];
        f = H[5];
        g = H[6];
        h = H[7];
        for (var j = 0; j < 64; j++) {
            if (j < 16) w[j] = m[j + i];
            else {
                var gamma0x = w[j - 15],
                    gamma1x = w[j - 2],
                    gamma0  = ((gamma0x << 25) | (gamma0x >>>  7)) ^
                              ((gamma0x << 14) | (gamma0x >>> 18)) ^
                               (gamma0x >>> 3),
                    gamma1  = ((gamma1x <<  15) | (gamma1x >>> 17)) ^
                              ((gamma1x <<  13) | (gamma1x >>> 19)) ^
                               (gamma1x >>> 10);
                w[j] = gamma0 + (w[j - 7] >>> 0) +
                       gamma1 + (w[j - 16] >>> 0);
            }
            var ch  = e & f ^ ~e & g,
                maj = a & b ^ a & c ^ b & c,
                sigma0 = ((a << 30) | (a >>>  2)) ^
                         ((a << 19) | (a >>> 13)) ^
                         ((a << 10) | (a >>> 22)),
                sigma1 = ((e << 26) | (e >>>  6)) ^
                         ((e << 21) | (e >>> 11)) ^
                         ((e <<  7) | (e >>> 25));
            t1 = (h >>> 0) + sigma1 + ch + (K[j]) + (w[j] >>> 0);
            t2 = sigma0 + maj;
            h = g;
            g = f;
            f = e;
            e = (d + t1) >>> 0;
            d = c;
            c = b;
            b = a;
            a = (t1 + t2) >>> 0;
        }
        H[0] = (H[0] + a) | 0;
        H[1] = (H[1] + b) | 0;
        H[2] = (H[2] + c) | 0;
        H[3] = (H[3] + d) | 0;
        H[4] = (H[4] + e) | 0;
        H[5] = (H[5] + f) | 0;
        H[6] = (H[6] + g) | 0;
        H[7] = (H[7] + h) | 0;
    }
    return H;
}

function computer(event) {//传过来的文件块
    let uint8_array, message, block, nBitsTotal, output, nBitsLeft, nBitsTotalH, nBitsTotalL;
    uint8_array = new Uint8Array(event.data.message);//转为10进制
    message = bytesToWords(uint8_array);//10转为
    block = event.data.block;
    event = null;
    uint8_array = null;
    output = {
        'block' : block
    };
    if (block.end === block.file_size) {
        nBitsTotal = block.file_size * 8;
        nBitsLeft = (block.end - block.start) * 8;
        nBitsTotalH = Math.floor(nBitsTotal / 0x100000000);
        nBitsTotalL = nBitsTotal & 0xFFFFFFFF;
        message[nBitsLeft >>> 5] |= 0x80 << (24 - nBitsTotal % 32);
        message[((nBitsLeft + 64 >>> 9) << 4) + 14] = nBitsTotalH;
        message[((nBitsLeft + 64 >>> 9) << 4) + 15] = nBitsTotalL;
        hash = sha256(message, hash);
        //打印结果
        console.log(bytesToHex(wordsToBytes(hash)))
    } else {
        hash = sha256(message, self.hash);
    }
}

function bytesToWords(t) {
  for (var e = [], a = 0, n = 0; a < t.length; a++,
       n += 8)
    e[n >>> 5] |= t[a] << 24 - n % 32;
  return e
}
function wordsToBytes(a){
  for(var b=[],c=0;c<a.length*32;c+=8){
    b.push(a[c>>>5]>>>24-c%32&255)
  }
  return b
}
function bytesToHex(a){
  for(var b=[],c=0;c<a.length;c++){
    b.push((a[c]>>>4).toString(16)),
    b.push((a[c]&15).toString(16));
  }
  return b.join("")
}

对文件操作所要注意的

不管是对哪种数据类型,位操作对象的本质都是一段连续的比特序列。从性能的角度讲,位操作最好是能直接操作连续的内存位。那就是通过二进制位操作符。在含有位操作符的运算中,都得通过 ToInt32() 转换为 32 位有符号整数,然后将其当做 32 位的比特序列进行位运算,运算结果返回也为 32 位有符号整数。因此,通过拼接 32 位有符号整数,就可以实现“对一段连续的比特序列进行位操作”的功能了。

正是基于这样的原理, CryptoJs 实现了名为WordArray的类,作为“一段连续比特序列”的抽象进行各种位操作。 WordArray 是 CryptoJs 中最核心的一个类,所有主要算法的实际操作对象都是WordArray对象。理解WordArray是理解CryptoJs各算法的基础 words 为 32 位有符号整数构成的数组,通过按顺序拼接数组中的数,就组成了比特序列。 JavaScript 中 32 位有符号整数是通过补码转换为二进制的,不过在这里我们不需要关注这点,因为这个整数的值是没有意义的,实际使用中,比特序列更多的是用字节作单位,或用 16 进制数表示,因此我们只需要知道 32 位等价于 4 个字节,等价 于 8个 16 进制数。

问题二:解决思路

Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。

  • 将计算相关逻辑存入js,将该js作为后台线程文件
let myWorker = new Worker('hash.js');

线程之间通信通过监听

你可以通过postMessage() 方法和onmessage事件处理函数触发workers的方法。当你想要向一个worker发送消息时,你只需要这样做(main.js):

function message(value) {
  myWorker.postMessage([value]);
  console.log('Message posted to worker');
}

在worker中接收到消息后,我们可以写这样一个事件处理函数代码作为响应(hash.js):

onmessage = function(e) {   
  console.log('Message received from main script');   
  var workerResult = 'Result: ' + (e.data[0] * e.data[1]);   
  console.log('Posting message back to main script');   
  postMessage(workerResult); 
}

Web Worker

Web Worker 有以下几个使用注意点。

(1)同源限制

分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。

(2)DOM 限制

Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用document、window、parent这些对象。但是,Worker 线程可以navigator对象和location对象。

(3)通信联系

Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成。

(4)脚本限制

Worker 线程不能执行alert()方法和confirm()方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。

(5)文件限制

Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本,必须来自网络。

web worker使用注意

在webpack相关项目中需要特定的插件去编译web worker相关文件,不同版本坑还不同需要多多调试;

业务结构图

代码地址

参考资料