Cuid2
安全、抗碰撞的唯一标识符,针对水平扩展和性能进行了优化。下一代UUID。
您的应用需要唯一标识符吗?忘掉在大型应用中经常发生碰撞的UUID和GUID吧。使用Cuid2来代替。
Cuid2具有以下特点:
- 安全: 难以猜测下一个id、现有的有效id,或从id中获取任何关于引用数据的信息。Cuid2使用多个独立的熵源,并使用经过安全审计的NIST标准加密安全哈希算法(Sha3)对其进行哈希处理。
- 抗碰撞: 生成相同id的可能性极低(默认情况下,需要生成大约4,000,000,000,000,000,000个id才能达到50%的碰撞概率)。
- 水平可扩展: 可在多台机器上生成id,无需协调。
- 离线兼容: 无需网络连接即可生成id。
- URL和命名友好: 不含特殊字符。
- 快速且便捷: 无异步操作。不会引入用户可察觉的延迟。压缩后小于5k。
- 但不会过快: 如果哈希速度过快,可能会导致并行攻击,从而找到重复项或破坏熵隐藏。对于唯一id,最快的运行者在安全竞赛中会失利。
Cuid2不适合:
- 顺序id(参见下文关于K-sortable id的说明)
- 高性能紧密循环,如渲染循环(如果不需要跨主机唯一id或安全性,可以考虑使用简单的计数器,或尝试Ulid或NanoId)。
入门
npm install --save @paralleldrive/cuid2
或
yarn add @paralleldrive/cuid2
import { createId } from '@paralleldrive/cuid2';
const ids = [
createId(), // 'tz4a98xxat96iws9zmbrgj3a'
createId(), // 'pfh0haxfpzowht3oi213cqos'
createId(), // 'nc6bzmkmd014706rfda898to'
];
使用Jest?跳转至在Jest中使用。
配置
import { init } from '@paralleldrive/cuid2';
// init函数返回一个具有指定配置的自定义createId函数。
// 所有配置属性都是可选的。
const createId = init({
// 具有与Math.random相同API的自定义随机函数。
// 您可以使用它来传递加密安全的随机函数。
random: Math.random,
// id的长度
length: 10,
// 主机环境的自定义指纹。用于帮助防止在分布式系统中生成id时发生碰撞。
fingerprint: 'a-custom-host-fingerprint',
});
console.log(
createId(), // wjfazn7qnd
createId(), // cerhuy9499
createId(), // itp2u4ozr4
);
验证
import { createId, isCuid } from '@paralleldrive/cuid2';
console.log(
isCuid(createId()), // true
isCuid('not a cuid'), // false
);
受信任者
为什么?
默认情况下,id应该是安全的,原因与浏览器会话默认应该安全相同。不安全的id可能会导致许多问题,并可能以意想不到的方式造成问题,包括未经授权的用户账户访问、未经授权访问用户数据,以及意外泄露用户个人数据,这可能导致灾难性后果,即使在看似无害的应用程序中也是如此,如健身跑步追踪器(参见2018年Strava五角大楼数据泄露事件和PleaseRobMe)。
并非所有安全措施都应被视为等同。例如,不应该信任浏览器的"加密安全"伪随机数生成器(CSPRNG)(在uuid和nanoid等工具中使用)。例如,浏览器CSPRNG可能存在漏洞。多年来,Chromium的Math.random()
根本就不随机。Cuid的创建是为了解决id生成器中不可信熵的问题,这导致了生产应用中频繁的id碰撞和相关问题。Cuid2不依赖单一熵源,而是结合多个熵源,以提供比其他解决方案更强的安全性和抗碰撞保证。
现代Web应用程序的需求与GUID(全局唯一标识符)和UUID(通用唯一标识符)早期编写的应用程序不同。特别是,Cuid2旨在提供比任何现有GUID或UUID实现更强的唯一性保证,并防止泄露有关被引用数据或生成id的系统的任何信息。
Cuid2是Cuid的下一代,Cuid已在数千个应用程序中使用了十多年,没有确认的碰撞报告。Cuid2的变化很大,可能会影响许多依赖Cuid的项目,因此我们决定创建一个替代库和id标准。Cuid现已被弃用,推荐使用Cuid2。
熵是系统中总信息量的度量。在唯一id的上下文中,更高的熵会导致更少的碰撞,并且也可能使攻击者更难猜测有效的id。
Cuid2由以下熵源组成:
- 初始字母,使id成为JavaScript和HTML/CSS中可用的标识符
- 当前系统时间
- 伪随机值
- 会话计数器
- 主机指纹
字符串采用Base36编码,这意味着它只包含小写字母和数字:0 - 9,没有特殊符号。
水平可扩展性
今天的应用程序不再运行在单一机器上。
应用程序可能需要支持在线/离线功能,这意味着我们需要一种方法,使不同主机上的客户端能够生成不会与其他主机生成的id发生碰撞的id - 即使它们没有连接到网络。
大多数伪随机算法使用毫秒级时间作为随机种子。当在单独的进程(如克隆的虚拟机或客户端浏览器)中运行时,随机ID缺乏足够的熵来保证不会发生碰撞。应用程序开发人员报告,当ID生成分布在大量机器上,以至于在同一毫秒内生成大量ID时,v4 UUID碰撞会导致应用程序出现问题。 每个新客户端都会以指数级增加冲突的可能性,就像随机字符串中每增加一个字符都会以指数级减少冲突的可能性一样。成功的应用每天会新增数百或数千个客户端,因此通过添加随机字符来对抗熵的缺乏将导致标识符变得ridiculously长。
由于这个问题的性质,可能在构建应用并将其扩展到百万用户之前都不会发现这个问题。当你注意到问题时(当高峰时段每毫秒需要创建数十个ID时),如果你的数据库没有对ID设置唯一约束(因为你认为你的GUID是安全的),你就会陷入困境。你的用户开始看到不属于他们的数据,因为数据库只返回它找到的第一个ID匹配项。
另一种情况是,你采取了安全措施,只让数据库创建ID。写操作只在主数据库上进行,负载分散在只读副本上。但在这种压力下,你必须开始水平扩展数据库写操作,突然你的应用开始变慢(如果数据库足够智能,能保证写入主机之间的ID唯一性),或者你开始在不同的数据库主机之间遇到ID冲突,导致你的写入主机对哪些ID代表哪些数据产生分歧。
性能
ID生成应该足够快,以至于人类不会注意到延迟,但又要足够慢,使得暴力破解(即使并行)变得不可行。这意味着不能等待异步熵池请求或跨进程/跨网络通信。在浏览器中性能会慢到不切实际。所有熵源都需要快到可以同步访问。
更糟糕的是,当数据库是保证ID唯一性的唯一保证时,这意味着客户端被迫向数据库发送不完整的记录,并等待网络往返才能在任何算法中使用这些ID。忘掉快速的客户端性能吧。这根本不可能。
这种情况导致一些客户端创建只能在单个客户端会话中使用的ID(如内存计数器)。当数据库返回真实ID时,客户端必须进行一些杂耍逻辑来替换正在使用的ID,增加了客户端实现代码的复杂性。
如果客户端ID生成更强大,冲突的机会会小得多,客户端可以向数据库发送完整的记录以插入,而无需等待完整的往返请求就能使用ID。
微小
页面加载需要快速,这意味着我们不能在复杂的算法上浪费大量JavaScript。Cuid2非常小。这对于重客户端JavaScript应用尤其重要。
安全
客户端可见的ID通常需要有足够的随机数据和熵,使得根据已知的ID猜测有效ID变得几乎不可能。这使得简单的顺序ID在客户端生成数据库键的情况下无法使用。此外,使用V4 UUID也不安全,因为对于几种ID生成算法,有已知的攻击方法,复杂的攻击者可以用来预测下一个ID。Cuid2已经经过安全专家和人工智能的审核,被认为可以安全地用于秘密分享链接等用例。
可移植
大多数更强大的UUID / GUID算法需要访问浏览器中不可用的操作系统服务,这意味着它们无法按规范实现。此外,我们的ID标准需要可移植到多种语言(原始cuid有22种不同的语言实现)。
移植版本
[列出了各种语言的Cuid2实现,包括Clojure、ColdFusion、Dart、Java、.NET、PHP、Python、Ruby和Rust]
相比Cuid的改进
原始的Cuid在超过十年的时间里为我们提供了良好的服务。我们在两个不同的社交网络中使用它,并用它为Adobe Creative Cloud生成ID。在使用它的生产系统中,我们从未遇到过冲突问题。但仍有改进的空间。
更好的冲突抵抗
可用熵是可以生成的唯一ID的最大数量。通常更多的熵会导致更低的冲突概率。为简单起见,我们在以下讨论中假设一个完美的随机分布。
原始的Cuid在数千个软件实现中运行了超过10年,没有确认的冲突报告,在某些情况下有超过1亿用户生成ID。
原始Cuid的最大可用熵约为3.71319E+29(假设每个会话1个ID)。这已经是一个非常大的数字,但Cuid2的最大推荐熵为4.57458E+49。作为参考,这种熵的差异大约相当于蚊子的大小与地球到最近恒星的距离之间的差异。Cuid2的默认熵为1.62155E+37,这比原始Cuid有显著增加,可以比作棒球大小和月球大小之间的差异。
哈希函数将所有熵源混合成一个单一值,因此使用高质量的哈希算法很重要。我们已经用Cuid2测试了数十亿个ID,迄今为止没有检测到冲突。
更易移植
原始Cuid在不同类型的主机(包括浏览器、Node和React Native)上使用不同的方法来生成指纹。不幸的是,这在cuid用户生态系统中造成了几个兼容性问题。
在Node中,每个生产主机略有不同,我们可以可靠地获取进程ID等来区分主机。但当我们开始在使用相同容器和微容器架构的云虚拟主机上部署时,我们早期关于不同主机在Node中生成不同PID的假设被证明是错误的。结果是Node中的主机指纹熵很低,限制了它们在云工作者和微容器等环境中为水平服务器扩展提供良好冲突抵抗的能力。
如果你有不同的指纹需求,例如当global和window都是undefined时,也无法使用Cuid自定义你的指纹函数。
Cuid2使用JavaScript环境中所有全局名称的列表。对其进行哈希处理可以产生非常好的主机指纹,但我们故意没有在原始Cuid中包含哈希函数,因为我们能找到的所有安全哈希函数都会增加包的大小,所以原始Cuid无法充分利用所有这些独特的主机熵。
在Cuid2中,我们使用一个微小、快速、经过安全审核的NIST标准化哈希函数,并用随机熵对其进行种子处理,因此在所有全局变量都相同的生产环境中,我们失去了唯一的指纹,但仍然获得随机熵来替代它,增强了冲突抵抗能力。
确定性长度
Cuid的长度是不确定的。这在大多数情况下运作良好,但对于某些数据结构的使用却成为了问题,迫使一些用户创建包装代码来填充输出。我们建议在大多数情况下坚持使用默认设置,但如果你不需要强大的唯一性保证(例如,你的用例是类似用户名或URL消歧),使用较短的版本也可以。
更高效的会话计数器熵
原始的Cuid在会话计数器上浪费了熵,这些计数器并不总是被使用,很少被填满,有时还会翻转,意味着如果你在短时间内生成足够多的ID,它们可能会相互冲突,从而降低其有效性。Cuid2用随机数初始化计数器,所以熵永远不会被浪费。它还使用了原生JS数字类型的全精度。如果你只生成一个ID,计数器就只是扩展了随机熵,而不是浪费数字,提供了更强的防冲突保护。
参数化长度
不同的用例对熵抗性有不同的需求。有时,一串短的随机数字就足够用于消歧:例如,常见的做法是使用短标签来区分相似的名称,如用户名或URL标签。由于原始cuid没有对其输出进行哈希处理,我们不得不做出一些严重限制熵的决定来生成短标签。在新版本中,所有熵源都与哈希函数混合,你可以安全地获取任何短于32位的子字符串。你可以粗略估计在达到50%碰撞概率之前可以生成多少个ID:[sqrt(36^(n-1)*26)
],所以如果你使用4位数,在生成约1101个ID后就会达到50%的碰撞概率。这对用户名消歧可能已经足够了。真的会有超过1000人想要使用相同的用户名吗?
默认情况下,你需要生成约4.0268498e+18个ID才能达到50%的碰撞概率,而在最大长度下,你需要生成约6.7635614e+24个ID才能达到50%的碰撞概率。要使用自定义长度,请导入init
函数,它接受配置选项:
import { init } from '@paralleldrive/cuid2';
const length = 10; // 生成约51,386,368个ID后达到50%的碰撞概率
const cuid = init({ length });
console.log(cuid()); // nw8zzfaa4v
增强的安全性
原始的Cuid泄露了ID的详细信息,包括来自主机环境的非常有限的数据(通过主机指纹),以及创建ID的确切时间。新的Cuid2将所有熵源哈希成一个看似随机的字符串。
由于哈希算法的存在,从生成的ID中恢复任何熵源应该是不可能的。Cuid出于数据库性能原因使用了大致单调递增的ID。有些人滥用它们来按创建日期选择数据。如果你想能够按创建日期排序项目,我们建议在数据库中创建一个单独的、已索引的createdAt
字段,而不是使用单调ID,原因如下:
- 很容易欺骗客户端系统生成过去或未来的ID。
- 在多个主机几乎同时生成ID时,顺序无法保证。
- 从未保证确定性单调分辨率。
在Cuid2中,哈希算法使用了盐值。盐值是一个随机字符串,在应用哈希函数之前添加到输入熵源中。这使得攻击者更难猜测有效的ID,因为每个ID的盐值都会改变,意味着攻击者无法使用任何现有的ID作为猜测其他ID的基础。
比较
创建Cuid2的主要动机是安全性。我们的ID应该默认安全,就像我们使用https而不是http一样。问题是,我们所有当前的ID规范都基于几十年前的标准,这些标准从未考虑到安全性,而是优化了在现代分布式应用中不再相关的数据库性能特征。今天几乎所有流行的ID都在优化k-sortable特性,这在10年前很重要。以下是k-sortable的含义,以及为什么它不再像我们创建Cuid规范时那样重要,该规范[帮助启发了当前的标准,如UUID v6 - v8]:
关于K-Sortable/顺序/单调递增ID的说明
简而言之:停止担心K-Sortable ID。它们不再那么重要了。请改用createdAt
字段。
在现代系统中使用顺序键的性能影响通常被夸大了。 如果你的数据库太小而无法使用云原生解决方案,那么它也小到不需要担心顺序与随机ID的性能影响,除非你生活在遥远的过去(即使用2010年的硬件)。如果它大到需要担心,随机ID可能仍然更快。
过去,顺序键可能对性能产生重大影响,但在现代系统中已不再如此。
使用顺序键的一个原因是避免ID碎片化,这可能需要大量磁盘空间来存储数十亿条记录的数据库。然而,在如此大的规模下,现代系统通常使用云原生数据库,这些数据库设计用于高效且低成本地处理TB级数据。此外,整个数据库可能存储在内存中,提供快速的随机访问查找性能。因此,碎片化键对性能的影响微乎其微。
更糟糕的是,K-Sortable ID并不总是对性能有利,因为它们可能在数据库中造成热点。如果你有一个系统在短时间内生成大量ID,这些ID将按顺序生成,导致树变得不平衡,这将导致频繁的重新平衡。这可能会对性能产生显著影响。
那么,哪些操作会受到非顺序ID的影响呢?分页排序操作。比如"获取100000条记录,按ID排序"。这会受到明显影响,但如果你的ID是不透明的,你需要多久按ID排序一次?我从未需要这样做。现代云数据库允许你在createdAt
字段上创建索引,性能极佳。
K-Sortable ID最糟糕的部分是它们对安全性的影响。K-Sortable = 不安全。
竞争者
我们不知道有任何标准或库能充分满足我们所有的要求。我们将使用以下标准来筛选常用的替代方案列表。让我们从竞争者开始:
数据库自增(Int、BigInt、AutoIncrement)、[UUID v1 - v8]、[NanoId]、[Ulid]、[Sony Snowflake](受Twitter Snowflake启发)、[ShardingID](Instagram)、[KSUID]、[PushId](Google)、[XID]、[ObjectId](MongoDB)。
以下是我们关心的失格因素:
- 泄露信息: 数据库自增、所有UUID(除V4外,包括V6 - V8)、Ulid、雪花算法、分片ID、pushId、ObjectId、KSUID
- 容易发生冲突: 数据库自增、v4 UUID
- 非加密安全的随机输出: 数据库自增、UUID v1、UUID v4
- 需要分布式协调: 雪花算法、分片ID、数据库自增
- 不适合URL或命名: UUID(太长,含破折号)、Ulid(太长)、UUID v7(太长)- 以及任何支持特殊字符如破折号、空格、下划线、#$%^&等的方案
- 生成速度过快: UUID v1、UUID v4、NanoId、Ulid、Xid
以下是我们关注的特性:
- 安全 - 无信息泄露,抗攻击: Cuid2、NanoId(中等 - 依赖Web加密API熵)
- 抗冲突: Cuid2、Cuid v1、NanoId、雪花算法、KSUID、XID、Ulid、分片ID、ObjectId、UUID v6 - v8
- 水平可扩展: Cuid2、Cuid v1、NanoId、ObjectId、Ulid、KSUID、Xid、分片ID、ObjectId、UUID v6 - v8
- 离线兼容: Cuid2、Cuid v1、NanoId、Ulid、UUID v6 - v8
- URL和命名友好: Cuid2、Cuid v1、NanoId(使用自定义字母表)
- 快速便捷: Cuid2、Cuid v1、NanoId、Ulid、KSUID、Xid、UUID v4、UUID v7
- 但不过于快速: Cuid2、Cuid v1、UUID v7、雪花算法、分片ID、ObjectId
Cuid2是唯一通过我们所有测试的解决方案。
NanoId和Ulid
总的来说,NanoId和Ulid似乎满足了我们大部分要求,但Ulid会泄露时间戳,而且它们都过于依赖Web加密API的随机熵。Web加密API依赖两个不可靠的因素:随机熵源和用于将熵扩展为看似随机数据的哈希算法。一些实现曾出现过严重的漏洞,使其易受攻击。
除了使用加密安全方法外,Cuid2还从多样化的池中提供自己的已知熵,并使用经过安全审核的NIST标准加密安全哈希算法。
速度过快: NanoId和Ulid的生成速度非常快。但这并不是好事。ID生成速度越快,进行冲突攻击的速度就越快。寻找ID分布统计异常的不法分子可以利用NanoId的速度优势。Cuid2的速度足够便捷,但不会快到成为安全风险。
熵安全性比较
- NanoId熵:Web加密
- Ulid熵:Web加密 + 时间戳(泄露)
- Cuid2熵:Web加密 + 时间戳 + 计数器 + 主机指纹 + 哈希算法
测试
在每次提交之前,我们会在7个不同的CPU核心上并行生成超过1000万个ID进行测试。每批测试中,我们都会进行直方图分析,以确保整个熵范围内分布均匀随机。任何偏差都会增加ID冲突的可能性,因此如果发现任何偏差,我们的测试会自动失败。
我们还生成随机图并进行目视检查。
故障排除
某些React Native环境可能缺少TextEncoding功能,需要进行polyfill。以下两种方法对遇到此问题的用户都有效:
npm install --save fast-text-encoding
然后,在导入Cuid2之前:
import "fast-text-encoding";
或者,如果上述方法不起作用:
npm install --save text-encoding-polyfill
然后,在导入Cuid2之前:
import "text-encoding-polyfill";
在Jest中使用
Jest使用jsdom,它构建的全局对象不符合当前标准。当使用jsdom环境时,Jest存在一个已知问题。new TextEncoder().encode()
和new Uint8Array()
的结果不同,参见jestjs/jest#9983。
要解决jsdom(以及Jest)的这个限制,你需要使用自定义环境来覆盖jsdom提供的Uint8Array:
安装jest-environment-jsdom。确保使用与你的jest相同的版本。参考Stack Overflow上的这个回答。
❯ npm i jest-environment-jsdom@27
在根目录创建jsdom-env.js
文件:
const JSDOMEnvironmentBase = require('jest-environment-jsdom');
Object.defineProperty(exports, '__esModule', {
value: true
});
class JSDOMEnvironment extends JSDOMEnvironmentBase {
constructor(...args) {
const { global } = super(...args);
global.Uint8Array = Uint8Array;
}
}
exports.default = JSDOMEnvironment;
exports.TestEnvironment = JSDOMEnvironment;
更新脚本以使用自定义环境:
{
// ...
"scripts": {
// ...
"test": "react-scripts test --env=./jsdom-env.js",
// ...
},
}
JSDOM缺少功能
JSDOM不支持TextEncoder和TextDecoder,参见jsdom/jsdom#2524。
在Jest中,Uint8Array/TextEncoder/TextDecoder等功能可能在jsdom环境中可用,但可能产生与平台标准不同的结果。这些是已知的错误,可能会在某个时候由jsdom解决,但目前没有明确的时间表。
请注意,这个问题可能会影响任何依赖TextEncoder或TextDecoder标准的包。如果你想使用一个简单且可靠的测试运行器,可以尝试Riteway。
赞助商
本项目得以实现,要感谢以下赞助商:
- DevAnywhere - 为软件构建者提供专家指导,从初级开发人员到软件领导者如VPE、CTO和CEO。
- EricElliottJS.com - 通过视频和互动课程按需学习JavaScript。