Skip to content

Latest commit

 

History

History
810 lines (547 loc) · 31.6 KB

security.md

File metadata and controls

810 lines (547 loc) · 31.6 KB

本文来自廖雪峰,郎涯进行简单排版与补充

编码算法

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

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

对于绝大多数开发者来说,设计一个安全的加密算法非常困难,验证一个加密算法是否安全更加困难,当前被认为安全的加密算法仅仅是迄今为止尚未被攻破。因此,要编写安全的计算机程序,我们要做到:

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

最简单的编码是直接给每个字符指定一个若干字节表示的整数,复杂一点的编码就需要根据一个已有的编码推算出来。比如UTF-8编码,它是一种不定长编码,但可以从给定字符的Unicode编码推算出来。

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

汉字 Unicode编码 UTF-8编码
0x4e2d 0xe4b8ad
0x6587 0xe69687
0x7f16 0xe7bc96
0x7801 0xe7a081

URL编码

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

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

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

Java标准库提供了一个URLEncoderURLDecoder类来对任意字符串进行URL编码、解码:

public class Main {
    public static void main(String[] args) {
        String encoded = URLEncoder.encode("中文!", StandardCharsets.UTF_8);
        System.out.println(encoded);
        
        String decoded = URLDecoder.decode(encoded, StandardCharsets.UTF_8);
        System.out.println(decoded);
    }
}

Base64

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

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

举个例子:3个byte数据分别是e4b8ad,按6bit分组得到390b222d

┌───────────────┬───────────────┬───────────────┐
│      e4       │      b8       │      ad       │
└───────────────┴───────────────┴───────────────┘
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│1│1│1│0│0│1│0│0│1│0│1│1│1│0│0│0│1│0│1│0│1│1│0│1│
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
┌───────────┬───────────┬───────────┬───────────┐
│    39     │    0b     │    22     │    2d     │
└───────────┴───────────┴───────────┴───────────┘

因为6位整数的范围总是063,所以,能用64个字符表示:字符AZ对应索引025,字符az对应索引2651,字符09对应索引52~61,最后两个索引6263分别用字符+/表示。

在Java中,二进制数据就是byte[]数组。Java标准库提供了Base64来对byte[]数组进行编解码:

public class Main {
    public static void main(String[] args) {
        byte[] input = new byte[] { (byte) 0xe4, (byte) 0xb8, (byte) 0xad };
        String b64encoded = Base64.getEncoder().encodeToString(input);
        System.out.println(b64encoded);
        
        byte[] output = Base64.getDecoder().decode(b64encoded);
        System.out.println(Arrays.toString(output)); // [-28, -72, -83]
        
        //一种针对URL的Base64编码可以在URL中使用的Base64编码,它仅仅是把+变成-,/变成_
        byte[] output = Base64.getUrlDecoder().decode(b64encoded);
        System.out.println(Arrays.toString(output));
    }
}

哈希算法

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

对任意一组输入数据进行计算,得到一个固定长度的输出摘要。哈希算法最重要的特点就是:

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

哈希碰撞

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

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

哈希算法是把一个无限的输入集合映射到一个有限的输出集合,必然会产生碰撞。

一个安全的哈希算法必须满足:

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

常用的哈希算法有:

算法 输出长度(位) 输出长度(字节)
MD5 128 bits 16 bytes
SHA-1 160 bits 20 bytes
RipeMD-160 160 bits 20 bytes
SHA-256 256 bits 32 bytes
SHA-512 512 bits 64 bytes
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的对照表)

    抵御彩虹表攻击,方法是对每个口令额外添加随机数,这个方法称之为加盐(salt)

    digest = md5(salt+inputPassword)

SHA-1

SHA-1是由美国国家安全局开发的,SHA算法实际上是一个系列,包括SHA-0(已废弃)、SHA-1、SHA-256、SHA-512等。

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

MD5因为输出长度较短,短时间内破解是可能的,目前已经不推荐使用

BouncyCastle

Java标准库的java.security包提供了一种标准机制,允许第三方提供商无缝接入。

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

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

使用该库,需要把BouncyCastle提供的jar包放到classpath中。这个jar包就是bcprov-jdk15on-xxx.jar,可以从官方网站下载。

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(new BigInteger(1, result).toString(16));
    }
}

Hmac

Hmac 算法是一种标准的基于密钥的哈希算法,它的全称是Hash-based Message Authentication Code,是一种更安全的消息摘要算法。可以配合MD5、SHA-1等哈希算法。

使用HmacMD5而不是用MD5加salt,有如下好处:

  • HmacMD5使用的key长度是64字节,更安全;
  • Hmac是标准算法,同样适用于SHA-1等其他哈希算法;
  • Hmac输出和原有的哈希算法长度一致。
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(new BigInteger(1, skey).toString(16));
        
        //SecretKey key = new SecretKeySpec(skey, "HmacMD5");
        
        Mac mac = Mac.getInstance("HmacMD5");
        mac.init(key);
        mac.update("HelloWorld".getBytes("UTF-8"));
        byte[] result = mac.doFinal();
        System.out.println(new BigInteger(1, result).toString(16));
    }
}

恢复SecretKey的语句就是new SecretKeySpec(hkey, "HmacMD5")

对称加密

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

  • 常用算法有DES、AES和IDEA等;

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

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

算法 密钥长度 工作模式 填充模式
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/...

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

Java标准库提供的算法实现并不包括所有的工作模式和所有填充模式。

使用时按以下步骤编写代码:

  1. 根据算法名称/工作模式/填充模式获取Cipher实例;
  2. 根据算法名称初始化一个SecretKey实例,密钥必须是指定长度;
  3. 使用SerectKey初始化Cipher实例,并设置加密或解密模式;
  4. 传入明文或密文,获得密文或明文。
public class Main {
    public static void main(String[] args) throws Exception {
        // 原文:
        String message = "Hello, world!";
        System.out.println("Message: " + message);
        
        // 128位密钥 = 16 bytes Key:
        byte[] key = "1234567890abcdef".getBytes("UTF-8");
         
        // 加密:
        byte[] data = message.getBytes("UTF-8");
        byte[] encrypted = encrypt(key, data);
        System.out.println("Encrypted: " + Base64.getEncoder().encodeToString(encrypted));
        
        // 解密:
        byte[] decrypted = decrypt(key, encrypted);
        System.out.println("Decrypted: " + new String(decrypted, "UTF-8"));
    }

    // 加密:
    public static byte[] encrypt(byte[] key, byte[] input) throws GeneralSecurityException {
        SecretKey keySpec = new SecretKeySpec(key, "AES");
        
        // CBC模式需要生成一个16 bytes的initialization vector:
        //SecureRandom sr = SecureRandom.getInstanceStrong();
        //byte[] iv = sr.generateSeed(16);
        //IvParameterSpec ivps = new IvParameterSpec(iv);
        
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(Cipher.ENCRYPT_MODE, keySpec);
        return cipher.doFinal(input);
    }

    // 解密:
    public static byte[] decrypt(byte[] key, byte[] input) throws GeneralSecurityException {        
        SecretKey keySpec = new SecretKeySpec(key, "AES");
        
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(Cipher.DECRYPT_MODE, keySpec);
        return cipher.doFinal(input);
    }
}

口令加密

PBE假定用户的口令很简单不安全,所以需要一个高强度密码和用户口令混在一起加密。

PBE算法通过用户口令和安全的随机salt计算出Key,然后再进行加密;PBE算法内部使用的仍然是标准对称加密算法(例如AES)。

以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);
    }
}

密钥交换

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

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

要解决这个问题,密钥交换算法即DH算法:Diffie-Hellman算法应运而生。DH算法解决了密钥在双方不直接传递密钥的情况下完成密钥交换。

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

我们来看DH算法交换密钥的步骤。假设甲乙双方需要传递密钥,他们之间可以这么做:

  1. 甲首选选择一个素数p,例如509,底数g,任选,例如5,随机数a,例如123,然后计算A=g^a mod p,结果是215,然后,甲发送p=509g=5A=215给乙;
  2. 乙方收到后,也选择一个随机数b,例如,456,然后计算B=g^b mod p,结果是181,乙再同时计算s=A^b mod p,结果是121;
  3. 乙把计算的B=181发给甲,甲计算s=B^a mod p的余数,计算结果与乙算出的结果一样,都是121。

所以最终双方协商出的密钥s是121。注意到这个密钥s并没有在网络上传输。而通过网络传输的pgAB是无法推算出s的,因为实际算法选择的素数是非常大的。

public class Main {
    public static void main(String[] args) {
        // Bob和Alice:
        Person bob = new Person("Bob");
        Person alice = new Person("Alice");

        // 各自生成KeyPair:
        bob.generateKeyPair();
        alice.generateKeyPair();

        // 双方交换各自的PublicKey:
        // Bob根据Alice的PublicKey生成自己的本地密钥:
        bob.generateSecretKey(alice.publicKey.getEncoded());
        // Alice根据Bob的PublicKey生成自己的本地密钥:
        alice.generateSecretKey(bob.publicKey.getEncoded());

        // 检查双方的本地密钥是否相同:
        bob.printKeys();
        alice.printKeys();
        // 双方的SecretKey相同,后续通信将使用SecretKey作为密钥进行AES加解密...
    }
}

class Person {
    public final String name;

    public PublicKey publicKey;
    private PrivateKey privateKey;
    private byte[] secretKey;

    public Person(String name) {
        this.name = name;
    }

    // 生成本地KeyPair:
    public void generateKeyPair() {
        try {
            KeyPairGenerator kpGen = KeyPairGenerator.getInstance("DH");
            
            kpGen.initialize(512);
            KeyPair kp = kpGen.generateKeyPair();
            this.privateKey = kp.getPrivate();
            this.publicKey = kp.getPublic();
            
        } catch (GeneralSecurityException e) {
            throw new RuntimeException(e);
        }
    }

    public void generateSecretKey(byte[] receivedPubKeyBytes) {
        try {
            // 从byte[]恢复PublicKey:
            X509EncodedKeySpec keySpec = new X509EncodedKeySpec(receivedPubKeyBytes);
            
            KeyFactory kf = KeyFactory.getInstance("DH");
            PublicKey receivedPublicKey = kf.generatePublic(keySpec);
            
            // 生成本地密钥:
            KeyAgreement keyAgreement = KeyAgreement.getInstance("DH");
            keyAgreement.init(this.privateKey); // 自己的PrivateKey
            keyAgreement.doPhase(receivedPublicKey, true); // 对方的PublicKey
            
            // 生成SecretKey密钥:
            this.secretKey = keyAgreement.generateSecret();
        } catch (GeneralSecurityException e) {
            throw new RuntimeException(e);
        }
    }

    public void printKeys() {
        System.out.printf("Name: %s\n", this.name);
        System.out.printf("Private key: %x\n", new BigInteger(1, this.privateKey.getEncoded()));
        System.out.printf("Public key: %x\n", new BigInteger(1, this.publicKey.getEncoded()));
        System.out.printf("Secret key: %x\n", new BigInteger(1, this.secretKey));
    }
}

非对称加密

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

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

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

  • 缺点是运算速度非常慢,比对称加密要慢很多

因此,如果小明要加密一个文件发送给小红,他应该首先向小红索取她的公钥,然后,他用小红的公钥加密,把加密文件发送给小红,此文件只能由小红的私钥解开,因为小红的私钥在她自己手里,所以,除了小红,没有任何人能解开此文件。

非对称加密的典型算法就是RSA算法。在实际应用的时候,非对称加密总是和对称加密一起使用。假设小明需要给小红需要传输加密文件,他俩首先交换了各自的公钥,然后:

  1. 小明生成一个随机的AES口令,然后用小红的公钥通过RSA加密这个口令,并发给小红;
  2. 小红用自己的RSA私钥解密得到AES口令;
  3. 双方使用这个共享的AES口令用AES加密通信。

这也是浏览器中常用的HTTPS协议的做法,即浏览器和服务器先通过RSA交换AES口令,接下来双方通信实际上采用的是速度较快的AES对称加密,而不是缓慢的RSA非对称加密。

public class Main {
    public static void main(String[] args) throws Exception {
        // 明文:
        byte[] plain = "Hello, encrypt use RSA".getBytes("UTF-8");
       
        // 创建公钥/私钥对:
        Person alice = new Person("Alice");
        
        // 用Alice的公钥加密:
        byte[] pk = alice.getPublicKey();
        System.out.println(String.format("public key: %x", new BigInteger(1, pk)));
        byte[] encrypted = alice.encrypt(plain);
        System.out.println(String.format("encrypted: %x", new BigInteger(1, encrypted)));
       
        // 用Alice的私钥解密:
        byte[] sk = alice.getPrivateKey();
        System.out.println(String.format("private key: %x", new BigInteger(1, sk)));
        byte[] decrypted = alice.decrypt(encrypted);
        System.out.println(new String(decrypted, "UTF-8"));
    }
}

class Person {
    String name;
    // 私钥:
    PrivateKey sk;
    // 公钥:
    PublicKey pk;

    public Person(String name) throws GeneralSecurityException {
        this.name = name;
        
        // 生成公钥/私钥对:
        KeyPairGenerator kpGen = KeyPairGenerator.getInstance("RSA");
        kpGen.initialize(1024);
        KeyPair kp = kpGen.generateKeyPair();
        
        this.sk = kp.getPrivate();
        this.pk = kp.getPublic();
    }

    // 把私钥导出为字节
    public byte[] getPrivateKey() {
        return this.sk.getEncoded();
    }

    // 把公钥导出为字节
    public byte[] getPublicKey() {
        return this.pk.getEncoded();
    }

    // 用公钥加密:
    public byte[] encrypt(byte[] message) throws GeneralSecurityException {
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.ENCRYPT_MODE, this.pk);
        return cipher.doFinal(message);
    }

    // 用私钥解密:
    public byte[] decrypt(byte[] input) throws GeneralSecurityException {
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.DECRYPT_MODE, this.sk);
        return cipher.doFinal(input);
    }
}

RSA的公钥和私钥都可以通过getEncoded()方法获得以byte[]表示的二进制数据,并根据需要保存到文件中。要从byte[]数组恢复公钥或私钥,可以这么写:

byte[] pkData = ...
byte[] skData = ...
KeyFactory kf = KeyFactory.getInstance("RSA");

// 恢复公钥:
X509EncodedKeySpec pkSpec = new X509EncodedKeySpec(pkData);
PublicKey pk = kf.generatePublic(pkSpec);

// 恢复私钥:
PKCS8EncodedKeySpec skSpec = new PKCS8EncodedKeySpec(skData);
PrivateKey sk = kf.generatePrivate(skSpec);

以RSA算法为例,它的密钥有256/512/1024/2048/4096等不同的长度。长度越长,密码强度越大,当然计算速度也越慢。

如果修改待加密的byte[]数据的大小,可以发现,使用512bit的RSA加密时,明文长度不能超过53字节。使用1024bit的RSA加密时,明文长度不能超过117字节,这也是为什么使用RSA的时候,总是配合AES一起使用,即用AES加密任意长度的明文,用RSA加密AES口令。

签名算法

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

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

常用的数字签名算法包括:MD5withRSA/SHA1withRSA/SHA256withRSA/SHA1withDSA/SHA256withDSA/SHA512withDSA/ECDSA

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

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

 signature = encrypt(privateKey, sha256(message))
 public class Main {
     public static void main(String[] args) throws GeneralSecurityException {
         // 生成RSA公钥/私钥:
         KeyPairGenerator kpGen = KeyPairGenerator.getInstance("RSA");
         kpGen.initialize(1024);
         KeyPair kp = kpGen.generateKeyPair();
         PrivateKey sk = kp.getPrivate();
         PublicKey pk = kp.getPublic();
 
         // 待签名的消息:
         byte[] message = "Hello, I am Bob!".getBytes(StandardCharsets.UTF_8);
 
         // 用私钥签名:
         Signature s = Signature.getInstance("SHA1withRSA");
         s.initSign(sk);
         s.update(message);
         byte[] signed = s.sign();
         System.out.println(String.format("signature: %x", new BigInteger(1, signed)));
 
         // 用公钥验证:
         Signature v = Signature.getInstance("SHA1withRSA");
         v.initVerify(pk);
         v.update(message);
         boolean valid = v.verify(signed);
         System.out.println("valid? " + valid);
     }
 }

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的完整实现。

数字证书

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

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

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

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

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

在Java程序中,数字证书存储在一种Java专用的key store文件中,JDK提供了一系列命令来创建和管理key store。我们用下面的命令创建一个key store,并设定口令123456:

keytool -storepass 123456 -genkeypair -keyalg RSA -keysize 1024 -sigalg SHA1withRSA -validity 3650 -alias mycert -keystore my.keystore -dname "CN=www.sample.com, OU=sample, O=sample, L=BJ, ST=BJ, C=CN"

几个主要的参数是:

  • keyalg:指定RSA加密算法;
  • sigalg:指定SHA1withRSA签名算法;
  • validity:指定证书有效期3650天;
  • alias:指定证书在程序中引用的名称;
  • dname:最重要的CN=www.sample.com指定了Common Name,如果证书用在HTTPS中,这个名称必须与域名完全一致。
public class Main {
    public static void main(String[] args) throws Exception {
        byte[] message = "Hello, use X.509 cert!".getBytes("UTF-8");
        
        // 读取KeyStore:
        KeyStore ks = loadKeyStore("/my.keystore", "123456");
        
        // 读取私钥:
        PrivateKey privateKey = (PrivateKey) ks.getKey("mycert", "123456".toCharArray());
        
        // 读取证书:
        X509Certificate certificate = (X509Certificate) ks.getCertificate("mycert");
        
        // 加密:
        byte[] encrypted = encrypt(certificate, message);
        System.out.println(String.format("encrypted: %x", new BigInteger(1, encrypted)));
        
        // 解密:
        byte[] decrypted = decrypt(privateKey, encrypted);
        System.out.println("decrypted: " + new String(decrypted, "UTF-8"));
        
        // 签名:
        byte[] sign = sign(privateKey, certificate, message);
        System.out.println(String.format("signature: %x", new BigInteger(1, sign)));
        
        // 验证签名:
        boolean verified = verify(certificate, message, sign);
        System.out.println("verify: " + verified);
    }

    static KeyStore loadKeyStore(String keyStoreFile, String password) {
        try (InputStream input = Main.class.getResourceAsStream(keyStoreFile)) {
            if (input == null) {
                throw new RuntimeException("file not found in classpath: " + keyStoreFile);
            }
            
            KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
            ks.load(input, password.toCharArray());
            return ks;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    static byte[] encrypt(X509Certificate certificate, byte[] message) throws GeneralSecurityException {
        Cipher cipher = Cipher.getInstance(certificate.getPublicKey().getAlgorithm());
        cipher.init(Cipher.ENCRYPT_MODE, certificate.getPublicKey());
        return cipher.doFinal(message);
    }

    static byte[] decrypt(PrivateKey privateKey, byte[] data) throws GeneralSecurityException {
        Cipher cipher = Cipher.getInstance(privateKey.getAlgorithm());
        cipher.init(Cipher.DECRYPT_MODE, privateKey);
        return cipher.doFinal(data);
    }

    static byte[] sign(PrivateKey privateKey, X509Certificate certificate, byte[] message)
            throws GeneralSecurityException {
        Signature signature = Signature.getInstance(certificate.getSigAlgName());
        signature.initSign(privateKey);
        signature.update(message);
        return signature.sign();
    }

    static boolean verify(X509Certificate certificate, byte[] message, byte[] sig) throws GeneralSecurityException {
        Signature signature = Signature.getInstance(certificate.getSigAlgName());
        signature.initVerify(certificate);
        signature.update(message);
        return signature.verify(sig);
    }
}

我们从key store直接读取了私钥-公钥对,私钥以PrivateKey实例表示,公钥以X509Certificate表示,实际上数字证书只包含公钥,因此,读取证书并不需要口令,只有读取私钥才需要。如果部署到Web服务器上,例如Nginx,需要把私钥导出为Private Key格式,把证书导出为X509Certificate格式。

以HTTPS协议为例,浏览器和服务器建立安全连接的步骤如下:

  1. 浏览器向服务器发起请求,服务器向浏览器发送自己的数字证书;
  2. 浏览器用操作系统内置的Root CA来验证服务器的证书是否有效,如果有效,就使用该证书加密一个随机的AES口令并发送给服务器;
  3. 服务器用自己的私钥解密获得AES口令,并在后续通讯中使用AES加密。

上述流程只是一种最常见的单向验证。如果服务器还要验证客户端,那么客户端也需要把自己的证书发送给服务器验证,这种场景常见于网银等。