加密之单向MD5,SHA,HMAC
1 Java加密概述
1.1 Java的安全体系架构介绍
Java中为安全框架提供类和接口。JDK 安全 API 是Java编程语言的核心 API,位于 java.security 包(及其子包),以及sun.securityAPI包(及其子包)中。设计用于帮助开发人员在程序中同时使用低级和高级安全功能。
JDK 1.1 中第一次发布的 JDK 安全中引入了Java 加密体系结构(JCA),指的是用于访问和开发 Java 平台密码功能的构架。在 JDK 1.1 中,JCA 包括用于数字签名和报文摘要的 API。JDK 1.2 大大扩展了 Java 加密体系结构,它还对证书管理基础结构进行了升级以支持 X.509 v3 证书,并为划分细致、可配置性强、功能灵活、可扩展的访问控制引入了新的 Java 安全体系结构。
Java 加密体系结构包含 JDK 1.2 安全 API 中与密码有关的部分。为实现多重、可互操作的密码,它还提供了提供者体系结构。
Java 密码扩展 (JCE))扩展了 JCA API,包括用于加密、密钥交换和信息认证码(MAC)的 API。JCE 和 JDK 密码共同提供了一个与平台无关的完整密码 API。不过现在融到jdk了
2 MD5加密
2.1 概述
与MD5与SHA密切相关的几个类的类图如下:

其中MessageDigestSpi为顶层抽象类,同一个包下的MessageDigest和DigestBase为子抽象类。
在上面的类图中,使用了Delegate(委托)设计模式。这种模式的原理为类B(在此处为Delegage内部类)和类A(在此处为MessageDigestSpi类)是两个互相没有什么关系的类,B具有和A一模一样的方法和属性;并且调用B中的方法和属性就是调用A中同名的方法和属性。B好像就是一个受A授权委托的中介。第三方的代码不需要知道A及其子类的存在,也不需要和A及其子类发生直接的联系,通过B就可以直接使用A的功能,这样既能够使用到A的各种功能,又能够很好的将A及其子类保护起来了。
MD5和SHA的相关代码都在MD5和SHA等类中,但是面向客户的MessageDigest抽象类不需要跟各个实现类打交道,只要通过委托类与其打交道即可
Message Digest Algorithm MD5(中文名为消息摘要算法第五版)为计算机安全领域广泛使用的一种散列函数,用以提供消息的完整性保护。该算法的文件号为RFC 1321.Rivest开发出来,经MD2、MD3和MD4发展而来。
MD5用于确保信息传输完整一致。是计算机广泛使用的杂凑算法之一(又译摘要算法、哈希算法),主流编程语言普遍已有MD5实现。将数据(如汉字)运算为另一固定长度值,是杂凑算法的基础原理,MD5的前身有MD2、MD3和MD4。
MD5的作用是让大容量信息在用数字签名软件签署私人密钥前被压缩成一种保密的格式(就是把一个任意长度的字节串变换成一定长的十六进制数字串)。
2.2 算法原理
对MD5算法简要的叙述可以为:MD5以512位分组来处理输入的信息,且每一分组又被划分为16个32位子分组,经过了一系列的处理后,算法的输出由四个32位分组组成,将这四个32位分组级联后将生成一个128位散列值。
在MD5算法中,首先需要对信息进行填充,使其位长对512求余的结果等于448。因此,信息的位长(Bits Length)将被扩展至N512+448,N为一个非负整数,N可以是零。填充的方法如下,在信息的后面填充一个1和无数个0,直到满足上面的条件时才停止用0对信息的填充。然后,在这个结果后面附加一个以64位二进制表示的填充前信息长度。经过这两步的处理,信息的位长=N512+448+64=(N+1)*512,即长度恰好是512的整数倍。这样做的原因是为满足后面处理中对信息长度的要求
MD5算法是单向不可逆算法
使用md5加密和验证大致流程如下:
2.3 实际操作
2.3.1 加密
加密代码
import java.security.MessageDigest;
private static String encodeByMD5(String originString) {
if (originString != null) {
try {
//创建具有指定算法名称的信息摘要
MessageDigest md = MessageDigest.getInstance("MD5");
//使用指定的字节数组对摘要进行最后更新,然后完成摘要计算
byte[] results = md.digest(originString.getBytes());
//此处是把字节转为十六进制字符
String resultString = byteArrayToHexString(results);
return resultString.toUpperCase();
//此处是 用base64处理
//return new BASE64Encoder().encode(results);
} catch (Exception ex) {
ex.printStackTrace();
}
}
return null;
}
3.3.2 MD5处理后转大写十六进制
字节转十六进制或
使用自己自定义的转十六进制算法
private static String byteArrayToHexString(byte[] b) {
StringBuffer resultSb = new StringBuffer();
for (int i = 0; i < b.length; i++) {
System.out.println(b[i]+"==========="+i);
resultSb.append(byteToHexString(b[i]));
}
return resultSb.toString();
}
private final static String[] hexDigits = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"};
private static String byteToHexString(byte b) {
int n = b;
if (n < 0)
n = 256 + n;
int d1 = n / 16;
int d2 = n % 16;
return hexDigits[d1] + hexDigits[d2];
}
使用Integer自带的转十六进制
private static String byteArrayToHexString(byte[] b) {
StringBuffer resultSb = new StringBuffer();
resultSb.append(byteToHex(b));
return resultSb.toString();
}
public static String byteToHex(byte[] inbuf) {
int i;
String byteStr;
StringBuilder strBuf = new StringBuilder();
for (i = 0; i < inbuf.length; i++) {
//此处的 & 0x00ff是为了 消除负数影响
byteStr = Integer.toHexString(inbuf[i] & 0x00ff);
if (byteStr.length() < 2) {
strBuf.append('0');
}
strBuf.append(byteStr);
}
return new String(strBuf);
}
点击此处了解Integer转换16进制需要 & 0x00ff 的作用
或者使用 字符串格式化处理返回十六进制,其中,%02X 是格式化字符串的模式,其中 % 表示占位符的开始,02 表示最小宽度为 2 位,不足时用零填充,X 表示以大写字母的十六进制形式输出
private static String hexToStr(byte[] hex){
StringBuilder sb = new StringBuilder();
for (byte b:hex){
sb.append(String.format("%02X",b));
}
return sb.toString();
}
2.3.3 MD5验密
//password是加密后字符串
// inputstring 是输入字符串
public static boolean validatePassword(String password, String inputString) {
if (password.equals(encodeByMD5(inputString))) {
return true;
} else {
return false;
}
}
2.4 为什么不推荐MD5加密
2.4.1 加密不安全
MD5 设计的目的是快,一张 RTX 4090 一秒能跑一百多亿次 MD5。这意味着攻击者拿到数据库里的 5f4dcc3b5aa765d61d8327deb882cf99 这串哈希,不需要逆向,他有两种更简单的办法:
- 彩虹表:提前算好几亿个常见密码的 MD5,直接反查。5f4dcc3b5aa765d61d8327deb882cf99 就是 password 的 MD5,Google 一下就出来。
- 暴力爆破:一秒几百亿次哈希,8 位纯数字密码几秒搞定,8 位字母数字组合也就是几小时的事。
加盐能挡住彩虹表(因为每个用户的盐不同,彩虹表提前算好的就用不上了),但挡不住暴力爆破。盐只是让攻击者不能批量搞,但针对单个用户的密码照样能在合理时间内撞出来。
根因:MD5 太快了。存密码需要的是慢哈希——算一次故意让它慢几百毫秒,让攻击者的爆破成本变得不可接受。
BCrypt 就是这种慢哈希里最经典、最稳的一个
2.4.2 BCrypt 的核心:cost factor
打开数据库里任意一条 BCrypt 密文,长这样:$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
拆开看:
$2a$:版本标识$10$:这就是cost factor,也是整个BCrypt的灵魂- 后面那一长串:22 字符的
salt + 31字符的 hash
cost factor 的含义是:BCrypt 内部会做 2^cost 次循环。cost 每加 1,计算时间翻倍。
cost = 10:约 100 ms 算一次;cost = 12:约 400 ms; cost = 14:约 1.5 秒;cost = 16:约 6 秒
这个设计把 安全性 和 硬件性能 解耦了。十年前 cost = 10 很安全,现在硬件快了、10 不够了,只需要把新注册用户的 cost 调到 12 就行——代码逻辑不用改,因为 cost 是写在密文里的,校验时自动读取。
cost 该设多少:
OWASP 2024的推荐是cost ≥ 10,内部系统或高敏感场景建议 12- 普通 web 应用登录:10 或 11(100–200 ms)
- 金融、后台管理:12(400 ms)
- 内部系统一天登录一次的:13(800 ms)
别无脑调高,因为 cost = 14 一次要 1.5 秒,如果有个接口每次都校验密码,服务器 QPS 直接被 cost 吃掉。先压测再定值。
2.4.3 Spring 里正确姿势:别用原始 BCrypt 类
很多教程教你用 org.mindrot.jbcrypt.BCrypt.hashpw(...) 这种原始 API,能跑但不是 Spring 的推荐做法。Spring Security 自己封装了一层:
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// 参数是 cost factor,不传默认是 10
return new BCryptPasswordEncoder(12);
}
}
2.4.4 容易踩的坑
2.4.4.1 有 72 字节上限
BCrypt 的输入最多只取前 72 字节,超出的字节直接丢弃。
这意味着如果你的用户用了一个 100 字符的随机密码(比如从密码管理器里生成的),BCrypt 只会看前 72 字节。理论上有个古怪的攻击场景:两个密码前 72 字节相同、后面不同,在 BCrypt 看来就是同一个密码。
修复: 长密码先过一次 SHA-256 再交给 BCrypt。BCryptPasswordEncoder 2021 年后的版本其实已经建议这种预哈希方案。
String prehashed = Base64.getEncoder().encodeToString(
MessageDigest.getInstance("SHA-256").digest(rawPassword.getBytes(StandardCharsets.UTF_8))
);
String hashed = passwordEncoder.encode(prehashed);
注意:一旦决定预哈希,以后校验也必须预哈希,整个系统要统一
2.4.4.2 $2a$ / $2b$ / $2y$ 傻傻分不清
$2a$:最早的版本,有个 unicode 相关的小 bug(2011 年发现)$2y$:PHP crypt 修复版$2b$:OpenBSD 官方修复版,推荐用这个
Java 的 jBCrypt 历史上默认用 $2a$,Spring Security 的 BCryptPasswordEncoder 默认也是 $2a$,但通过构造函数可以指定:new BCryptPasswordEncoder(BCryptVersion.$2Y, 12);
实际上三种版本现在都能校验通过(Spring Security 的 matches 会自动识别前缀),新系统用 $2y 或 $2b 都行,不用折腾迁移。
2.4.4.3 时序攻击(Timing Attack)
看下面这段代码,哪里有问题?
public boolean login(String username, String rawPassword) {
User user = userRepository.findByUsername(username);
if (user == null) return false;
return passwordEncoder.matches(rawPassword, user.getPassword());
}
问题:用户不存在时,方法几毫秒就返回;用户存在时,方法要花 100+ ms 跑 BCrypt。攻击者可以靠响应时间差,用接口探测出哪些用户名是真实存在的。
修复:用户不存在时也走一次 dummy BCrypt 校验,让响应时间打平。前面登录示例代码里那行 passwordEncoder.matches(rawPassword, "$2a$10$dummy...") 就是干这个用的。
3 SHA加密
3.1 概述
SHA是一种数据加密算法,该算法经过加密专家多年来的发展和改进已日益完善,现在已成为公认的最安全的散列算法之一,并被广泛使用。该算法的思想是接收一段明文,然后以一种不可逆的方式将它转换成一段(通常更小)密文,也可以简单的理解为取一串输入码(称为预映射或信息),并把它们转化为长度较短、位数固定的输出序列即散列值(也称为信息摘要或信息认证代码)的过程。散列函数值可以说是对明文的一种指纹或是摘要所以对散列值的数字签名就可以视为对此明文的数字签名。
安全散列算法SHA(Secure Hash Algorithm,SHA)是美国国家标准技术研究所发布的国家标准FIPS PUB 180。其中规定了SHA-1,SHA-224,SHA-256,SHA-384,和SHA-512这几种单向散列算法。SHA-1,SHA-224和SHA-256适用于长度不超过$2{64}$二进制位的消息。SHA-384和SHA-512适用于长度不超过$2$二进制位的消息。
3.2 原理
SHA-1是一种数据加密算法,该算法的思想是接收一段明文,然后以一种不可逆的方式将它转换成一段(通常更小)密文,也可以简单的理解为取一串输入码(称为预映射或信息),并把它们转化为长度较短、位数固定的输出序列即散列值(也称为信息摘要或信息认证代码)的过程。
单向散列函数的安全性在于其产生散列值的操作过程具有较强的单向性。如果在输入序列中嵌入密码,那么任何人在不知道密码的情况下都不能产生正确的散列值,从而保证了其安全性。
SHA将输入流按照每块512位(64个字节)进行分块,并产生20个字节的被称为信息认证代码或信息摘要的输出。
该算法输入报文的长度不限,产生的输出是一个160位的报文摘要。输入是按512 位的分组进行处理的。SHA-1是不可逆的、防冲突,并具有良好的雪崩效应。
通过散列算法可实现数字签名实现,数字签名的原理是将要传送的明文通过一种函数运算(Hash)转换成报文摘要(不同的明文对应不同的报文摘要),报文摘要加密后与明文一起传送给接受方,接受方将接受的明文产生新的报文摘要与发送方的发来报文摘要解密比较,比较结果一致表示明文未被改动,如果不一致表示明文已被篡改。
MAC(信息认证代码)就是一个散列结果,其中部分输入信息是密码,只有知道这个密码的参与者才能再次计算和验证MAC码的合法性
3.3 实际操作
public static String shaEncode(String inStr) throws Exception {
MessageDigest sha = null;
try {
sha = MessageDigest.getInstance("SHA");
} catch (Exception e) {
e.printStackTrace();
}
byte[] byteArray = inStr.getBytes("UTF-8");
byte[] shaBytes = sha.digest(byteArray);
StringBuffer hexValue = new StringBuffer();
for (int i = 0; i < shaBytes.length; i++) {
int val = ((int) shaBytes[i]) & 0xff;
if (val < 16) {
hexValue.append("0");
}
hexValue.append(Integer.toHexString(val));
}
return hexValue.toString();
}
public static void main(String args[]) throws Exception {
String str = new String("amigoxiexiexingxing");
System.out.println("原始:" + str);
System.out.println("SHA后:" + shaEncode(str));
}
3.4 SHA和MD5比较
因为二者均由MD4导出,SHA-1和MD5彼此很相似。相应的,他们的强度和其他特性也是相似,但还有以下几点不同:
- 对强行攻击的安全性:最显著和最重要的区别是
SHA-1摘要比MD5摘要长32位。使用强行技术,产生任何一个报文使其摘要等于给定报摘要的难度对MD5是2^128数量级的操作,而对SHA-1则是2^160数量级的操作。这样,SHA-1对强行攻击有更大的强度。 - 对密码分析的安全性:由于MD5的设计,易受密码分析的攻击,
SHA-1显得不易受这样的攻击。 - 速度:在相同的硬件上,
SHA-1的运行速度比MD5慢
4 HMAC
4.1 定义
HMAC(Hash Message Authentication Code,散列消息鉴别码,基于密钥的Hash算法的认证协议。消息鉴别码实现鉴别的原理是,用公开函数和密钥产生一个固定长度的值作为认证标识,用这个标识鉴别消息的完整性。使用一个密钥生成一个固定大小的小数据块,即MAC,并将其加入到消息中,然后传输。接收方利用与发送方共享的密钥进行鉴别认证等
4.2 实际操作
初始化HMAC密钥,生成随机密钥,可选算法:HmacMD5,HmacSHA1,HmacSHA256,HmacSHA384,HmacSHA512
public static final String KEY_MAC = "HmacMD5";
public static String initMacKey() throws Exception {
//得到一个 指定算法密钥的密钥生成器
KeyGenerator keyGenerator = KeyGenerator.getInstance(KEY_MAC);
//生成一个密钥
SecretKey secretKey = keyGenerator.generateKey();
return new BASE64Encoder().encode(secretKey.getEncoded());
}
HMAC加密
public static final String KEY_MAC = "HmacMD5";
public static byte[] encryptHMAC(byte[] data, String key) throws Exception {
SecretKey secretKey = new SecretKeySpec(new BASE64Decoder().decodeBuffer(key), KEY_MAC);
Mac mac = Mac.getInstance(secretKey.getAlgorithm());
mac.init(secretKey);
return mac.doFinal(data);
}
使用时,由于每次生成随机密钥 会导致加密结果不一致,可以使用固定密钥而不是每次都变动的密钥
public static void main(String[] args) throws Exception {
String s = initMacKey();
byte[] bytes = encryptHMAC("123456".getBytes(), "123456");
System.out.println(new BASE64Encoder().encode(bytes));
}

浙公网安备 33010602011771号