Wednesday, January 22, 2014

JCA Engine - Cipher

Cipher的分类

Java中的Cipher类提供了对数据加密解密的功能。根据加密算法的不同,大致可以分为三类:

  • 对称加密(Symmetric):加密和解密使用相同的密钥(SecretKey)。参与加密解密的双方,需要预先商定好密钥。如果保证了密钥的安全,理论上则可以保证加密数据的安全。
  • 非对称加密(Asymmetric):加密和解密使用不同的密钥。比如,加密的时候使用公钥(Public Key),解密的时候需要使用对应的私钥(Private Key),反之亦然。一般将公钥公开,私钥自己保留。比如,B需要给A发送一段加密信息。首先B会向A请求一个A的公钥。B利用请求到的公钥对消息进行加密,并且发送给A。即使消息在传输过程中被截获,因为没有A的私钥,所以也无法解密数据,得到的无非是一些没有意义的字节而已。因此,保证私钥的安全,理论上就可以保证数据的安全。
  • 混合加密(Hybrid):对称加密和非对称加密各有优点:
    • 对称加密的速度比较快,但是,在分发密钥的时候存在安全隐患(比如密钥在传输过中被人截获);
    • 非对称加密不需要事先商定密钥,只需要把公钥公开,因为只有持有私钥的人才能够解密使用公钥加密的数据,而私钥一般是不需要公开的,但是,缺点是非对称加密的效率较对称加密慢了许多。
    所以将二者混合使用,各取优点,便成了混合加密——使用非对称加密来传输对称加密中使用的密钥,使用对称加密来加密真正的数据。因为一组通信过程中,密钥商定只需要进行一次,所以使用非对称加密来商定,保证商定密钥过程的安全性。然而,通信可能需要进行很多次,所以使用对称加密,提高了通信的效率。因为对称加密的密钥是安全的,所以通信的数据也可以认为是安全的。

根据一次加密操作的数据量不同,也可以把加密分成块加密(Block Cipher)和流加密(Stream Cipher)。所谓Block Cipher,就是把数据被拆分成一些固定大小的数据块(比如64 Bits),一次加密一个数据块。与之相对的是流式加密(Stream Cipher),也就是说每次加密的数据长度更小,比如一个Bit或者一个Byte。目前比较流行的是Block Cipher,Java中的Cipher类就是针对于Block Cipher实现的。而且,通过给Block Cipher指定不同的Mode,也可以达到模拟Stream Cipher的作用。

Cipher类使用方法

Cipher是一个Engine类,想要获得它的实例,需要从JCA中请求——通过调用Cipher类的静态方法getInstace来完成请求。在请求Engine实例的时候,Cipher和其它的Engine类是不一样的:请求其它的Engine类时,一般是传入一个算法名称,进而得到一个实现了这种算法的Engine类的实例。但是,在请求Cipher实例的时候,需要传入的是一个“转换模式”——Transformation。一个Transformation包含3个信息:

  • Algorithm:表示Cipher使用的算法,如:DES、AES等。
  • Mode:用于在加密之前或者之后对Block做一些处理,提高安全性。如:ECB、CBC、CFB等。
  • Padding:对于Block Cipher而言,每种算法对于Block Size是有要求的。比如,DES算法的Block Size是8 Bytes;而AES算法的Block Size是16 Bytes。然而,如果需要加密的数据的长度不是Block Size的整数倍,那么,在加密之前先要进行Padding,将数据补全到Block Size的整数倍,以便于接下来进行分块加密。这个属性就使用于指定Padding的规则。

Transformation包含的三个信息需要使用“/”进行分隔。比如我想要使用AES算法,ECB Mode以及PKCS5Padding,那么,我们需要传入的Transformation为:AES/ECB/PKCS5Padding。

Cipher加密步骤

  1. 调用Cipher.getInstance通过传入Transformation向JCA请求对应的Cipher实例。
  2. 通过调用init方法,设置Cipher的状态和Key,对Cipher进行初始化。Cipher本身是有状态的,一个Cipher要么用于加密,要么用于解密。在调用init方法的时候,需要通过传入Cipher的状态来指定当前的Cipher将用于加密还是解密。Cipher的状态在Cipher类中有静态常量定义(ENCRYPT_MODE, DECRYPT_MODE)。另外,每种加密算法都有自己的Key,此处需要用加密算法对应的Key来初始化Cipher。
  3. 调用update方法,将待加密的数据传入Cipher中(可选)。
  4. 调用doFinal方法获得最终的加密结果。

注意:在调用update方法的时候,其返回值是传入的数据的加密结果。如果累计传入到update方法的数据量不足一个Block,那么update方法不会返回任何结果(一个空的byte[])。一旦累计的数据量超过了1倍或者1倍以上的Block Size,那么,这个方法将返回之前所有可以构成Block的数据的加密结果,并且清空已经返回了的Block。之后无论是调用doFinal还是update方法,都不会再返回这些已经返回过的加密结果了。所以说,如果使用update方式逐次的更新数据,那么需要将每次调用update方法的返回值保存起来,最终才能拼接成一个完整的加密结果。

无论目前累计传入Cipher的数据是否可以构成一个Block,如果调用了doFinal方法,那么cipher会将传入的数据进行padding,使之构成Block Size的整数倍,然后返回加密结果。

下面是一个AES加密的例子

public class AESDemo {
    public static byte[] encrypt(byte[] data, Key key) {
        try {
            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); //此处可以省略Mode和Padding,Java的默认实现将会使用默认的Mode和Padding(ECB和PKCS5Padding),也就是说"AES"将是一样的效果
            cipher.init(Cipher.ENCRYPT_MODE, key);
            byte[] cipherData = cipher.doFinal(data);
            return cipherData;
        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException ex) {
            throw new RuntimeException(ex);
        }
    }

    public static String encrypt(String text, Key key) {
        try {
            byte[] data = text.getBytes("UTF-8");
            byte[] cipherData = encrypt(data, key);
            String cipherText = DatatypeConverter.printHexBinary(cipherData); //这里使用Base64更好一些,为了之后分析方便,我在这里使用了Hex
            return cipherText;
        } catch (UnsupportedEncodingException ex) {
            throw new RuntimeException(ex);
        }
    }

    public static void main(String[] args) {
        byte[] encodedKey = { 9, 93, -54, 55, -33, -43, 42, 102, -89, 116, 125, -19, 120, 59, 92, -110 };
        SecretKey key = new SecretKeySpec(encodedKey, "AES");
        String text = "Hello Cipher";
        String cipherText = encrypt(text, key);
        System.out.println(cipherText);
    }
}
        

运行这个Demo程序,将得到如下输出:

B7324301EDA3890DFA73042934352254

下面来分析以下这个输出结果——长度为32的16进制串,也就是说加密的结果是16 Bytes。

需要加密的消息是"Hello Cipher"——长度为12的字符串。因为都是ASCII字符,所以每个字符占用一个字节,共计12 Bytes。

之所以加密前的数据长度和加密后的数据长度不一样,是因为AES的Block Size是16 Bytes。因为"Hello Cipher"只有12 Bytes,在进行加密的时候会把它Padding到16 Bytes,因此最终的加密结果就是16 Bytes。

Cipher解密步骤

解密的步骤和加密非常相似,唯一的区别是在init Cipher的时候需要将Cipher的状态设置为解密-DECRYPT_MODE

  1. 调用Cipher.getInstance通过Transformation请求对应的Cipher实例。
  2. 调用init方法,传入状态参数DECRYPT_MODE,以及解密时需要用到的Key。
  3. 调用update方法,将待解密的数据传入Cipher中(可选)。
  4. 调用doFinal方法获得最终解密的结果。

下面是AES解密的例子:

public class AESDemo {
    public static byte[] decrypt(byte[] cipherData, Key key) {
        try {
            Cipher cipher = Cipher.getInstance("AES");
            cipher.init(Cipher.DECRYPT_MODE, key);
            byte[] data = cipher.doFinal(cipherData);
            return data;
        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException ex) {
            throw new RuntimeException(ex);
        }
    }

    public static String decrypt(String cipherText, Key key) {
        try {
            byte[] cipherData = DatatypeConverter.parseHexBinary(cipherText);
            byte[] data = decrypt(cipherData, key);
            String text = new String(data, "UTF-8");
            return text;
        } catch (UnsupportedEncodingException ex) {
            throw new RuntimeException(ex);
        }
    }

    public static void main(String[] args) {
        byte[] encodedKey = { 9, 93, -54, 55, -33, -43, 42, 102, -89, 116, 125, -19, 120, 59, 92, -110 };
        SecretKey key = new SecretKeySpec(encodedKey, "AES");
        String cipherText = "B7324301EDA3890DFA73042934352254";
        String text = decrypt(cipherText, key);
        System.out.println(text);
    }
}
        

运行这个程序,得到如下输出:

Hello Cipher

可以看到,已经从密文中准确的解密出了明文。

关于Mode

对于Block Cipher支持很多种Mode,每种Mode都可以解决一些安全性问题,当然每种Mode都有各自的优点和缺点。这是一个比较庞大的话题,此处暂时不进行讨论。此处仅仅讲解以下最简单的Mode——ECB(Electronic Code Book)

与其说ECB是简单的Mode,不如说ECB根本没有什么功能。它对每个Block不进行任何处理,Block被加密成密文后,直接进行输出,Block和Block之间没有任何关联。对于ECB Mode而言,最大的优点就是block之间没有关联,比如在传输或者存储的过程中,某个字节损坏了(无法进行解密),那么影响的仅仅是其中的一个block,对于其它的block依然可以进行解密,因为block之间没有关联。同样ECB的优点也是它的缺点,导致了一些安全问题,使得采用ECB Mode进行加密的密文容易被攻击(篡改,猜测,解密等...)。

运行下面一段代码:

public class ECBDemo {
    public static void main(String[] args) {
        try {
            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
            System.out.println("Block Size: " + cipher.getBlockSize());
            SecretKey key = new SecretKeySpec(new byte[]{-56, -29, 33, 117, -17, 123, 82, 108, -71, 82, 102, -63, -13, 29, 105, 26, -90, -115, 70, -125, -69, -126, -43, -86, 49, -108, 109, 64, 112, 2, -122, 79}, "AES");
            cipher.init(Cipher.ENCRYPT_MODE, key);
            byte[] data1 = "How are you doing. I have a question...".getBytes("UTF-8");
            byte[] data2 = "How are you doing. I was thinking...".getBytes("UTF-8");
            byte[] cipherData1 = cipher.doFinal(data1);
            byte[] cipherData2 = cipher.doFinal(data2);
            System.out.println(DatatypeConverter.printHexBinary(cipherData1));
            System.out.println(DatatypeConverter.printHexBinary(cipherData2));
        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | UnsupportedEncodingException | IllegalBlockSizeException | BadPaddingException ex) {
            throw new RuntimeException(ex);
        }
    }
}
    

将会得到如下输出:

B237616BC0754F0EC16460F9A857D3D67EFB95DA272E49063324C858B2818B189053685C7A7ED49529D8CBCF1FD5552A
B237616BC0754F0EC16460F9A857D3D651FB47DC9EF1BEB43E26917883A5F8514383EF98D738EA87D34EA807F4D2958B
    

我们可以看到,因为两个字符串都是以"How are you doing. "开头,因此导致了第一个Block的加密结果相同。这样就产生了安全隐患,我们可以对这些相同的部分进行猜测,或者篡改等。

如何解决ECB存在的问题呢?需要用到其它的Mode,比如常用的CBC, PCBC, CFB等等。

关于Padding

目前Padding的操作是在Cipher内部帮助我们完成的,让我们来看看Padding的数据是什么样的。

如何来一探究竟呢?上面我们使用AES/ECB/PKCS5Padding对"Hello Cipher"进行了加密,加密结果是B7324301EDA3890DFA73042934352254。因为这个是加密的结果,而且,数据是先Padding然后再加密,所以从这个加密过的结果上是没有办法知道Padding是什么样子的。如果直接用上面的代码解密,Cipher将自动处理掉Padding的内容,导致我们依然没有办法看到Padding是什么样的。

那么想要获取Padding的数据,简单的思路是这样,在解密的时候使用这个Transformation - "AES/ECB/NoPadding"。什么意思呢?这里我们对Cipher撒了个谎“我的加密数据使用AES/ECB进行加密的,其中没有使用Padding(其实是有的,我们心里清楚),所以你不需要帮我把Padding的内容处理掉,直接返回给我解密后的结果即可”。代码如下:

public class PKCS5PaddingInspector {
    public static void main(String[] args) throws Exception {
        byte[] encodedKey = { 9, 93, -54, 55, -33, -43, 42, 102, -89, 116, 125, -19, 120, 59, 92, -110 };
        SecretKey key = new SecretKeySpec(encodedKey, "AES");
        String cipherText = "B7324301EDA3890DFA73042934352254";
        byte[] cipherData = DatatypeConverter.parseHexBinary(cipherText);

        Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
        cipher.init(Cipher.DECRYPT_MODE, key);
        byte[] data = cipher.doFinal(cipherData);
        System.out.println(Arrays.toString(data));
    }
}
    

运行上面的代码,得到如下结果:

[72, 101, 108, 108, 111, 32, 67, 105, 112, 104, 101, 114, 4, 4, 4, 4]
    

分析:

因为,"Hello Cipher"只有12 Bytes,而且,AES的Block Size是16 Bytes,因此需要Padding 4 Bytes。看到上面结果里最后四个字节了么?那就是Padding的数据。下面我们给出PKCS5Padding的规范:

  • 如果需要在末尾添加n个字节,那么添加的n个字节的值都是n
  • 如果不需要进行padding(正好是Block Size的倍数),那么在消息末尾添加值为Block Size的Block Size个字节。比如Block Size是16 Bytes,那么就需要添加16个值为16的Byte。

因此只要是使用了PKCS#5进行padding的消息,都会被添加上额外的字节。那么在解密之后,可以通过判断末尾字节的值,来确定末尾有多少个字节是Padding的,从而进行剔除。

总结

这里只讲解了Cipher的一些基础用法,对于更加深入的用法以后会慢慢说明,也可以去查找其它资料。

虽然这里仅仅以AES算法作为了例子,但是对于其它算法也是相同的操作流程,无非是使用的Transformation不一样罢了。甚至非对称加密算法也是一样的,只需要记住,加密用公钥,解密就需要用私钥,反之亦然,即可。

本文没有详细的讲解Cipher Mode,其实这一部分的内容还是比较重要的,想要真正的把Cipher应用在实际的系统中仅仅依靠ECB是不行的,因为ECB的安全级别实在是太低了,相对来说CBC比较好些,理解简单,而且也可以解决ECB中存在的安全隐患。

除了PKCS5Padding,Java的默认实现中还提供了一些其它的Padding方法:方法列表请参见 这里

Thanks for reading.

参考资料

  • 《Java Crypotography》—— Jonathan B. Knudsen
  • 《Java Security 2nd Edition》
  • 《Java 加密与解密的艺术》—— 梁栋

No comments :

Post a Comment