如何在node.js中生成随机SHA1哈希作为ID?

155

我正在使用以下代码来生成node.js的sha1 id:

crypto.createHash('sha1').digest('hex');
问题在于它每次都返回相同的id。是否可能使其每次生成随机id,以便我可以将其用作数据库文档id?

2
不要使用sha1。它已不再被认为是安全的(抗碰撞)。这就是为什么naomik的答案更好的原因。 - Niels Abildgaard
@Niels Abildgaard,生成后检查重复项非常简单,冲突风险并不一定相关,无论您使用什么作为数据库大小,都需要检查重复项。 “安全”与基数无关。 - mckenzm
@mckenzm 没有必要认为上述都是正确的:您不知道ID用于何处,如何创建带有ID的项目,哪些假设适用于它们在具体应用中(因此是否具有冲突抵抗力),并且您无需检查重复项,这就是冲突抗性ID 的整个要点,并且它们在大型和分散式应用程序中特别使用的原因。 - Niels Abildgaard
@ Niels Abildgaard,OP只想要一个平衡的合成键。从空气中来。通常我们会反转时间戳或哈希当前行数。它只需要在表中分布良好且唯一,这是键所强制执行的。然后需要进行另一次尝试。碰撞抵抗力很好,但从未绝对。 - mckenzm
6个回答

705

比 sha1 随机性高 243,583,606,221,817,150,598,111,409 倍

建议使用 crypto.randomBytes。虽然它不是 sha1,但对于 id 目的来说,它更快,同样“随机”。

var id = crypto.randomBytes(20).toString('hex');
//=> f26d60305dae929ef8640a75e70dd78ab809cfe9

生成的字符串长度是产生的随机字节的两倍,每个字节编码为十六进制后有2个字符。20个字节将变成40个十六进制字符。

使用20个字节,我们有 256^20 或者1,461,501,637,330,902,918,203,684,832,716,283,019,655,932,542,976 种不同的输出值。这与SHA1的160位(20字节)可能的输出完全相同。

因此,对我们来说,意识到这一点后就没有必要shasum我们的随机字节了。这就像掷骰子两次但只接受第二次掷出的结果;无论如何,每次掷骰子都只有6种可能的结果,所以第一次掷骰子就已经足够。


为什么这更好?

要理解为什么这更好,首先我们必须了解哈希函数的工作原理。哈希函数(包括SHA1)如果给定相同的输入,则始终会生成相同的输出。

比如说,我们想要生成ID,但是我们的随机输入是由抛硬币决定的,我们只有"正面""反面"这两种可能。

% echo -n "heads" | shasum
c25dda249cdece9d908cc33adcd16aa05e20290f  -

% echo -n "tails" | shasum
71ac9eed6a76a285ae035fe84a251d56ae9485a4  -

如果"heads"再次出现,SHA1 输出将与第一次相同。

% echo -n "heads" | shasum
c25dda249cdece9d908cc33adcd16aa05e20290f  -

好的,所以抛硬币不是一个很好的随机ID生成器,因为我们只有两个可能的输出。

如果我们使用标准的六面骰子,我们就有6个可能的输入。猜猜SHA1算法会有多少可能的输出?6!

input => (sha1) => output
1 => 356a192b7913b04c54574d18c28d46e6395428ab
2 => da4b9237bacccdf19c0760cab7aec4a8359010b0
3 => 77de68daecd823babbb58edb1c8e14d7106e83bb
4 => 1b6453892473a467d07372d45eb05abc2031647a
5 => ac3478d69a3c81fa62e60f5c3696165a4e5e6ac4
6 => c1dfd96eea8cc2b62785275bca38ac261256e278

我们很容易自欺欺人地认为,仅仅因为函数的输出看起来非常随机,就代表它很随机。

我们都同意,抛硬币或掷骰子都不能用作随机ID生成器,因为SHA1可能的结果(我们用于ID的值)非常少。但是如果我们使用输出更多的东西呢?像带有毫秒数的时间戳?或JavaScript的Math.random?甚至这两者的组合?!

让我们计算一下可以得到多少独特的ID...


带有毫秒数的时间戳的唯一性

当使用(new Date()).valueOf().toString()时,您将获得一个13位数字(例如1375369309741)。然而,由于这是一个顺序更新的数字(每毫秒一次),所以输出几乎总是相同的。让我们来看看:

for (var i=0; i<10; i++) {
  console.log((new Date()).valueOf().toString());
}
console.log("OMG so not random");

// 1375369431838
// 1375369431839
// 1375369431839
// 1375369431839
// 1375369431839
// 1375369431839
// 1375369431839
// 1375369431839
// 1375369431840
// 1375369431840
// OMG so not random

公平起见,为了比较,在一个给定的分钟内(慷慨的操作执行时间),您将有 60*100060000 个独立的访问者。


Math.random的唯一性

现在,当使用 Math.random 时,由于JavaScript表示64位浮点数的方式,您将获得长度介于13到24个字符之间的数字。更长的结果意味着更多的数字,也就意味着更多的熵。首先,我们需要找出哪个长度最可能。

以下代码将确定哪个长度最有可能。我们通过生成100万个随机数,并基于每个数字的 .length 来增加计数器来实现这一点。

// get distribution
var counts = [], rand, len;
for (var i=0; i<1000000; i++) {
  rand = Math.random();
  len  = String(rand).length;
  if (counts[len] === undefined) counts[len] = 0;
  counts[len] += 1;
}

// calculate % frequency
var freq = counts.map(function(n) { return n/1000000 *100 });
通过将每个计数器除以一百万,我们可以得到从Math.random返回的数字长度的概率。
len   frequency(%)
------------------
13    0.0004  
14    0.0066  
15    0.0654  
16    0.6768  
17    6.6703  
18    61.133  <- highest probability
19    28.089  <- second highest probability
20    3.0287  
21    0.2989  
22    0.0262
23    0.0040
24    0.0004

因此,尽管这并不完全准确,但大方地说您会得到一个19个字符长的随机输出:0.1234567890123456789。前面的字符将始终是0,因此我们实际上只能得到17个随机字符。这给我们留下了10 ^ 17 +1(可能为0;请参见下面的注释)或100,000,000,000,000,001个唯一值。


那么我们可以生成多少个随机输入?

好的,我们计算了毫秒级时间戳和Math.random的结果数。

      100,000,000,000,000,001 (Math.random)
*                      60,000 (timestamp)
-----------------------------
6,000,000,000,000,000,060,000

那是一颗只有六千亿亿面的骰子。或者,为了让这个数字更容易理解,这个数字大约

input                                            outputs
------------------------------------------------------------------------------
( 1×) 6,000,000,000,000,000,060,000-sided die    6,000,000,000,000,000,060,000
(28×) 6-sided die                                6,140,942,214,464,815,497,21
(72×) 2-sided coins                              4,722,366,482,869,645,213,696

听起来不错,是吧?那么,让我们来找出答案...

SHA1 会生成一个20字节的值,有可能有256^20种结果。所以我们实际上没有充分利用 SHA1 的潜力。那么我们到底使用了多少呢?

node> 6000000000000000060000 / Math.pow(256,20) * 100

毫秒级时间戳和 Math.random 仅使用 SHA1 的 160 位潜力的 4.11e-27%!

generator               sha1 potential used
-----------------------------------------------------------------------------
crypto.randomBytes(20)  100%
Date() + Math.random()    0.00000000000000000000000000411%
6-sided die               0.000000000000000000000000000000000000000000000411%
A coin                    0.000000000000000000000000000000000000000000000137%

哇塞,看看这些零。那么crypto.randomBytes(20)有多好?比现在的方式好243,583,606,221,817,150,598,111,409倍。


+1和零的频率说明

如果你想知道为什么要+1,那是因为Math.random可能会返回0,这意味着我们必须考虑1个额外的可能结果。

根据下面的讨论,我对0出现的频率很感兴趣。这是一个小脚本,random_zero.js,用来获取一些数据。

#!/usr/bin/env node
var count = 0;
while (Math.random() !== 0) count++;
console.log(count);

然后,我在4个线程中运行它(我的处理器有4个内核),将输出附加到一个文件中。

$ yes | xargs -n 1 -P 4 node random_zero.js >> zeroes.txt

原来得到一个0并不那么困难。在记录了100个数值后,平均值为:

大约316,485,4823次随机中才会出现一个0

太棒了!需要进行更多的研究以确定该数字是否与v8的Math.random实现的均匀分布相同。


3
crypto.randomBytes 绝对是最好的选择 ^^ - Mulan
“毫秒级时间戳和Math.random仅使用SHA1的160位潜力的4.11e-27%!”实际上应该是“4.11e-25%”吗?“6e21 / 256 ** 20 * 100”为“4.1053665947016126e-25”。这也意味着“0.00000000000000000000000000411%”实际上应该是“0.000000000000000000000000411%”,等等。 - Patrick Roberts

69

在这里看一下:如何使用node.js Crypto创建HMAC-SHA1哈希? 我会创建一个当前时间戳和随机数的哈希值,以确保哈希的唯一性:

var current_date = (new Date()).valueOf().toString();
var random = Math.random().toString();
crypto.createHash('sha1').update(current_date + random).digest('hex');

47
为了更好的方法,请参见下面@naomik的答案。 - Gabi Purcaru
3
这也是一个很好的答案,Gabi。速度稍微快了一点,大约15%。两个人都做得很好!实际上,我喜欢在“salt”中看到一个Date(),因为它可以让开发者轻松地相信,在除了最疯狂的并行计算情况之外,这将是唯一的值。虽然知道用randomBytes(20)生成的值是唯一的,但是我们可能不熟悉另一个库的随机数生成内部机制,所以这只是一种信心。 - Dmitri R117
@Dmitri R117,对主键进行加盐的好处是什么?由于插入时可以检测到重复项,因此不需要保证唯一值。有些商店会预分配块。更重要的是,分布在扁平化索引或大小相同的分区中更为均匀。 - mckenzm

33

也可以在浏览器中完成!

编辑: 这段内容与我的前一个回答不太相符。我将其作为第二个答案留在这里,供那些想在浏览器中完成此操作的人参考。

如果您愿意,您还可以在现代浏览器中客户端完成此操作。

// str byteToHex(uint8 byte)
//   converts a single byte to a hex string 
function byteToHex(byte) {
  return ('0' + byte.toString(16)).slice(-2);
}

// str generateId(int len);
//   len - must be an even number (default: 40)
function generateId(len = 40) {
  var arr = new Uint8Array(len / 2);
  window.crypto.getRandomValues(arr);
  return Array.from(arr, byteToHex).join("");
}

console.log(generateId())
// "1e6ef8d5c851a3b5c5ad78f96dd086e4a77da800"

console.log(generateId(20))
// "d2180620d8f781178840"

浏览器要求

Browser    Minimum Version
--------------------------
Chrome     11.0
Firefox    21.0
IE         11.0
Opera      15.0
Safari     5.1

3
Number.toString(radix) 不能保证始终生成两位数字的值(例如:(5).toString(16) = "5",而不是 "05")。这并不重要,除非您的最终输出需要恰好包含 len 个字符。在这种情况下,您可以在 map 函数中使用 return ('0'+n.toString(16)).slice(-2); - The Brawny Man
1
很棒的代码,谢谢。只想补充一点:如果你要将它用于id属性的值,请确保ID以字母[A-Za-z]开头。 - GijsjanB
非常好的回答(和评论)- 非常感谢您在回答中包含了浏览器要求! - kevlarr
浏览器要求不正确。IE11 不支持 Array.from()。 - tdc
3
这段话来源于一个维基网页,在回答期间被提取。如果你想的话,可以编辑这个回答,但真的有谁在乎IE吗?如果你想支持它,你必须用polyfill填充一半的JavaScript...... - Mulan

2

如果想要获取唯一标识符,您应该使用UUID(通用唯一识别码)/ GUID(全局唯一标识符)。

哈希应该是确定性的、唯一的,并且对于任何大小的输入都具有固定的长度。因此,无论您运行哈希函数多少次,如果使用相同的输入,则输出将相同。

UUID是唯一的和随机生成的!有一个名为“uuid”的软件包,您可以通过npm安装它

npm install uuid

&在您的代码中导入模块

const { v4:uuidv4} = require('uuid');

//调用方法uuidv4或您导入时命名的任何名称,并将其记录下来或存储或分配。该方法以字符串形式返回UUID。

console.log(uuidv4()); // 示例输出:“59594fc8-6a35-4f50-a966-4d735d8402ea”

这是npm链接(如果需要): https://www.npmjs.com/package/uuid


0
简洁的方法,适用于浏览器:
// Returns a 256 bit string, or the equivalent Uint8Array if `false` is
// passed in.

function get256RandomBits(returnAsString = true) {
  const uint8Array = new Uint8Array(32); // 32 bytes = 256 bits
  const rng = crypto.getRandomValues(uint8Array);
  if (returnAsString) {
    return Array.from(rng).map(b => b.toString(16).padStart(2, '0')).join('');
  }
  else {
    return rng;
  }
}

示例输出: - c2fec24e465658aad1208d0a3f863585aa2e5fd30f4e0712e2c74239419700d3 - 8f9ff25c2948b8ee39f77303e0678b6eae1382e3d2517e15a1c4de6840f5673b - d212ff805630edb41d22c9b6fdd8db6e7f910ea25483bad8b598249a6c73d950
由于这个问题是在10年前提出的,我使用了256位而不是160位,但是思路是相同的;只需根据需要修改第二行。

0

使用crypto是一个好的方法,因为它是本地和稳定的模块,但在某些情况下,如果您想创建一个真正强大和安全的哈希,则可以使用bcrypt。我用它来处理密码,它有很多技术可以进行哈希、创建盐并比较密码。

技巧1(在不同的函数调用中生成盐和哈希)

const salt = bcrypt.genSaltSync(saltRounds);
const hash = bcrypt.hashSync(myPlaintextPassword, salt);

技巧2(自动生成盐和哈希):
const hash = bcrypt.hashSync(myPlaintextPassword, saltRounds);

更多示例可以在这里查看:https://www.npmjs.com/package/bcrypt


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接