# 加密与安全

在计算机系统中,什么是加密与安全呢?

我们举个栗子:假设Bob要给Alice发一封邮件,在邮件传送的过程中,黑客可能会窃取到邮件的内容,所以需要防窃听。黑客还可能会篡改邮件的内容,Alice必须有能力识别出邮件有没有被篡改。最后,黑客可能假冒Bob给Alice发邮件,Alice必须有能力识别出伪造的邮件

所以,应对潜在的安全威胁,需要做到三防:

  • 防窃听
  • 防篡改
  • 防伪造

计算机加密技术就是为了实现上述目标,而现代计算机密码学理论是建立在严格的数学理论基础上的,密码学已经逐渐发展成一门科学。对于绝大多数开发者来说,设计一个安全的加密算法非常困难,验证一个加密算法是否安全更加困难,当前被认为安全的加密算法仅仅是迄今为止尚未被攻破。因此,要编写安全的计算机程序,我们要做到:

  • 不要自己设计山寨的加密算法;
  • 不要自己实现已有的加密算法;
  • 不要自己修改已有的加密算法。

# 编码算法

要学习编码算法,我们先来看一看什么是编码。

# ASCII

ASCII码就是一种编码,字母A的编码是十六进制的0x41,字母B是0x42,以此类推

# Unicode

因为ASCII编码最多只能有128个字符,要想对更多的文字进行编码,就需要用Unicode。而中文的中使用Unicode编码就是0x4e2d,使用UTF-8则需要3个字节编码。

因此,最简单的编码是直接给每个字符指定一个若干字节表示的整数,复杂一点的编码就需要根据一个已有的编码推算出来。

比如UTF-8编码,它是一种不定长编码,但可以从给定字符的Unicode编码推算出来。

# URL 编码

URL编码是浏览器发送数据给服务器时使用的编码,它通常附加在URL的参数部分,例如:

https://www.baidu.com/s?wd=%E4%B8%AD%E6%96%87 (opens new window)

之所以需要URL编码,是因为出于兼容性考虑,很多服务器只识别ASCII字符。但如果URL中包含中文、日文这些非ASCII字符怎么办?不要紧,URL编码有一套规则:

  • 如果字符是A~Z,a~z,0~9以及-、_、.、*,则保持不变;
  • 如果是其他字符,先转换为UTF-8编码,然后对每个字节以%XX表示。

# Base64编码

URL编码是对字符进行编码,表示成%xx的形式,而Base64编码是对二进制数据进行编码,表示成文本格式。

Base64编码可以把任意长度的二进制数据变为纯文本,且只包含A~Z、a~z、0~9、+、/、=这些字符。它的原理是把3字节的二进制数据按6bit一组,用4个int整数表示,然后查表,把int整数用索引对应到字符,得到编码后的字符串。

# 编码算法小结

URL 编码和 Base64 编码都是编码算法,它们不是加密算法;

URL 编码的目的是把任意文本数据编码为%前缀表示的文本,便于浏览器和服务器处理;

Base64 编码的目的是把任意二进制数据编码为文本,但编码后数据量会增加 1/3。

# 哈希算法

# 什么是哈希

哈希算法(Hash)又称摘要算法(Digest),它的作用是:对任意一组输入数据进行计算,得到一个固定长度的输出摘要。

哈希算法最重要的特点就是:

  • 相同的输入一定得到相同的输出;
  • 不同的输入大概率得到不同的输出。

哈希算法的目的就是为了验证原始数据是否被篡改。

# 哈希碰撞

哈希碰撞是指,两个不同的输入得到了相同的输出:

"AaAaAa".hashCode(); // 0x7460e8c0
"BBAaBB".hashCode(); // 0x7460e8c0

有童鞋会问:碰撞能不能避免?答案是不能。碰撞是一定会出现的,因为输出的字节长度是固定的,String的hashCode()输出是4字节整数,最多只有4294967296种输出,但输入的数据长度是不固定的,有无数种输入。所以,哈希算法是把一个无限的输入集合映射到一个有限的输出集合,必然会产生碰撞。

碰撞不可怕,我们担心的不是碰撞,而是碰撞的概率,因为碰撞概率的高低关系到哈希算法的安全性。一个安全的哈希算法必须满足:

  • 碰撞概率低;
  • 不能猜测输出。

常用的哈希算法有:

算法 输出长度(位) 输出长度(字节)
MD5 128 bits 16 bytes
RipeMD-160 160 bits 20 bytes
SHA-1 160 bits 20 bytes
SHA-256 256 bits 32 bytes
SHA-512 512 bits 64 bytes

# 使用哈希

Java标准库提供了常用的哈希算法,并且有一套统一的接口。我们以MD5算法为例,看看如何对输入计算哈希:

import java.math.BigInteger;
import java.security.MessageDigest;
// ...
public class Main {
    public static void main(String[] args) throws Exception {
        // 创建一个MessageDigest实例:
        MessageDigest md = MessageDigest.getInstance("MD5");
        // 反复调用update输入数据:
        md.update("Hello".getBytes("UTF-8"));
        md.update("World".getBytes("UTF-8"));
        byte[] result = md.digest(); // 16 bytes: 68e109f0f40ca72a15e05cc22786f8e6
        System.out.println(new BigInteger(1, result).toString(16));
    }
}

# 哈希算法的用途

  • 文件下载

因为相同的输入永远会得到相同的输出,因此,如果输入被修改了,得到的输出就会不同。

我们在网站上下载软件的时候,经常看到下载页显示的哈希,如何利用这个值来判断下载到本地的软件是原始的、未经篡改的文件?我们只需要自己计算一下本地文件的哈希值,再与官网公开的哈希值对比,如果相同,说明文件下载正确,否则,说明文件已被篡改。

  • 存储密码

哈希算法的另一个重要用途是存储用户口令。如果直接将用户的原始口令存放到数据库中,会产生极大的安全风险:

  • 数据库管理员能够看到用户明文口令;
  • 数据库数据一旦泄漏,黑客即可获取用户明文口令。

不存储用户的原始口令,那么如何对用户进行认证?

方法是存储用户口令的哈希,例如 MD5(实际情况中一般使用 SHA-256)。

在用户输入原始口令后,系统计算用户输入的原始口令的MD5并与数据库存储的MD5对比,如果一致,说明口令正确,否则,口令错误。

# 哈希算法小结

哈希算法可用于验证数据完整性,具有防篡改检测的功能;

常用的哈希算法有MD5、SHA-1等;

用哈希存储口令时要考虑彩虹表攻击。

# BouncyCastle 库

我们知道,Java标准库提供了一系列常用的哈希算法。

但如果我们要用的某种算法,Java标准库没有提供怎么办?

方法一:自己写一个,难度很大;

方法二:找一个现成的第三方库,直接使用。

BouncyCastle就是一个提供了很多哈希算法和加密算法的第三方库。它提供了Java标准库没有的一些算法,例如,RipeMD160哈希算法。

Java标准库的java.security包提供了一种标准机制,允许第三方提供商无缝接入。我们要使用BouncyCastle提供的RipeMD160算法,需要先把BouncyCastle注册一下:

public class Main {
    public static void main(String[] args) throws Exception {
        // 注册BouncyCastle:
        Security.addProvider(new BouncyCastleProvider());
        // 按名称正常调用:
        MessageDigest md = MessageDigest.getInstance("RipeMD160");
        md.update("HelloWorld".getBytes("UTF-8"));
        byte[] result = md.digest();
        System.out.println(HexFormat.of().formatHex(result));
    }
}

其中,注册BouncyCastle是通过下面的语句实现的:

Security.addProvider(new BouncyCastleProvider());

注册只需要在启动时进行一次,后续就可以使用BouncyCastle提供的所有哈希算法和加密算法。

# BouncyCastle 库小结

BouncyCastle是一个开源的第三方算法提供商;

BouncyCastle提供了很多Java标准库没有提供的哈希算法和加密算法;

使用第三方算法前需要通过Security.addProvider()注册。

# Hmac 算法

Hmac算法就是一种基于密钥的消息认证码算法,它的全称是Hash-based Message Authentication Code,是一种更安全的消息摘要算法。

Hmac算法总是和某种哈希算法配合起来用的。例如,我们使用MD5算法,对应的就是HmacMD5算法,它相当于“加盐”的MD5:

HmacMD5 ≈ md5(secure_random_key, input)

因此,HmacMD5可以看作带有一个安全的key的MD5。使用HmacMD5而不是用MD5加salt,有如下好处:

  • HmacMD5使用的key长度是64字节,更安全;
  • Hmac是标准算法,同样适用于SHA-1等其他哈希算法;
  • Hmac输出和原有的哈希算法长度一致。

可见,Hmac本质上就是把key混入摘要的算法。验证此哈希时,除了原始的输入数据,还要提供key。

为了保证安全,我们不会自己指定key,而是通过Java标准库的KeyGenerator生成一个安全的随机的key。下面是使用HmacMD5的代码:

import javax.crypto.*;
import java.util.HexFormat;

public class Main {
    public static void main(String[] args) throws Exception {
        KeyGenerator keyGen = KeyGenerator.getInstance("HmacMD5");
        SecretKey key = keyGen.generateKey();
        // 打印随机生成的key:
        byte[] skey = key.getEncoded();
        System.out.println(HexFormat.of().formatHex(skey));
        Mac mac = Mac.getInstance("HmacMD5");
        mac.init(key);
        mac.update("HelloWorld".getBytes("UTF-8"));
        byte[] result = mac.doFinal();
        System.out.println(HexFormat.of().formatHex(result));
    }
}

# Hmac 算法小结

Hmac算法是一种标准的基于密钥的哈希算法,可以配合MD5、SHA-1等哈希算法,计算的摘要长度和原摘要算法长度相同。

# 对称加密

对称加密算法就是传统的用一个密码进行加密和解密。例如,我们常用的WinZIPWinRAR对压缩包的加密和解密,就是使用对称加密算法。

从程序的角度看,所谓加密,就是这样一个函数,它接收密码和明文,然后输出密文:

secret = encrypt(key, message);

而解密则相反,它接收密码和密文,然后输出明文:

plain = decrypt(key, secret);

在软件开发中,常用的对称加密算法有:

算法 密钥长度 工作模式 填充模式
DES 56/64 ECB/CBC/PCBC/CTR/... NoPadding/PKCS5Padding/...
AES 128/192/256 ECB/CBC/PCBC/CTR/... NoPadding/PKCS5Padding/PKCS7Padding/...
IDEA 128 ECB PKCS5Padding/PKCS7Padding/...

密钥长度直接决定加密强度,而工作模式和填充模式可以看成是对称加密算法的参数和格式选择。Java标准库提供的算法实现并不包括所有的工作模式和所有填充模式,但是通常我们只需要挑选常用的使用就可以了。

最后注意,DES算法由于密钥过短,可以在短时间内被暴力破解,所以现在已经不安全了。

# 对称加密小结

对称加密算法使用同一个密钥进行加密和解密,常用算法有DES、AES和IDEA等;

密钥长度由算法设计决定,AES的密钥长度是128/192/256位;

使用对称加密算法需要指定算法名称、工作模式和填充模式。

# 对称加密课外阅读

维基百科 数据加密标准 (opens new window)

维基百科 高级加密标准 (opens new window)

维基百科 分组密码工作模式 (opens new window)

博客 5 分钟了解 AES 加密算法的细节参数 (opens new window)

博客 为什么不推荐在对称加密中使用CBC工作模式 (opens new window)

# 口令加密

对称加密算法决定了口令必须是固定长度,然后对明文进行分块加密。又因为安全需求,口令长度往往都是128位以上,即至少16个字符。

但是我们平时使用的加密软件,输入6位、8位都可以,难道加密方式不一样?

实际上用户输入的口令并不能直接作为AES的密钥进行加密(除非长度恰好是128/192/256位),并且用户输入的口令一般都有规律,安全性远远不如安全随机数产生的随机口令。因此,用户输入的口令,通常还需要使用PBE算法,采用随机数杂凑计算出真正的密钥,再进行加密。

PBE 就是 Password Based Encryption 的缩写,它的作用如下:

key = generate(userPassword, secureRandomPassword);

PBE的作用就是把用户输入的口令和一个安全随机的口令采用杂凑后计算出真正的密钥。以AES密钥为例,我们让用户输入一个口令,然后生成一个随机数,通过PBE算法计算出真正的AES口令,再进行加密,代码如下:

public class Main {
    public static void main(String[] args) throws Exception {
        // 把BouncyCastle作为Provider添加到java.security:
        Security.addProvider(new BouncyCastleProvider());
        // 原文:
        String message = "Hello, world!";
        // 加密口令:
        String password = "hello12345";
        // 16 bytes随机Salt:
        byte[] salt = SecureRandom.getInstanceStrong().generateSeed(16);
        System.out.printf("salt: %032x\n", new BigInteger(1, salt));
        // 加密:
        byte[] data = message.getBytes("UTF-8");
        byte[] encrypted = encrypt(password, salt, data);
        System.out.println("encrypted: " + Base64.getEncoder().encodeToString(encrypted));
        // 解密:
        byte[] decrypted = decrypt(password, salt, encrypted);
        System.out.println("decrypted: " + new String(decrypted, "UTF-8"));
    }

    // 加密:
    public static byte[] encrypt(String password, byte[] salt, byte[] input) throws GeneralSecurityException {
        PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());
        SecretKeyFactory skeyFactory = SecretKeyFactory.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
        SecretKey skey = skeyFactory.generateSecret(keySpec);
        PBEParameterSpec pbeps = new PBEParameterSpec(salt, 1000);
        Cipher cipher = Cipher.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
        cipher.init(Cipher.ENCRYPT_MODE, skey, pbeps);
        return cipher.doFinal(input);
    }

    // 解密:
    public static byte[] decrypt(String password, byte[] salt, byte[] input) throws GeneralSecurityException {
        PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());
        SecretKeyFactory skeyFactory = SecretKeyFactory.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
        SecretKey skey = skeyFactory.generateSecret(keySpec);
        PBEParameterSpec pbeps = new PBEParameterSpec(salt, 1000);
        Cipher cipher = Cipher.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
        cipher.init(Cipher.DECRYPT_MODE, skey, pbeps);
        return cipher.doFinal(input);
    }
}

# 口令加密小结

PBE算法通过用户口令和安全的随机salt计算出Key,然后再进行加密;

Key通过口令和安全的随机salt计算得出,大大提高了安全性;

PBE算法内部使用的仍然是标准对称加密算法(例如AES)。

# 密钥交换

对称加密算法解决了数据加密的问题。我们以AES加密为例,在现实世界中,小明要向路人甲发送一个加密文件,他可以先生成一个AES密钥,对文件进行加密,然后把加密文件发送给对方。因为对方要解密,就必须需要小明生成的密钥。

现在问题来了:如何传递密钥?

在不安全的信道上传递加密文件是没有问题的,因为黑客拿到加密文件没有用。但是,如何如何在不安全的信道上安全地传输密钥?

要解决这个问题,密钥交换算法即DH算法:Diffie-Hellman算法应运而生。

DH算法解决了密钥在双方不直接传递密钥的情况下完成密钥交换,这个神奇的交换原理完全由数学理论支持。

但是DH算法并未解决中间人攻击,即甲乙双方并不能确保与自己通信的是否真的是对方。消除中间人攻击需要其他方法。

# 密钥交换小结

DH 算法是一种密钥交换协议,通信双方通过不安全的信道协商密钥,然后进行对称加密传输。

DH 算法没有解决中间人攻击。

# 非对称加密

从DH算法我们可以看到,公钥-私钥组成的密钥对是非常有用的加密方式,因为公钥是可以公开的,而私钥是完全保密的,由此奠定了非对称加密的基础。

非对称加密就是加密和解密使用的不是相同的密钥:只有同一个公钥-私钥对才能正常加解密。

非对称加密的典型算法就是RSA算法,它是由Ron Rivest,Adi Shamir,Leonard Adleman这三个哥们一起发明的,所以用他们仨的姓的首字母缩写表示。

非对称加密相比对称加密的显著优点在于,对称加密需要协商密钥,而非对称加密可以安全地公开各自的公钥,在N个人之间通信的时候:使用非对称加密只需要N个密钥对,每个人只管理自己的密钥对。而使用对称加密需要则需要N*(N-1)/2个密钥,因此每个人需要管理N-1个密钥,密钥管理难度大,而且非常容易泄漏。

既然非对称加密这么好,那我们抛弃对称加密,完全使用非对称加密行不行?也不行。因为非对称加密的缺点就是运算速度非常慢,比对称加密要慢很多。

# 非对称加密小结

非对称加密就是加密和解密使用的不是相同的密钥,只有同一个公钥-私钥对才能正常加解密;

只使用非对称加密算法不能防止中间人攻击。

# 数字签名

我们使用非对称加密算法的时候,对于一个公钥-私钥对,通常是用公钥加密,私钥解密。

如果使用私钥加密,公钥解密是否可行呢?实际上是完全可行的。

不过我们再仔细想一想,私钥是保密的,而公钥是公开的,用私钥加密,那相当于所有人都可以用公钥解密。这个加密有什么意义?

这个加密的意义在于,如果小明用自己的私钥加密了一条消息,比如小明喜欢小红,然后他公开了加密消息,由于任何人都可以用小明的公钥解密,从而使得任何人都可以确认小明喜欢小红这条消息肯定是小明发出的,其他人不能伪造这个消息,小明也不能抵赖这条消息不是自己写的。

因此,私钥加密得到的密文实际上就是数字签名,要验证这个签名是否正确,只能用私钥持有者的公钥进行解密验证。使用数字签名的目的是为了确认某个信息确实是由某个发送方发送的,任何人都不可能伪造消息,并且,发送方也不能抵赖。

在实际应用的时候,签名实际上并不是针对原始消息,而是针对原始消息的哈希进行签名,即:

signature = encrypt(privateKey, sha256(message)) 对签名进行验证实际上就是用公钥解密:

hash = decrypt(publicKey, signature) 然后把解密后的哈希与原始消息的哈希进行对比。

因为用户总是使用自己的私钥进行签名,所以,私钥就相当于用户身份。而公钥用来给外部验证用户身份。

常用数字签名算法有:

  • MD5withRSA
  • SHA1withRSA
  • SHA256withRSA

# DSA 签名

除了RSA可以签名外,还可以使用DSA算法进行签名。DSA是Digital Signature Algorithm的缩写,它使用ElGamal数字签名算法。

DSA只能配合SHA使用,常用的算法有:

  • SHA1withDSA
  • SHA256withDSA
  • SHA512withDSA

和RSA数字签名相比,DSA的优点是更快。

# ECDSA 签名

椭圆曲线签名算法ECDSA:Elliptic Curve Digital Signature Algorithm也是一种常用的签名算法,它的特点是可以从私钥推出公钥。比特币的签名算法就采用了ECDSA算法,使用标准椭圆曲线secp256k1。BouncyCastle提供了ECDSA的完整实现。

# 数字签名小结

数字签名就是用发送方的私钥对原始数据进行签名,只有用发送方公钥才能通过签名验证。

数字签名用于:

  • 防止抵赖;
  • 防止伪造;
  • 检测篡改。

常用的数字签名算法包括:

  • MD5withRSA
  • SHA1withRSA
  • SHA256withRSA
  • SHA1withDSA
  • SHA256withDSA
  • SHA512withDSA
  • ECDSA

# 数字证书

我们知道,摘要算法用来确保数据没有被篡改,非对称加密算法可以对数据进行加解密,签名算法可以确保数据完整性和抗否认性,把这些算法集合到一起,并搞一套完善的标准,这就是数字证书。

因此,数字证书就是集合了多种密码学算法,用于实现数据加解密、身份认证、签名等多种功能的一种安全标准。

数字证书可以防止中间人攻击,因为它采用链式签名认证,即通过根证书(Root CA)去签名下一级证书,这样层层签名,直到最终的用户证书。而Root CA证书内置于操作系统中,所以,任何经过CA认证的数字证书都可以对其本身进行校验,确保证书本身不是伪造的。

# 数字证书小结

数字证书就是集合了多种密码学算法,用于实现数据加解密、身份认证、签名等多种功能的一种安全标准。

数字证书采用链式签名管理,顶级的Root CA证书已内置在操作系统中。

数字证书存储的是公钥,可以安全公开,而私钥必须严格保密。

# 常见攻击与技术鉴赏

以下是一些常见的网络攻击手段:

  1. 拒绝服务攻击(DDoS):攻击者通过向目标服务器发送大量的流量或请求,使服务器无法正常处理合法的请求,从而导致服务不可用。
  2. 网络钓鱼(Phishing):攻击者通过伪造合法的电子邮件、网站或其他通讯方式,骗取用户的个人信息、账号密码等敏感信息。
  3. 黑客攻击:攻击者通过利用漏洞或弱点,获取目标系统的控制权或敏感数据。
  4. 恶意软件:攻击者通过向目标系统中注入恶意软件,如病毒、木马、间谍软件等,来获取目标系统的控制权或敏感数据。
  5. 中间人攻击:攻击者通过欺骗双方,将通讯的两端分别连接到攻击者控制的服务器上,从而截获通讯数据和敏感信息。
  6. 社会工程学攻击:攻击者利用人性弱点,如好奇心、信任等,通过伪装、欺骗等手段,获取目标系统的控制权或敏感数据。

【计算机博物志】战争密码(上集)如何复刻一台恩格玛机 (opens new window)

【计算机博物志】战争密码(中集)蝴蝶的翅膀 (opens new window)

【计算机博物志】战争密码(下集)炸弹机 (opens new window)

MD5 为何不再安全 (opens new window)

DES 的生与死 (opens new window)