1. 项目概述:为什么前端开发者需要了解MD5?
在Web开发的日常里,我们经常需要处理一些敏感信息,比如用户的密码。直接把这些信息明文存储在数据库里,无异于把家门钥匙放在门垫下面。因此,加密成为了前端开发中一个绕不开的话题。而MD5,作为加密算法家族中一位“家喻户晓”的成员,尽管在如今的安全领域已不再被推荐用于密码存储,但它依然在许多场景下扮演着重要角色,例如生成文件签名、校验数据完整性,或是在一些对安全性要求不高的内部系统中进行快速摘要计算。
你可能会问,既然不推荐用于密码,为什么还要学?原因很简单:
理解MD5是理解现代密码学的一个绝佳起点
。它的实现相对直观,能让你清晰地看到消息摘要算法的核心流程——如何将任意长度的输入,转换成一个固定长度(128位)的“指纹”。这个过程涉及到位操作、逻辑函数、循环位移等基础但关键的计算机科学概念。亲手实现一遍MD5,远比调用十次
crypto.subtle.digest('MD5', buffer)
更能加深你对数据完整性、哈希碰撞等概念的理解。此外,在一些遗留系统、CTF(Capture The Flag)竞赛题,或是需要快速生成唯一标识符(但非密码)的场景中,你依然会遇到它。
所以,这篇文章不是鼓励你在新项目里用MD5存密码,而是带你深入它的内部,看看这个经典的算法是如何在JavaScript中一步步将“Hello World”变成那一串32位的十六进制字符的。我们会从零开始,用纯JavaScript实现一个MD5函数,并探讨其在实际开发中的合理应用场景与注意事项。
2. MD5算法核心原理拆解
在动手写代码之前,我们必须先搞清楚MD5到底在干什么。你可以把它想象成一个极其复杂的“搅拌机”。你扔进去任意长度的数据(消息),它经过四轮、每轮16步的“搅拌”(压缩函数处理),最终吐出一杯128位的“混合果汁”(摘要)。这个摘要有两个重要特性:1. 理论上,不同的输入会产生截然不同的摘要;2. 几乎不可能从摘要反推出原始输入。
2.1 算法流程总览
MD5处理输入消息的整个过程可以分为以下几个清晰的步骤:
-
数据填充
:首先,确保输入数据的长度(以位为单位)对512取模的结果是448。如果不是,就先在消息末尾补一个
1,然后补足够多的0,直到满足条件。这一步是为了给下一步留出空间。 - 附加长度值 :在填充后的消息末尾,再附加上原始消息长度的64位表示(低位字节优先)。经过1和2两步,整个消息的长度恰好是512位的整数倍。
- 初始化MD缓冲区 :算法使用一个128位的缓冲区(通常由四个32位变量A、B、C、D表示)来存放中间和最终结果。它们被初始化为固定的常数。
- 处理消息分组 :将填充附加后的消息按512位(64字节)一个分组进行切分。每个分组都会与当前的缓冲区ABCD进行四轮主循环运算,每一轮包含16次操作,每次操作都会用到分组中不同的16个32位字、一个正弦函数生成的常数表T以及一个特定的左循环位移函数。
- 输出 :当所有分组都处理完毕后,将缓冲区A、B、C、D中的值按低位字节优先的顺序连接起来,就得到了128位的MD5摘要,通常我们会将其转换为32位的十六进制字符串进行展示。
这个过程的核心在于第4步的“压缩函数”,它包含了MD5算法的精华。
2.2 压缩函数与四轮循环
压缩函数是MD5的心脏,它接受当前的缓冲区(ABCD)和一个512位的输入分组,输出一个新的缓冲区。它由四轮结构相似但逻辑函数不同的循环构成,每轮16步,共64步。
每一轮循环都使用一个不同的非线性逻辑函数(F, G, H, I):
-
F轮
:
F(X, Y, Z) = (X & Y) | ((~X) & Z)(选择函数,如果X则Y,否则Z) -
G轮
:
G(X, Y, Z) = (X & Z) | (Y & (~Z)) -
H轮
:
H(X, Y, Z) = X ^ Y ^ Z(逐位异或) -
I轮
:
I(X, Y, Z) = Y ^ (X | (~Z))
在每一步中,算法会做这样几件事:
- 将缓冲区B、C、D中的值通过当前轮的逻辑函数进行混合。
- 将结果与缓冲区A相加。
-
加上当前分组中特定的一个32位字
M[k]。 -
加上一个常数
T[i](这个常数是通过Math.abs(Math.sin(i + 1)) * 2^32计算并取整得到的,i从1到64)。 -
将结果进行一个不固定的左循环位移
s位。 - 再与缓冲区B相加。
- 最后对缓冲区A、B、C、D进行轮换赋值。
注意 :这里的加法都是模
2^32加法,即结果超过32位时自动溢出,这在JavaScript中可以通过(a + b) >>> 0这样的无符号右移0位操作来模拟,或者直接使用(a + b) & 0xFFFFFFFF。
正是这64步精密的、非线性的、带位移的运算,使得输入消息中哪怕一个比特的改变,也会像蝴蝶效应一样,导致最终输出的摘要面目全非。
3. 手把手实现JavaScript版MD5
理解了原理,我们现在用JavaScript来实现它。我们将构建一个名为
md5
的函数,它接收一个字符串,返回其MD5哈希值的十六进制字符串。
3.1 工具函数准备
首先,我们需要一些辅助函数来处理位运算和编码。JavaScript的位运算操作是32位有符号的,我们需要小心处理以确保无符号行为。
/**
* 将字符串转换为UTF-8编码的字节数组
* @param {string} string - 输入字符串
* @returns {Array<number>} 字节数组
*/
function stringToUtf8Bytes(string) {
const utf8 = [];
for (let i = 0; i < string.length; i++) {
let charCode = string.charCodeAt(i);
if (charCode < 0x80) {
// 单字节字符 (0x00-0x7F)
utf8.push(charCode);
} else if (charCode < 0x800) {
// 双字节字符 (0x80-0x7FF)
utf8.push(0xc0 | (charCode >> 6));
utf8.push(0x80 | (charCode & 0x3f));
} else if (charCode < 0x10000) {
// 三字节字符 (0x800-0xFFFF)
utf8.push(0xe0 | (charCode >> 12));
utf8.push(0x80 | ((charCode >> 6) & 0x3f));
utf8.push(0x80 | (charCode & 0x3f));
} else {
// 四字节字符 (0x10000-0x10FFFF),JavaScript内部使用UTF-16代理对表示
i++; // 跳过代理对的高位
// 简化处理,对于非常用字符,此处可以抛出错误或用一个替换字符,这里我们用一个占位符
utf8.push(0xef, 0xbf, 0xbd); // Unicode替换字符 � 的UTF-8编码
}
}
return utf8;
}
/**
* 将32位整数左循环移位
* @param {number} x - 要移位的数
* @param {number} n - 移位位数
* @returns {number} 移位后的结果
*/
function leftRotate(x, n) {
return (x << n) | (x >>> (32 - n));
}
/**
* 将32位整数转换为8位十六进制字符串(小写),并确保是8字符
* @param {number} num - 输入数字
* @returns {string} 十六进制字符串
*/
function toHexString(num) {
// 确保处理为无符号32位整数
let hex = ((num >>> 0) & 0xFFFFFFFF).toString(16);
// 补零到8位
while (hex.length < 8) {
hex = '0' + hex;
}
return hex;
}
3.2 核心MD5函数实现
接下来是核心的
md5
函数。我们将严格按照算法步骤进行。
function md5(inputString) {
// 步骤1&2: 消息填充与附加长度
const msgBytes = stringToUtf8Bytes(inputString);
const originalBitLength = inputString.length * 8; // 注意:这是字符数*8,对于非ASCII字符不精确,但我们的stringToUtf8Bytes已处理。
// 计算填充。先添加一个 0x80 字节(二进制10000000),即补一个1和七个0。
msgBytes.push(0x80);
// 计算当前字节数对64取模,需要填充到56字节(448位)模64。
while ((msgBytes.length % 64) !== 56) {
msgBytes.push(0x00);
}
// 附加原始位长度的低64位(8字节),以小端序(低位字节在前)存储。
// JavaScript数字是双精度浮点,我们需要分高低32位处理。
const lengthLow = originalBitLength & 0xFFFFFFFF;
const lengthHigh = (originalBitLength / 0x100000000) & 0xFFFFFFFF;
for (let i = 0; i < 8; i++) {
// 按小端序推入字节
msgBytes.push((lengthLow >>> (i * 8)) & 0xFF);
}
for (let i = 0; i < 8; i++) {
msgBytes.push((lengthHigh >>> (i * 8)) & 0xFF);
}
// 步骤3: 初始化MD缓冲区 (小端序)
let a = 0x67452301;
let b = 0xefcdab89;
let c = 0x98badcfe;
let d = 0x10325476;
// 预计算常数表 T
const T = new Array(65); // T[1] 到 T[64]
for (let i = 1; i <= 64; i++) {
T[i] = Math.floor(Math.abs(Math.sin(i)) * 0x100000000) >>> 0;
}
// 步骤4: 处理每个512位(64字节)分组
for (let offset = 0; offset < msgBytes.length; offset += 64) {
// 将当前分组的64个字节转换为16个32位字(小端序)
const M = new Array(16);
for (let i = 0; i < 16; i++) {
const j = offset + i * 4;
M[i] = (msgBytes[j]) |
(msgBytes[j + 1] << 8) |
(msgBytes[j + 2] << 16) |
(msgBytes[j + 3] << 24);
// 确保为无符号32位
M[i] >>>= 0;
}
// 保存当前缓冲区的值
let AA = a;
let BB = b;
let CC = c;
let DD = d;
// 定义四轮循环中每一步的通用操作函数
const md5cycle = function (func, a, b, c, d, x, s, t) {
// 模2^32加法
const temp = (a + func(b, c, d) + x + t) >>> 0;
a = (b + leftRotate(temp, s)) >>> 0;
return a;
};
// 定义四轮逻辑函数
const F = (x, y, z) => (x & y) | ((~x) & z);
const G = (x, y, z) => (x & z) | (y & (~z));
const H = (x, y, z) => x ^ y ^ z;
const I = (x, y, z) => y ^ (x | (~z));
// 第一轮 (F函数)
a = md5cycle(F, a, b, c, d, M[0], 7, T[1]);
d = md5cycle(F, d, a, b, c, M[1], 12, T[2]);
c = md5cycle(F, c, d, a, b, M[2], 17, T[3]);
b = md5cycle(F, b, c, d, a, M[3], 22, T[4]);
a = md5cycle(F, a, b, c, d, M[4], 7, T[5]);
d = md5cycle(F, d, a, b, c, M[5], 12, T[6]);
c = md5cycle(F, c, d, a, b, M[6], 17, T[7]);
b = md5cycle(F, b, c, d, a, M[7], 22, T[8]);
a = md5cycle(F, a, b, c, d, M[8], 7, T[9]);
d = md5cycle(F, d, a, b, c, M[9], 12, T[10]);
c = md5cycle(F, c, d, a, b, M[10], 17, T[11]);
b = md5cycle(F, b, c, d, a, M[11], 22, T[12]);
a = md5cycle(F, a, b, c, d, M[12], 7, T[13]);
d = md5cycle(F, d, a, b, c, M[13], 12, T[14]);
c = md5cycle(F, c, d, a, b, M[14], 17, T[15]);
b = md5cycle(F, b, c, d, a, M[15], 22, T[16]);
// 第二轮 (G函数)
a = md5cycle(G, a, b, c, d, M[1], 5, T[17]);
d = md5cycle(G, d, a, b, c, M[6], 9, T[18]);
c = md5cycle(G, c, d, a, b, M[11], 14, T[19]);
b = md5cycle(G, b, c, d, a, M[0], 20, T[20]);
a = md5cycle(G, a, b, c, d, M[5], 5, T[21]);
d = md5cycle(G, d, a, b, c, M[10], 9, T[22]);
c = md5cycle(G, c, d, a, b, M[15], 14, T[23]);
b = md5cycle(G, b, c, d, a, M[4], 20, T[24]);
a = md5cycle(G, a, b, c, d, M[9], 5, T[25]);
d = md5cycle(G, d, a, b, c, M[14], 9, T[26]);
c = md5cycle(G, c, d, a, b, M[3], 14, T[27]);
b = md5cycle(G, b, c, d, a, M[8], 20, T[28]);
a = md5cycle(G, a, b, c, d, M[13], 5, T[29]);
d = md5cycle(G, d, a, b, c, M[2], 9, T[30]);
c = md5cycle(G, c, d, a, b, M[7], 14, T[31]);
b = md5cycle(G, b, c, d, a, M[12], 20, T[32]);
// 第三轮 (H函数)
a = md5cycle(H, a, b, c, d, M[5], 4, T[33]);
d = md5cycle(H, d, a, b, c, M[8], 11, T[34]);
c = md5cycle(H, c, d, a, b, M[11], 16, T[35]);
b = md5cycle(H, b, c, d, a, M[14], 23, T[36]);
a = md5cycle(H, a, b, c, d, M[1], 4, T[37]);
d = md5cycle(H, d, a, b, c, M[4], 11, T[38]);
c = md5cycle(H, c, d, a, b, M[7], 16, T[39]);
b = md5cycle(H, b, c, d, a, M[10], 23, T[40]);
a = md5cycle(H, a, b, c, d, M[13], 4, T[41]);
d = md5cycle(H, d, a, b, c, M[0], 11, T[42]);
c = md5cycle(H, c, d, a, b, M[3], 16, T[43]);
b = md5cycle(H, b, c, d, a, M[6], 23, T[44]);
a = md5cycle(H, a, b, c, d, M[9], 4, T[45]);
d = md5cycle(H, d, a, b, c, M[12], 11, T[46]);
c = md5cycle(H, c, d, a, b, M[15], 16, T[47]);
b = md5cycle(H, b, c, d, a, M[2], 23, T[48]);
// 第四轮 (I函数)
a = md5cycle(I, a, b, c, d, M[0], 6, T[49]);
d = md5cycle(I, d, a, b, c, M[7], 10, T[50]);
c = md5cycle(I, c, d, a, b, M[14], 15, T[51]);
b = md5cycle(I, b, c, d, a, M[5], 21, T[52]);
a = md5cycle(I, a, b, c, d, M[12], 6, T[53]);
d = md5cycle(I, d, a, b, c, M[3], 10, T[54]);
c = md5cycle(I, c, d, a, b, M[10], 15, T[55]);
b = md5cycle(I, b, c, d, a, M[1], 21, T[56]);
a = md5cycle(I, a, b, c, d, M[8], 6, T[57]);
d = md5cycle(I, d, a, b, c, M[15], 10, T[58]);
c = md5cycle(I, c, d, a, b, M[6], 15, T[59]);
b = md5cycle(I, b, c, d, a, M[13], 21, T[60]);
a = md5cycle(I, a, b, c, d, M[4], 6, T[61]);
d = md5cycle(I, d, a, b, c, M[11], 10, T[62]);
c = md5cycle(I, c, d, a, b, M[2], 15, T[63]);
b = md5cycle(I, b, c, d, a, M[9], 21, T[64]);
// 将本轮结果与原始缓冲区值相加
a = (a + AA) >>> 0;
b = (b + BB) >>> 0;
c = (c + CC) >>> 0;
d = (d + DD) >>> 0;
}
// 步骤5: 输出(小端序字节序,但ABCD本身是小端序存储的,直接拼接即可)
return toHexString(a) + toHexString(b) + toHexString(c) + toHexString(d);
}
// 测试
console.log(md5('')); // 输出: d41d8cd98f00b204e9800998ecf8427e
console.log(md5('Hello World')); // 输出: b10a8db164e0754105b7a99be72e3fe5
实操心得 :在实现过程中,最易出错的地方是 字节序 和 无符号整数处理 。MD5算法规范定义使用的是 小端序 (Little-Endian),即低位字节在低地址。我们的
M[i]组装和最后的输出拼接都遵循这个规则。JavaScript的位运算只支持32位有符号整数,因此在进行加法后,我们使用>>> 0或& 0xFFFFFFFF来确保结果是32位无符号的,这是模拟模2^32加法的关键技巧。
4. 现代JavaScript环境中的MD5应用与替代方案
虽然我们实现了一个教育意义的MD5函数,但在实际生产环境中,我们几乎不会自己从头写。浏览器和Node.js都提供了更强大、更标准的加密API。
4.1 使用Web Crypto API(浏览器环境)
现代浏览器支持Web Crypto API,这是一个用于执行密码学操作的原生接口,性能和安全性好得多。
async function md5WithCryptoApi(message) {
// 1. 将字符串编码为Uint8Array
const encoder = new TextEncoder();
const data = encoder.encode(message);
// 2. 使用subtle.digest方法计算哈希
const hashBuffer = await crypto.subtle.digest('MD5', data);
// 3. 将ArrayBuffer转换为十六进制字符串
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
}
// 使用示例
md5WithCryptoApi('Hello World').then(hex => console.log(hex)); // 输出: b10a8db164e0754105b7a99be72e3fe5
注意 :
crypto.subtle仅在安全的上下文(HTTPS或localhost)中可用。对于不支持的环境,需要回退方案。
4.2 使用Node.js内置的crypto模块
在Node.js环境中,使用内置的
crypto
模块是标准做法。
const crypto = require('crypto');
function md5Node(str) {
return crypto.createHash('md5').update(str, 'utf8').digest('hex');
}
console.log(md5Node('Hello World')); // 输出: b10a8db164e0754105b7a99be72e3fe5
4.3 为什么MD5不再安全?该用什么替代?
MD5早在2004年就被证明存在严重的碰撞漏洞(即可以人为制造出两个不同内容但MD5值相同的文件)。这意味着它无法用于需要抗碰撞性的场景,例如:
- 数字签名和证书 :攻击者可以伪造一个具有相同MD5签名的恶意文件。
- 密码存储 :这是最大的误区。MD5是 单向哈希函数 ,不是加密函数。加密(如AES)可以解密,哈希不能。但即使作为哈希,MD5也因其快速和易受彩虹表攻击而已被淘汰。对于密码,必须使用 加盐的、故意缓慢的 哈希函数,如 bcrypt、scrypt、Argon2 或 PBKDF2 。
替代方案指南 :
| 场景 | 推荐算法 | 说明 |
|---|---|---|
| 密码哈希 | bcrypt, scrypt, Argon2, PBKDF2 | 专门为密码设计的慢哈希函数,内置盐值和工作因子(成本参数),能有效抵御暴力破解和彩虹表。 |
| 文件完整性校验 | SHA-256, SHA-3, BLAKE2 | 需要抗碰撞性。SHA-256是目前最广泛使用的替代品。对于需要更快速度的场景,BLAKE2是优秀选择。 |
| 需要唯一标识符(非密码) | SHA-1(谨慎), MD5(仅限非安全场景) | 例如生成缓存键、ETag。在确保不会因碰撞导致安全问题的内部场景,MD5因其简短和计算快仍有使用,但新项目建议用SHA-256。 |
| 消息认证码 | HMAC-SHA256 | 用于验证消息在传输过程中未被篡改,并确认发送者身份。 |
在JavaScript中的密码哈希示例(使用bcryptjs库) :
npm install bcryptjs
const bcrypt = require('bcryptjs');
const saltRounds = 12; // 工作因子,越高越安全但也越慢
// 哈希密码
const plainPassword = 'mySuperSecretPassword';
bcrypt.hash(plainPassword, saltRounds, function(err, hash) {
// 将hash存储到数据库
console.log('Hashed Password:', hash);
// 验证密码
bcrypt.compare(plainPassword, hash, function(err, result) {
console.log('Password match:', result); // true
});
});
5. 实战应用场景与避坑指南
尽管有安全缺陷,MD5在特定非安全关键场景下仍有其价值。关键在于理解其边界。
5.1 合理应用场景
- 生成短链接或唯一标识符 :将长URL进行MD5哈希,取前若干位作为短码。虽然存在碰撞理论可能,但在海量数据下概率极低,且即使碰撞也只是导致两个不同的长URL映射到同一个短链,在大多数业务中是可接受的。不过,更专业的做法是使用Base62编码的自增ID或雪花算法。
- 文件或数据块的去重与校验 :在网盘同步、P2P下载中,可以用MD5作为文件块的指纹,快速判断本地是否已存在相同内容,避免重复上传/下载。例如,在断点续传时校验分片完整性。
- 缓存键生成 :将复杂的查询参数或请求体进行MD5哈希,生成一个固定长度的字符串作为Redis等缓存系统的Key。
- ETag生成(需谨慎) :HTTP协议中的ETag头可用于缓存验证。对于静态资源,可以用文件内容的MD5作为弱ETag。但需注意,如果后端是集群部署,要确保同一文件在所有服务器上生成的MD5一致(即文件内容完全一致)。
5.2 常见陷阱与避坑指南
-
编码陷阱
:这是最常见的错误。
MD5是对字节序列进行运算,而不是字符串
。我们的实现中使用了
stringToUtf8Bytes。如果你直接对字符串进行charCodeAt,对于非ASCII字符(如中文),不同编码(UTF-8, GBK)会产生不同的字节序列,从而得到不同的MD5值。 务必明确指定和统一编码 ,通常使用UTF-8。 - 输出格式 :MD5输出是128位,即16字节。通常表示为32位十六进制字符串(小写)。有时也会看到Base64编码(24字符)。确保你的系统前后端、不同语言库之间的输出格式一致。
- 性能考量 :纯JavaScript实现的MD5在需要处理大量数据(如大文件)时性能堪忧。在这种情况下,应优先使用上述提到的原生API(Web Crypto / Node.js crypto),或者考虑在Web Worker中执行计算以避免阻塞主线程。
-
安全误区重申
:
- 绝对不要用MD5存储密码 。
- 不要用MD5做数字签名或任何需要强抗碰撞性的安全校验 。
- 如果用于校验文件下载是否完整,需意识到攻击者可能替换文件并制造相同MD5的恶意文件。此时应使用SHA-256或更强的哈希,并配合HTTPS。
5.3 调试与验证技巧
当你实现或使用MD5遇到问题时,如何验证?
-
使用标准测试向量
:有一些公认的MD5值可以用来验证你的实现是否正确。
-
""(空字符串) ->d41d8cd98f00b204e9800998ecf8427e -
"a"->0cc175b9c0f1b6a831c399e269772661 -
"abc"->900150983cd24fb0d6963f7d28e17f72 -
"message digest"->f96b697d7cb7938d525a2f31aaf161d0
-
- 在线工具交叉验证 :使用可靠的在线MD5计算工具(注意选择UTF-8编码选项)进行对比。但切勿用其处理真实敏感数据。
- 分步调试 :对于自定义实现,可以打印出填充后的消息字节数组、每个分组处理前的M数组、每轮循环后的ABCD值,与已知正确的实现进行逐步骤比对。
6. 从MD5延伸:前端加密的边界与最佳实践
通过MD5的深入探讨,我们触及了前端加密的一个核心矛盾点: 前端代码是公开透明的,任何“加密”逻辑都可被分析、模拟和绕过 。因此,前端加密的目的通常不是提供绝对的安全,而是增加攻击门槛、保护用户隐私(防止明文传输)、或满足合规性要求。
前端加密的合理定位 :
- HTTPS的补充 :在已经使用HTTPS的通道上,对敏感数据(如密码)再进行一次客户端哈希(需加盐),可以防止在服务器日志或中间代理中偶然记录下明文密码。但这 不能替代 服务器端的强密码哈希。
- 非对称加密的客户端应用 :使用公钥加密数据,只有拥有私钥的服务器能解密。这可以确保传输过程中即使被截获也无法解密。常用于加密表单数据,然后再通过HTTPS发送。
- 混淆与隐私保护 :对不希望明文出现在URL或日志中的参数进行哈希或对称加密。
一个更安全的“前端密码处理”流程示例 :
- 用户在客户端输入密码。
- 前端使用一个固定的或从服务器获取的“前端盐”(per-request salt),对密码进行哈希(例如使用SHA-256)。这一步的目的是避免明文密码在浏览器内存中被恶意扩展程序窃取,也避免在HTTPS请求日志中留下明文。
- 将这个哈希值(有时称为“传输密码”)通过HTTPS发送到服务器。
- 服务器端绝不能直接存储这个值 。服务器应使用一个独立的、随机的“后端盐”,对接收到的“传输密码”再次进行强密码哈希(如bcrypt),然后将这个最终哈希值和后端盐一起存入数据库。
这个流程结合了前端混淆和后端强哈希,提供了纵深防御。但它的核心安全依然建立在HTTPS和后端强哈希之上。
最后,回顾MD5,它像是一把刻度磨损但仍可用来量个大概的旧尺子。了解它,能让我们更深刻地理解哈希函数的原理和密码学发展的脉络。但在为你的新项目选择工具时,请务必根据实际的安全需求,拿起更精准、更坚固的现代尺子——如SHA-256家族或专门的密码哈希函数。
2947

被折叠的 条评论
为什么被折叠?



