前言

用户数据的数据库存储,一直是后端开发经典话题,今天就通过几种不同的加密方式(hash)简单的探讨一下

在开始之前我先思考下面几个问题

为什么用户信息在数据库中还需要进行加密?

  • 防止数据泄露
    • 外部攻击:黑客可能通过SQL注入、暴力破解等手段获取数据库访问权限,加密后即使数据被窃取,也无法直接读取。
    • 内部威胁:内部人员可能滥用权限访问敏感数据,加密后即使有权限,也无法直接查看明文。
    • 防止撞库: 很多情况下用户可能在多个平台使用相同密码进行注册,一但用户的密码泄露其他平台也可能受到攻击
  • 保护用户隐私
    • 敏感信息:如身份证号、银行卡号等,泄露可能导致身份盗窃和财产损失。
    • 信任维护:加密有助于维护用户信任,避免因数据泄露导致声誉损失。

常见的加密类型有哪些?

  • 对称加密:如AES,加密解密使用相同密钥,适合大数据量加密 应用场景例如 各种通行数据加密,比如 https等。
  • 非对称加密:如RSA,加密解密使用不同密钥,适合密钥分发和数字签名,应用场景例如 各种通信协议的身份验证,比如 ssh ,https等。
  • 哈希函数(不可逆的):如SHA-256,用于存储密码哈希值,确保密码安全。 应用场景例如 JWT,信息摘要等。

为啥对密码 hash 前还要加 salt ?

  1. 抵抗彩虹表攻击
  2. 抵抗字典攻击
  3. 保证hash结果的唯一性

密码加密方式(hash)几种实现

Plain Text Password (CSDN行为)

这种情况一定是要杜绝的,这样无异于裸奔一但数据库泄露用户所有的数据攻击者都可以直接使用(用户绝对不允许的)! 就算运气的没有泄露,但用户数据可都是是明文在呈现在开发者面前的用户同样不允许,更别说万一开发者心怀不轨后果不堪设想!

MD5 & SHA-1

MD5:

1
2
3
4
5
6
7
8
9
10
11
12
13
public String encryption(String password) {
byte[] encode = password.getBytes(StandardCharsets.UTF-8);
String hash = null;
try{
MessageDigest md5 = MessageDigest.getInstance("MD5");
md5.update(encode);
byte[] digest = md5.digest();
hash = Base64.getEncoder().encodeToString(digest);
}catch (NoSuchAlgorithmException e){
e.printStackTrace();
}
return hash;
}

SHA-1:

1
2
3
4
5
6
7
8
9
10
11
12
13
public String encryption(String password) {
byte[] encode = password.getBytes(StandardCharsets.UTF-8);
String hash = null;
try{
MessageDigest md5 = MessageDigest.getInstance("SHA-1");
sh1.update(encode);
byte[] digest = sha1.digest();
hash = Base64.getEncoder().encodeToString(digest);
}catch (NoSuchAlgorithmException e){
e.printStackTrace();
}
return hash;
}

通过MD5或SHA-1的hash数据库不再是明文存储即使泄露攻击者也无法直接使用。攻击者虽然无法直接通过hash值获取密码,但常见的hash加密方式就这几种,攻击者大可通过字典攻击和提前准备相应算法的彩虹表加速破解,由于这些算法都是基于hash的所以有一下两个问题,一、相同的密码hash出来的值是相同的,二、存在两个不同的密码但具有相同的hash值,那么通过字典或 彩虹表攻击跟容易成功即使是破译出的密码与原密码不相同

相关链接

MD5能被破解吗?

MD5 Online

SHA-256 + salt

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 //合并password和salt
private byte[] mergePasswordAndSalt(String password ,String salt){
byte[] encode = password.getBytes(StandardCharsets.UTF_8);
if(!Objects.isNull(salt) && !salt.isBlank()) {
byte[] saltBytes = salt.getBytes(StandardCharsets.UTF_8);
encode = Arrays.copyOf(encode,encode.length + saltBytes.length);
System.arraycopy(saltBytes,0,encode,encode.length - saltBytes.length,saltBytes.length);
}
return encode;
}
//加密函数
public String encryption(String password, String salt) {
byte[] encode = mergePasswordAndSalt(password,salt);
String res = null;
try {
MessageDigest md256 = MessageDigest.getInstance("SHA-256");
md256.update(encode);
byte[] hash = md256.digest();
res = Base64.getEncoder().encodeToString(hash);
}catch (NoSuchAlgorithmException e){
e.printStackTrace();
}
return res;
}

SHA-256 + salt 很好的解决了MD5和SHA-1的问题,通过加salt无法通过预先生成的彩虹表加速破译,由于salt是随机的所以即使用户密码相同但hash出来的值也不相同,可以有效抵御字典攻击,因为salt每一个用户都有所以攻击者无法批量处理用户必须单独处理,使用SHA-256增大hash的结果域有效减少碰撞的概率同时也有效的抵御了字典和枚举攻击

PBKDF2 + salt

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public String encryption(String password, String salt) {

String res = null;
try {
PBEKeySpec pbeKeySpec = new PBEKeySpec(password.toCharArray(),
salt.getBytes(StandardCharsets.UTF_8), 10000, 256);
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
SecretKey secretKey = secretKeyFactory.generateSecret(pbeKeySpec);
res = Base64.getEncoder().encodeToString(secretKey.getEncoded());

} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
e.printStackTrace();
}
return res;
}

PBKDF2 : 一种特意设计的慢速算法主要针对暴力枚举

相关链接

PBKDF2 - 维基百科,自由的百科全书

Bcrypt

Bcrypt 在java的标准库中并未实现所以我们使用第三方库,依赖如下

1
2
3
4
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>

关于该依赖的介绍,请参见Spring Security 加密模块 :: Spring Security Reference

示例代码

1
2
3
4
5
6
7
8
9
10
11
12

int strength = 10;
PasswordEncoder passwordEncoder = new BcryptPasswordEncoder(strength);

public String encryption(String password) {
return passwordEncoder.encode(password);
}

public boolean verity(String password, String summary) {
return passwordEncoder.matches(password,summary);
}

非常简单,但要注意strength的大小,strength越大就越安全进行相应的match时也越慢,所以根据实际情况选择合适的大小以防拖慢相应接口

附录

彩虹表

彩虹表(Rainbow Table)是一种用于破解哈希密码的预计算表,通过空间换时间的方式加速密码破解过程。以下是详细解释:

彩虹表的基本概念

  • 目的:彩虹表用于快速查找哈希值对应的原始密码,常用于破解使用哈希函数存储的密码。
  • 组成:彩虹表由多条链组成,每条链包含一组密码和对应的哈希值。

彩虹表的工作原理

彩虹表的核心思想是通过预计算和存储密码-哈希对,减少破解时的计算量。

哈希链(Hash Chain)
  • 定义:哈希链是一系列密码和哈希值的交替序列。
  • 生成过程
    1. 选择一个初始密码(起点)。
    2. 对密码进行哈希,得到哈希值。
    3. 使用归约函数(Reduction Function)将哈希值转换为另一个密码。
    4. 重复上述步骤,生成一条链。
归约函数(Reduction Function)
  • 定义:归约函数将哈希值映射回密码空间,生成一个新的密码。
  • 作用:归约函数用于在哈希链中生成下一个密码,确保链的连续性。
彩虹表的构建
  • 多条链:彩虹表包含多条哈希链,每条链有不同的初始密码。
  • 存储:只存储每条链的起点和终点,减少存储空间。

彩虹表的使用

  • 查找过程
    1. 给定一个哈希值,使用归约函数生成一个密码。
    2. 检查该密码是否在彩虹表的终点集合中。
    3. 如果找到匹配的终点,沿着对应的链重新计算,找到原始密码。
    4. 如果未找到,重复归约和哈希过程,直到找到匹配或遍历所有可能。

彩虹表的优缺点

优点
  • 时间效率:通过预计算,显著减少破解时间。
  • 空间效率:只存储链的起点和终点,节省存储空间。
缺点
  • 存储需求:尽管优化,大型彩虹表仍需大量存储空间。
  • 碰撞问题:不同链可能合并,导致查找失败。
  • 防御措施:加盐(Salt)等防御手段可有效抵御彩虹表攻击。

防御彩虹表攻击的措施

  • 加盐(Salt):在密码哈希前添加随机字符串,使相同密码的哈希值不同,增加破解难度。
  • 密钥扩展(Key Stretching):如PBKDF2、bcrypt等算法,增加哈希计算复杂度,延长破解时间。
  • 多因素认证:结合多种认证方式,提升账户安全性。

总结

彩虹表通过预计算和存储哈希链,加速密码破解过程。尽管其具有时间和空间效率,但通过加盐、密钥扩展和多因素认证等措施,可有效防御彩虹表攻击,提升密码存储的安全性。

Base64编码

Base64 是一种用于将二进制数据编码为文本格式的编码方案。它通过将二进制数据转换为由 64 个可打印字符组成的字符串,使得数据可以在文本协议(如电子邮件、URL、JSON)中安全传输。以下是 Base64 的详细讲解:


Base64 的背景

  • 起源:Base64 最初用于电子邮件传输,因为早期的电子邮件系统只能处理文本数据,无法直接传输二进制数据(如图片、音频)。
  • 用途
    • 在文本协议中传输二进制数据。
    • 将二进制数据嵌入到文本文件(如 XML、JSON)中。
    • 在 URL 中传输数据(需使用 URL 安全的 Base64 变种)。

Base64 的工作原理

字符集

Base64 使用 64 个可打印字符来表示二进制数据:

  • 标准字符集A-Za-z0-9+/
  • 填充字符=(用于填充数据,使长度满足 4 的倍数)。
编码过程
  1. 将二进制数据分组
    • 每 3 个字节(24 位)分为一组。
  2. 将 24 位数据分为 4 个 6 位的块
    • 每个 6 位的块对应一个 Base64 字符。
  3. 将 6 位的块转换为字符
    • 根据 Base64 字符集映射为对应的字符。
  4. 处理填充
    • 如果数据长度不是 3 的倍数,使用 = 填充。
解码过程
  1. 将 Base64 字符串分组
    • 每 4 个字符分为一组。
  2. 将每个字符转换为 6 位的块
    • 根据 Base64 字符集映射为对应的 6 位值。
  3. 将 4 个 6 位的块合并为 3 个字节
    • 恢复原始的二进制数据。
  4. 去除填充字符
    • 忽略末尾的 = 字符。

Base64 的示例

编码示例
  • 输入数据"Hello"(二进制表示为 48 65 6C 6C 6F)。
  • 编码过程
    1. "Hello" 转换为二进制:01001000 01100101 01101100 01101100 01101111
    2. 分组为 6 位块:010010 000110 010101 101100 011011 000110 1111
    3. 将每个 6 位块转换为 Base64 字符:
      • 010010 -> S
      • 000110 -> G
      • 010101 -> V
      • 101100 -> s
      • 011011 -> b
      • 000110 -> G
      • 1111 -> 需要填充,补全为 111100 -> 8
    4. 添加填充字符:SGVsbG8=
  • 编码结果"SGVsbG8="
解码示例
  • 输入数据"SGVsbG8="
  • 解码过程
    1. 将每个字符转换为 6 位块:
      • S -> 010010
      • G -> 000110
      • V -> 010101
      • s -> 101100
      • b -> 011011
      • G -> 000110
      • 8 -> 111100
    2. 合并为 8 位字节:
      • 01001000 -> H
      • 01100101 -> e
      • 01101100 -> l
      • 01101100 -> l
      • 01101111 -> o
    3. 去除填充字符。
  • 解码结果"Hello"

Base64 的变种

  • 标准 Base64
    • 字符集:A-Za-z0-9+/
    • 填充字符:=
  • URL 安全的 Base64
    • 字符集:A-Za-z0-9-_
    • 去除了 +/,避免与 URL 中的特殊字符冲突。
  • MIME Base64
    • 用于电子邮件传输,每 76 个字符插入换行符。

Base64 的优缺点

优点
  • 文本兼容性:可以在文本协议中传输二进制数据。
  • 简单易用:编码和解码过程简单,广泛支持。
  • 数据完整性:编码后的数据不会丢失信息。
缺点
  • 数据膨胀:编码后的数据比原始数据大约 33%。
  • 不适合加密:Base64 不是加密算法,编码后的数据可以轻松解码。

Base64 的应用场景

  1. 电子邮件附件:将二进制文件(如图片、文档)编码为文本格式。
  2. 数据嵌入:将二进制数据嵌入到文本文件(如 XML、JSON)中。
  3. URL 传输:在 URL 中传输二进制数据(使用 URL 安全的 Base64)。
  4. 密码存储:将加密密钥或哈希值编码为文本格式。

Base64 的实现示例(Java)

以下是使用 Java 标准库进行 Base64 编码和解码的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.Base64;

public class Base64Example {
public static void main(String[] args) {
String originalText = "Hello, Base64!";

// 编码
String encodedText = Base64.getEncoder().encodeToString(originalText.getBytes());
System.out.println("Encoded: " + encodedText);

// 解码
byte[] decodedBytes = Base64.getDecoder().decode(encodedText);
String decodedText = new String(decodedBytes);
System.out.println("Decoded: " + decodedText);
}
}

总结

  • Base64 是一种将二进制数据编码为文本格式的编码方案。
  • 核心原理:将每 3 个字节的数据转换为 4 个 Base64 字符。
  • 应用场景:电子邮件附件、数据嵌入、URL 传输等。
  • 优点:文本兼容性高,简单易用。
  • 缺点:数据膨胀,不适合加密。

Base64 是数据传输和存储中的重要工具,广泛应用于各种场景中。

其他问题

在junit的环境下无法使用Scanner获得输入

原因如下:

【强制】单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执
行过程必须完全自动化才有意义。

输出结果需要人工检查的测试不是一个好的单元测试。单元 测试中不准使用 System.out 来进行人肉验证,必须使用 assert 来验证。

【强制】单元测试是可以重复执行的,不能受到外界环境的影响。

说明:单元测试通常会被放到持续集成中,每次有代码check in时单元测试都会被执行。

如 果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。

正例:为了不受外界环境影响,要求设计代码时就把 SUT 的依赖改成注入,在测试时用 spring 这样的 DI 框架注入一个本地(内存)实现或者 Mock 实现。

内容参考《阿里巴巴Java开发规范》

常见的解决方案是写到main方法中。

idea的环境下获取console为null

在idea下运行以下代码:

1
2
3
4
5
6
7
public static void main(String[] args) {
Console console = System.console();
String username = console.readLine("请输入用户名!");
String nick = console.readLine("请输入呢称。");
char[] chs = console.readPassword("请输入密码");
String password = new String(chs);
}

会报空console指针异常

原因:如果Java程序要与windows下的cmd或者Linux下的Terminal交互,就可以使用这个Java Console类代劳。Java要与Console进行交互,不总是能得到可用的Java Console类的。一个JVM是否有可用的Console,依赖于底层平台和JVM如何被调用。如果JVM是在交互式命令行(比如Windows的cmd)中启动的,并且输入输出没有重定向到另外的地方,那么就可以得到一个可用的Console实例。
但当使用Eclipse等IDE运行以上代码时Console中将会为null。 表示Java程序无法获得Console实例,是因为JVM不是在命令行中被调用的,或者输入输出被重定向了。在Eclipse诸如类似的IDE工具中运行Console类。如果没有对Console实例判空操作,结果使用了该实例会抛出java.lang.NullPointerException异常。

『java 核心技术卷 1』如下
java 核心技术卷