Showing posts with label Java Security. Show all posts
Showing posts with label Java Security. Show all posts

Friday, February 14, 2014

PBE - Password Based Encryption

加密算法需要的key都是一个byte数组,一般是通过KeyGenerator等类随机生成的。对于我们来说,byte数组是不便于记忆的,因此我们一般是将key导出到一个文件中,在使用的时候再从文件中导入。然而,在某些情况下,我们可能希望从键盘输入一个passphrase,并且使用这个passphrase对数据进行加密。

安全和方便

使用用户输入的密码进行加密,从用户使用角度上非常方便。因为不需要去记忆或者寻找介质保存一段难以理解Key信息。鱼和熊掌不能兼得。在得到了方便的同时,我们损失了安全性。

设想一下,SunJCE提供的DES算法要求的key是56bits,56bits的key一共有256种可能性,也就是72057594037927936种可能性。换句话说,如果使用暴力破解(一个一个实验),最坏的情况下需要72057594037927936次尝试。

现在我们换成使用用户输入的字符作为key。ASCII码能够表示128个字符,其中可以通过键盘输入的只有95个。让我们想得极端一些,用户输入了6个字符的passphrase。也就是说passphrase一共有956种可能性,也就是735091890625种可能。看到了把,这比使用56bit的DESKey减少了将近10万倍。

暴力破解

下面我们来看尝试实际破解一个使用passphrase进行加密的数据。

下面的加密数据是这样得到的:

  1. 通过键盘输入一个长度为4的Passphrase
  2. 将Passphrase使用SHA-256进行Hash运算,得到长度为32的Byte数组
  3. 利用这个Byte数组构造AES算法的Secret Key
  4. 使用Secret Key利用AES/ECB/PKCS5Padding算法对一段文字的UTF-8编码进行加密
  5. 加密结果使用Base64编码输出

Base64的输出结果是:

93Gx5o5W6VVuOxQaMn/QgQ==

对于原始数据的SHA-256消息摘要是:

FDF6DDC44BCF8B03D1B0097E09DB36B3A729A21E65BB22539D38B5B00EA96B35

我们不妨尝试写程序暴力破解出使用的Passphrase和解密数据。此处提醒一下,可以通过对比原始数据的消息摘要来验证是否正确的解密。

暴力破解的源码在这里,运行这个程序,需要输入密文,以及原文的消息摘要。可能需要比较长的时间才能计算出结果,请耐心等待。

如此看来,这样的一个passphrase我们可以很容易的就将其破解。现在让我们来反思一下为什么会被破解呢?主要的原因是可以通过键盘输入的字符太少了,这就导致passphrase的组合太少,而且计算机可以很快的从一个passphrase中计算出它对应的key,所以我们可以使用穷举发来逐一实验,并且在一个相对不长的时间内将密码破解出来。

PBE原理

设想一下,如果我们使用更加复杂的算法将passphrase转换成Key,是否会降低被破解的可能呢?现在的逻辑是我们直接使用了SHA-256将任意一个passphrase转换成256Bits的key。现在我们做一个修改,将passphrase进行1000000次SHA-256运算的结果作为key。这样就可以使生成Key的时间增加1000000倍。假设说,利用这个算法,将一个passphrase转换为Key需要3秒钟。那么4位的passphrase有954=81450625种可能性,将每种可能性转化为Key需要3秒钟,然后我们可以通过计算得出总时间为81450625 * 3 / 3600 / 24 = 2828(天)。这个时间一般来说是可以接受的了,而且我们可以通过增加HASH的次数使生成Key的时间更长。

似乎问题就这样被简单的解决了,但是事实并非如此。如果我们不能发现我们的问题,那么我们的对手会帮助我们发现。虽然我们可以通过增加Hash次数来延长生成Key的时间,但是黑客们可能技高一筹。作为黑客,很可能有一个预先已经生成好的列表了,这个列表中包含了每种passphrase经过1000000次SHA-256之后的结果。这样一来,黑客就可以绕过我们设定好的1000000次HASH,直接利用预先计算好的Hash结果进行破解。

为了避免黑客们使用这种预先计算好的Key列表,我们需要在原本的passphrase中加入Salt——盐。所谓盐,就是在进行Hash计算的时候,在passphrase上附加上一定长度的随机的byte数组。因为这个byte数组的长度和内容都是任意的,所以这就大大降低了黑客有预先计算好的Key列表这种可能性。从而强制意欲不轨的人想要得到每种passphrase对应的key,就必须按照规定好的算法进行1000000或者更多次的Hash运算。

Salt的选取

那么这里有一个问题:我应该如何选取Salt呢?涉及到两方面:

  • 内容选取
  • 长度选取

对于内容,尽可能是随机的。记住不要使用java.util.Random类来生成随机的Salt,因为java.util.Random是不安全的,可以使用SecureRandom来生成随机的Salt。

对于长度,先给出结论,应该尽可能的长,但是不需要超过Hash算法输出的长度。比如SHA-256输出256Bit的hash结果,因此需要32字节的Salt。原因是这样的:虽然Hash算法能够尽量保证不同的输入产生不同的Hash值,但是只要稍微一想就可以知道,这是不可能的。原因很简单,输入数据的可能性是无穷大的,而输出数据却是固定的长度,可能是256Bit。所以对于不同的输入,肯定有可能得到相同的输出。对于输出是256Bits的Hash算法,最多能够产生2256中不同的Hash值。那么,最理想的情况下,这2256个Hash值恰巧由同一个passphrase+2256种salt计算得出。而2265种salt恰巧就是256Bits所能表示的范围。换句话说,最完美的情况下,这会最大限度的减少黑客拥有预先算好的Key的可能性,如果Salt更长,那么就有可能和另外一个256Bits的Salt的Hash值重复了,所以是没有必要的。那么完美情况下,超出了Hash算法输出长度的Salt可能重复,就更不用说不完美的情况了——Salt用不着超出256Bits,就已经存在重复的了,因此就更加没有必要计算超过Hash输出结果长度的Salt了。

Java中的PBE

Java中提供了两种PBE的实现方式:PBE方式和PBKDF2方式

PBE方式

Key的生成在Cipher内部。即,在使用时,需要把Passphrase, Salt, Iteration Count都传入到Cipher中,Cipher内部会根据这些信息,计算出最终的Key,并且在加密解密时使用。

使用步骤:

  1. 通过Passpharse构造PBEKeySpec
  2. 利用SecretKeyFactory将PBEKeySpec转换成PBEKey
  3. 使用Salt和Iteration Count来构造PBEParameterSpec
  4. 通过调用Cipher类的init方法将PBEKey和PBEParameterSpec传入Cipher中
  5. 利用Cipher的doFinal方法对数据进行加密或者解密。

其计算Key的过程在调用Cipher的init方法时进行

下面是一个使用PBE的例子:

public class PBECipher {
    private Cipher encryptCipher;

    private Cipher decryptCipher;

    /**
     * 使用MD5算法将Passphrase转换成DES所用的Key
     */
    private final static String ALGORITHM = "PBEWithMD5AndDES";

    public PBECipher(char[] passphrase, byte[] salt, int iterationCount) {
        Key key = generatePBEKey(passphrase);
        encryptCipher = generateCipher(Cipher.ENCRYPT_MODE, key, salt, iterationCount);
        decryptCipher = generateCipher(Cipher.DECRYPT_MODE, key, salt, iterationCount);
    }

    private Key generatePBEKey(char[] passphrase) {
        try {
            /**使用passphrase构造PBEKeySpec*/
            PBEKeySpec keySpec = new PBEKeySpec(passphrase);
            /**使用SecretKeyFactory将PBEKeySpec转换为PBEKey*/
            SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(ALGORITHM);
            SecretKey key = keyFactory.generateSecret(keySpec);
            return key;
        } catch (NoSuchAlgorithmException | InvalidKeySpecException ex) {
            throw new RuntimeException(ex);
        }
    }

    private Cipher generateCipher(int opmode, Key key, byte[] salt, int iterationCount) {
        try {
            /**使用Salt和Iteration Count来构造PBEParameterSpec*/
            PBEParameterSpec parameterSpec = new PBEParameterSpec(salt, iterationCount);
            Cipher cipher = Cipher.getInstance(ALGORITHM);
            /**使用Key和PBEParameterSpec对Cipher进行初始化*/
            cipher.init(opmode, key, parameterSpec);
            return cipher;
        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException | InvalidKeyException ex) {
            throw new RuntimeException(ex);
        }
    }

    public byte[] encrypt(byte[] plainData) {
        return transform(encryptCipher, plainData);
    }

    public byte[] decrypt(byte[] cipherData) {
        return transform(decryptCipher, cipherData);
    }

    private byte[] transform(Cipher cipher, byte[] data) {
        try {
            byte[] cipherData = cipher.doFinal(data);
            return cipherData;
        } catch (IllegalBlockSizeException | BadPaddingException ex) {
            throw new RuntimeException(ex);
        }
    }

    public static void main(String[] args) throws Exception {
        byte[] salt = { -81, 93, -1, -32, -17, -33, -55, -66 };

        long start = System.currentTimeMillis();
        /**Iteration Count为100000000,因此会进行10000000次Hash运算来计算Key*/
        PBECipher cipher = new PBECipher("1qaz2wsxE".toCharArray(), salt, 10000000);
        long end = System.currentTimeMillis();
        System.out.println("Time elapsed: " + ((end - start)));

        String text = "Hello PBE";
        byte[] data = text.getBytes("UTF-8");
        byte[] cipherData = cipher.encrypt(data);
        byte[] plainData = cipher.decrypt(cipherData);
        String plainText = new String(plainData, "UTF-8");
        System.out.println(plainText);
    }
}
    

运行程序将得到如下输出:

Time elapsed: 9501
Hello PBE
    

首先我们可以看到解密后的数据和我们加密前的数据是一致的,因此可以证明加密解密的过程是正确的;另外一点需要注意Time elapsed,因为我们设置了Iteration Count为10000000,因此Cipher的init方法的执行时间会怎加,用于进行Key的计算,以此让暴力破解变得不易。

按照我们上面的分析,如果使用MD5将Passphrase转换为Key,因为MD5输出的散列结果是128 Bit(16 Bytes),所以理想的Salt长度是16 Bytes。但是需要注意,在Java的默认实现中,PBE方式要求Salt的长度只能是8 Bytes。这也是为什么不推荐使用PBE方式的原因之一。

Java的默认实现中支持4种加密算法的PBE实现:

  • PBEWithMD5AndDES
  • PBEWithMD5AndTripleDES
  • PBEWithSHA1AndDESede
  • PBEWithSHA1AndRC2_40

我们知道Key的计算是在Cipher内部完成的,那么在Cipher内部是如何将Passphrase转换成Key的呢?通过研究PBECipherCore类的源码,我们看到其中的deriveCipherKey方法是这样进行转换的:

  1. 将Passphrase转换为字节数组
  2. 把salt附加在passphrase转换的字节数组后面
  3. 将保存着passphrase和salt的字节数组进行iteration count次MD5运算,最终得到了长度是16 Bytes的结果
  4. 从16 Bytes的运算结果中取出前8 Bytes,做为DES算法的Key
  5. 同样,从16 Bytes的运算结果中取出前8 Bytes,做为加密时使用的IV

从这个过程中我们可以看到,Java的默认实现中,PBE方式的IV和Key的计算方式是一样的,因此降低了安全性,这也是不推荐使用PBE方式的另一个原因。

PBKDF2方式

Sun在JDK 1.4中引入了PBKDF2方式,来支持passphrase到Key的转换。和PBE方式不同,PBKDF2方式将Passphrase转换Key的过程从Cipher中分离出来,使之更加具有弹性。利用PBKDF2方式可以将一个passphrase转换成任意长度的key,在得到了Key之后,就可以使用普通的加密算法(DES/AES等算法)对数据进行加密。

使用步骤:

  1. 使用Passphrase,Salt,Iteration Count来构造PBEKeySpec
  2. 使用SecretKeyFactory将PBEKeySpec转换成PBKDF2Key
  3. 调用PBKDF2Key的getEncoded方法获取Encoded Key
  4. 使用Encoded Key构造SecretKeySpec,即得到SecretKey
  5. 使用得到的SecretKey对Cipher进行初始化
  6. 使用Cipher对数据进行加密或者解密

下面是一个PBKDF2的例子:

public class PBKDF2Cipher {
    private Cipher encryptCipher;

    private Cipher decryptCipher;

    public PBKDF2Cipher(char[] passphrase, byte[] salt, int iterationCount, byte[] iv) {
        long start = System.currentTimeMillis();
        /**将Passphrase, Salt, Iteration转换成Key*/
        Key key = generateKey(passphrase, salt, iterationCount);
        long end = System.currentTimeMillis();
        System.out.println("Time elapsed: " + (end - start));

        /**使用得到的SecretKey对Cipher进行初始化*/
        encryptCipher = generateCipher(Cipher.ENCRYPT_MODE, key, iv);
        decryptCipher = generateCipher(Cipher.DECRYPT_MODE, key, iv);
    }

    private Key generateKey(char[] passphrase, byte[] salt, int iterationCount) {
        try {
            /**使用Passphrase,Salt,Iteration Count来构造PBEKeySpec*/
            PBEKeySpec keySpec = new PBEKeySpec(passphrase, salt, iterationCount, 256);
            /**使用SecretKeyFactory将PBEKeySpec转换成PBKDF2Key*/
            SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
            SecretKey tempKey = keyFactory.generateSecret(keySpec);
            /**
             * 调用PBKDF2Key的getEncoded方法获取Encoded Key
             * 使用Encoded Key构造SecretKeySpec,即得到SecretKey
             */
            SecretKey key = new SecretKeySpec(tempKey.getEncoded(), "AES");
            return key;
        } catch (NoSuchAlgorithmException | InvalidKeySpecException ex) {
            throw new RuntimeException(ex);
        }
    }

    /**
     * 创建Cipher,并且使用key和IV进行初始化
     * 此处和正常是使用Cipher没有任何区别
     * @param opmode
     * @param key
     * @param iv
     * @return
     */
    private Cipher generateCipher(int opmode, Key key, byte[] iv) {
        try {
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
            cipher.init(opmode, key, ivParameterSpec);
            return cipher;
        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException ex) {
            throw new RuntimeException(ex);
        }
    }

    public byte[] encrypt(byte[] plainData) {
        return transform(encryptCipher, plainData);
    }

    public byte[] decrypt(byte[] cipherData) {
        return transform(decryptCipher, cipherData);
    }

    /**
     * 使用Cipher对数据进行加密或者解密
     * @param cipher 加密或者解密使用的Cipher
     * @param data 待加密或者解密的数据
     * @return 加密或者解密的结果
     */
    private byte[] transform(Cipher cipher, byte[] data) {
        try {
            byte[] tData = cipher.doFinal(data);
            return tData;
        } catch (IllegalBlockSizeException | BadPaddingException ex) {
            throw new RuntimeException(ex);
        }
    }

    public static void main(String[] args) throws Exception {
        byte[] salt = { 125, 26, -86, -29, -79, -11, 10, 51, -29, -24, -128, -95, 0, -16, -95, 52, -90, -54, 63, -19 };
        byte[] iv = { 125, -53, 114, 46, -62, 48, -36, 80, -70, 56, -65, -46, 62, 39, -77, -18 };
        PBKDF2Cipher cipher = new PBKDF2Cipher("1qaz2wsxE".toCharArray(), salt, 1000000, iv);
        String text = "Hello PBKDF2";
        byte[] data = text.getBytes("UTF-8");
        byte[] cipherData = cipher.encrypt(data);
        byte[] plainData = cipher.decrypt(cipherData);
        String plainText = new String(plainData, "UTF-8");
        System.out.println(plainText);
    }
}
    

运行上面的程序,将得到如下输出:

Time elapsed: 7837
Hello PBKDF2
    

可以看到正确的解密结果,可以证明加密解密的过程是正确的。另外,我们可以看到在生成Key的时候花费了7837毫秒,这是因为在生成Key的时候进行了1000000次Hash运算。此处可以看到和PBE方式的不同,PBE方式是在初始化Cipher的时候进行Hash运算的,而PBKDF2方式是使用KeyFactory将Passphrase转换成Key的。

那么KeyFactory是如何将PBEKeySpec转换成PBEKey的呢?Java中默认的PBKDF2实现中,使用的是PBKDF2WithHmacSHA1. 正如这个算法的名字,其用于生成Key的Hash算法是HmacSHA1,也就是带有Secret Key的SHA1算法。通过查看PBKDF2KeyImpl的源码,我们可以得到PBKDF2生成Key的详细步骤:

  1. 使用UTF-8对passphrase进行编码,得到passphrase对应的byte数组
  2. 把passphrase的byte数组作为Key,初始化HmacSHA1
  3. 根据需要的Key Length和Hash算法的输出长度来计算需要分成多少个Block来生成Key。以PBKDF2WithHmacSHA1生成AES-256 Key为例,AES-256 Key需要的长度是256 Bits,而HmacSHA1的输出是160 Bits,所以两个Hash结果才能拼成一个AES-256 Key,这样我们就说需要分成两个Block来计算AES-256 Key。

    计算公式:

    (KeyLength + HashLength - 1) / HashLength。

  4. 顺次计算每个Block的Hash结果,每个Block的初始值是Salt+序号。比如第一个Block的初始值是Salt+1,以此类推,第二个Block的初始值是Salt+2…
  5. 使用Hmac计算Block初始值的Hash值,利用得到的Hash值继续使用Hmac进行Hash,一直进行Iteration Count次Hash。
  6. 将这些Hash结果进行异或,得到当前Block的最终运算结果。
  7. 继续进行第五步,直到得到所有Block的最终运算结果。
  8. 将所有的Hash结果组成一个完整的Key,对于最后一次的Hash结果可能需要将尾部截掉。

下图是PBKDF2计算Key的示意图:

为了便于理解,下面是仿造JDK源码,写的一段模拟计算Key的代码:

public class PBKDF2Derivation {
    private String hashAlgorithm;

    private Mac mac;

    public PBKDF2Derivation() {
        hashAlgorithm = "HmacSHA1";
        try {
            mac = Mac.getInstance(hashAlgorithm);
        } catch (NoSuchAlgorithmException ex) {
            throw new RuntimeException(ex);
        }
    }

    public byte[] deriveKey(char[] password, byte[] salt, int iterCount, int keyLengthInBit) {
        byte[] encodedPassword = getPassword(password);
        try {
            mac.init(new SecretKeySpec(encodedPassword, "HmacSHA1"));
        } catch (InvalidKeyException ex) {
            throw new RuntimeException(ex);
        }

        if (keyLengthInBit % 8 != 0) {
            throw new IllegalArgumentException("Key Length must be multiple of 8");
        }

        int keyLengthInByte = keyLengthInBit / 8;
        byte[] keyData = new byte[keyLengthInByte];
        int macLength = mac.getMacLength();
        int blockCount = (keyLengthInByte + macLength - 1) / macLength;
        int lastHashLength = keyLengthInByte - macLength * (blockCount - 1);

        for (int i = 1; i <= blockCount; i++) {
            mac.update(salt);
            mac.update(convertInt2Bytes(i));
            byte[] hash = mac.doFinal();
            byte[] newHash = hash;
            for (int j = 1; j < iterCount; j++) {
                newHash = mac.doFinal(newHash);
                for (int k = 0; k < hash.length; k++) {
                    hash[k] ^= newHash[k];
                }
            }

            if (i < blockCount) {
                System.arraycopy(hash, 0, keyData, (i - 1) * macLength, hash.length);
            } else {
                System.arraycopy(hash, 0, keyData, (i - 1) * macLength, lastHashLength);
            }
        }
        return keyData;
    }

    private byte[] getPassword(char[] password) {
        Charset utf8 = Charset.forName("UTF-8");
        ByteBuffer encodedPasswordBuffer = utf8.encode(CharBuffer.wrap(password));
        byte[] encodedPassword = encodedPasswordBuffer.array();
        return encodedPassword;
    }

    private byte[] convertInt2Bytes(int i) {
        byte[] bytes = new byte[4];
        bytes[3] = (byte) i;
        bytes[2] = (byte) ((i >> 8) & 0xff);
        bytes[1] = (byte) ((i >> 16) & 0xff);
        bytes[0] = (byte) ((i >> 24) & 0xff);
        return bytes;
    }
}
    

PBE方式 vs. PBKDF2方式

相比PBE方式,更加推荐使用PBKDF2方式:

  • Java的默认实现中PBE方式不能设置Salt的长度,只能是规定的16 Bytes,而PBKDF2方式却可以设置任意长度的Salt。
  • PBE方式的IV和Key都是通过Passphrase计算出来的,而且不能单独设置IV。PBKDF2方式却可以对IV完全独立设置,提高了安全性。
  • PBKDF2方式将Key的计算完全隔离出来,更加具有弹性。

Tuesday, January 28, 2014

Cipher Mode - CFB(Cipher Feedback)

Block Cipher在效率上要明显高于Stream Cipher。但是,并不能武断的认为Block Cipher就一定比Stream Cipher好。在不同的应用环境下,适合的Cipher种类也是不一样的。比如想要实现Putty这类终端模拟器,在传输命令的时候需要对命令进行加密传输。此时,就不应该使用Block Cipher。可以想象一下,我们使用Putty输入一个命令ls,等了半天,没有反映,原因是输入的数据不足一个block。这样的用户体验很明显是不能被接受的。通常的做法是每输入一个字符,就将这个字符加密并且传输到服务器上。在Java中,所有的Cipher都是Block Cipher,那么想要实现这种Stream Cipher应该怎么办呢?需要通过使用能够模拟Stream Cipher的Mode来完成。

CFB - Cipher Feedback

在CFB Mode中,我们可以在Block Size的基础上设置每次加密的数据量,以下称之为Actual Size(Actual Size必须小于或者等于算法的Block Size)。对于一种特例,是Actual Size正好等于Block Size,我们先从这种最容易理解的情况开始入手。

这种特例的加密流程如下:

  1. Ci = EK(Ci-1) ⊕ Pi

    第i个Block的加密结果是这样得到的:首先,将第i-1个Block的加密结果进行加密即EK(Ci-1) 我们称之为Encrypted Block,然后,将这个加密结果与第i个Block进行异或操作。

  2. C0 = IV

    对于第一个Block,因为它前面没有任何Block的加密结果,因此需要将IV进行加密EK(IV),然后,将加密结果和第一个Block进行异或,得到第一个Block的加密结果。

加密过程如下图:

CFB encryption.svg

同样是针对这种特殊情况,解密的过程是相反的:

  1. Pi = EK(Ci-1) ⊕ Ci

    第i个Block的解密过程是这样:首先,将第i-1个Block的加密结果进行加密,即EK(Ci-1),然后将这个加密结果和第i个Block的密文进行异或操作。

    这里需要注意下,无论是加密还是解密,都需要使用加密算法对前一个Block进行加密。可能会觉得很奇怪,但仔细想想我们就会明白:因为在整个的加密过程中,我们实际上是把一连串的明文Block与一连串的Encrypted Block (即EK(Ci-1)) 进行异或操作(我们没有直接加密明文数据的过程,明文数据是通过与Encrypted Block异或后转变成密文的),进而得到最终的加密结果。因此CFB Mode的本质,就是计算一连串的Encrypted Block,因为加密和解密的时候IV是一样的,所以我们必须使用相同的方式来计算这一连串的Encrypted Block。因此,“在加密和解密过程中都需要使用加密算法对Ci-1进行加密”,这一点就说得通了。

  2. C0 = IV

    同样,这里和加密是一样的。

解密过程如下图:

CFB decryption.svg

因为在CFB Mode内部,无论是加密还是解密,都要使用加密来计算Encrypted Block(EK(Ci-1)),那么如果我们把CFB Mode应用到非对称加密算法中,就会产生一个比较奇怪的现象。无论是加密还是解密,使用的都是同一个Key(如果加密使用Private Key,那么解密也需要使用Private Key;如果加密使用Public Key,那么解密也需要使用Public Key),因为内部进行的都是加密操作,因此需要使用相同的Key才能得到相同的Encrypted Block序列。这样看来,非对称加密算法,在CFB Mode下,变成了对称加密的表现形式。这里仅仅是纯粹的理论分析,实际上非对称加密算法的CFB Mode没有任何意义,因为表现形式和对称加密一样,但是效率却是非对称加密的效率(把对称加密算法和非对称加密算法的缺点都占了)。可能正是因为如此,Java中没有实现RSA或者DSA算法的CFB Mode。

下面是一个AES应用CFB Mode的例子:

public class CFBDemo {
    public static byte[] transform(String transformation, int mode, Key key, byte[] iv, byte[] data) {
        try {
            Cipher cipher = Cipher.getInstance(transformation);
            IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
            cipher.init(mode, key, ivParameterSpec);
            byte[] transformedData = cipher.doFinal(data);
            return transformedData;
        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | BadPaddingException | IllegalBlockSizeException ex) {
            throw new RuntimeException(ex);
        }
    }

    public static byte[] encrypt(byte[] data, Key key, byte[] iv) {
        return transform("AES/CFB/PKCS5Padding", Cipher.ENCRYPT_MODE, key, iv, data);
    }

    public static byte[] decrypt(byte[] cipherData, Key key, byte[] iv) {
        return transform("AES/CFB/PKCS5Padding", Cipher.DECRYPT_MODE, key, iv, cipherData);
    }

    public static void main(String[] args) throws Exception {
        Key key = new SecretKeySpec(new byte[]{ 42, 9, -69, -94, -58, -55, 40, -68, 56, -53, 68, 68, 95, -114, -73, 5, 115, -65, -115, -113, -72, -61, 70, 23, -71, -59, -39, 49, 46, -127, -116, -70 }, "AES");
        byte[] iv = { 37, -101, 93, -90, -70, 108, 14, -6, -59, -109, 53, -89, -116, -110, 16, -124 };

        byte[] data = "Hello RSA CFB".getBytes("UTF-8");
        System.out.println(Arrays.toString(data));
        byte[] cipherData = encrypt(data, key, iv);
        System.out.println(Arrays.toString(cipherData));
        byte[] plainData = decrypt(cipherData, key, iv);
        System.out.println(Arrays.toString(plainData));
    }
}
    

运行上面程序,将得到如下输出:

[72, 101, 108, 108, 111, 32, 82, 83, 65, 32, 67, 70, 66]
[17, 80, 5, -46, 44, 36, -103, -5, 120, -86, -34, -77, -123, -27, 79, 54]
[72, 101, 108, 108, 111, 32, 82, 83, 65, 32, 67, 70, 66]
    

第一行是源数据,第二行是加密后的结果,第三行是解密后的结果。可以看到,因为加密的数据不足16 Bytes,所以Cipher对数据进行了Padding。因为CFB Mode可以模拟Stream Cipher,因此可以不适用Padding。通过改变上面代码的Transformation就可以做到这一点。将上面的代码中的Transformation改成AES/CFB/NoPadding,运行程序,将得到如下输出:

[72, 101, 108, 108, 111, 32, 82, 83, 65, 32, 67, 70, 66]
[17, 80, 5, -46, 44, 36, -103, -5, 120, -86, -34, -77, -123]
[72, 101, 108, 108, 111, 32, 82, 83, 65, 32, 67, 70, 66]
    

可以看到,padding的三个字节已经没有了。CFB Mode具体是如何做到这一点的,将会在下面介绍。

CFB的变体

到此为止,我们所看到的CFB Mode和CBC Mode相比,并没有什么本质上的区别。仔细观察它们的流程图,我们同样会发现它们的流程基本是一样的。那么为什么说CFB可以让Block Cipher模拟Stream Cipher呢。接下来我们就来看一下CFB的变体。

我们可以简单的把CFB Mode分成两个部分:

  1. 对前一个Cipher Block进行加密,得到Encrypted Block;(加密部分)
  2. 将Encrypted Block和当前Plain Block进行异或,得到加密结果Cipher Block。(异或部分)

我们可以想象,加密操作要求数据的长度必须是Block Size,而异或操作对数据长度没有要求。而我们已经知道,CFB Mode仅在异或部分使用了Plain Block,因此我们就可以确定,CFB Mode对于Plain Block的长度是没有要求的。而在加密部分,Encrypted Block的长度需要是Block Size。所以在CFB Mode中,我们可以一次加密任意长度的明文数据(必须小于Block Size),只需要保证Ci-1的长度是Block Size即可。

标准的CFB Mode是这样做的,引入一个移位器(Shift Register),移位器的大小和Block Size 的大小一致。

加密:

  1. 将IV存入移位器(Shift Register)中。
  2. 对移位器(Shift Register)中的数据进行加密,将结果保存到Encrypted Buffer中。此处注意:移位器中的数据没有变化。(之前一直以为直接将结果保存在了Shift Register中,后来经过分析实验错误传递机制,才发现了这个理解错误)
  3. 从Encrypted Buffer左侧取出Actual Size个字节,与长度为Actual Size的Plain Block进行异或操作,得到长度为Actual Size的加密结果。当前Encrypted Buffer在这里被丢弃。
  4. 将移位器中的数据左移Actual Size个字节,即,在移位器右侧空出长度为Actual Size的空间。
  5. 把第3步的加密结果从右侧移入移位器中,放在第4步空出的空间里面。
  6. 转到第2步进行下一轮Actual Size个字节的加密。

解密:

  1. 将IV存入移位器中。
  2. 对移位器(Shift Register)中的数据进行加密,将结果保存到Encrypted Buffer中。此处注意:移位器中的数据没有变化。
  3. 从Encrypted Buffer左侧取出Actual Size个字节,与长度为Actual Size的加密数据进行异或操作,得到长度为Actual Size的解密结果。当前Encrypted Buffer在这里被丢弃。
  4. 将移位器中的数据左移Actual Size个字节,即,在移位器右侧空出长度为Actual Size的空间。
  5. 把第3步使用到的加密数据从右侧移入移位器中,放在第4步空出的空间里面。
  6. 转到第2步进行下一轮Actual Size个字节的解密。

比较常用的CFB Mode变体是CFB-8 Mode。CFB-8 Mode——一次加密8 Bits数据,也就是一个Byte。在Java的默认实现中,Byte是最小的加密单位,尽管在技术上CFB Mode可以支持更小的加密单位,但是在Java并没有支持。其实仔细想想,加密单位小于一个Byte是没有什么实际意义的,因为计算机处理数据的基本单位就是Byte,所以Byte已经可以正常满足我们的需求了。

/**
 * 这里在加密的时候没有调用doFinal方法,取而代之的是只调用了update方法。
 * doFinal方法是告诉Cipher消息传输完毕了,可以进行padding了。
 * 因为CFB-8是每次加密一个Byte,所以不需要任何Padding,所以,
 * 这里也就没有必要调用doFinal方法了。
 *
 */
public class CFB8Demo {
    private Cipher cipher;

    public CFB8Demo(String transformation, int mode, Key key, byte[] iv) {
        try {
            cipher = Cipher.getInstance(transformation);
            IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
            cipher.init(mode, key, ivParameterSpec);
        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException ex) {
            throw new RuntimeException(ex);
        }
    }

    public byte[] encrypt(byte[] data) {
        return cipher.update(data);
    }

    public byte[] decrypt(byte[] cipherData) {
        return cipher.update(cipherData);
    }

    public static void main(String[] args) throws Exception {
        Key key = new SecretKeySpec(new byte[]{ -7, -35, 69, 95, 107, 32, 49, 13, 13, -15, -89, -42, -40, -47, -128, 104, 49, -120, 5, 80, 48, 79, -74, 116, -125, -62, -124, 29, -71, -115, 127, 76 }, "AES");
        byte[] iv = { -86, 119, -62, -100, -21, -63, 38, -88, -75, 27, -9, 29, 115, -17, -117, 106 };

        CFB8Demo cfb8Cipher = new CFB8Demo("AES/CFB8/NoPadding", Cipher.ENCRYPT_MODE, key, iv);
        try (Scanner scanner = new Scanner(System.in)) {
            while (true) {
                String inputText = scanner.nextLine();
                byte[] data = inputText.getBytes("UTF-8");
                byte[] cipherData = cfb8Cipher.encrypt(data);
                /*使用16进制表示法将加密结果输出,实际项目中最好使用Base64*/
                System.out.println(DatatypeConverter.printHexBinary(cipherData));
            }
        }
    }
}
    

运行上面的代码,随便输入任意长度的字符,回车之后都会直接对输入的字符进行加密,而且加密结果中不包含任何的Padding。

CFB vs. CFB-X

让我们来讨论一个问题:在相同的算法,相同的Key,相同的IV的前提下,使用CFB Mode和CFB-8 Mode加密相同的数据,得到的加密结果是否相同?

  1. 将IV进行加密,在这一步CFB和CFB-8得到的结果都是一样的,我们把这个结果标记为EK(C0)
  2. CFB Mode 将第一个Plain Block和EK(C0)进行异或操作,得到的是一个长度为Block Size的Byte数组。

    CFB-8 Mode 将第一个Byte和EK(C0)的第一个Byte进行异或操作,得到长度为1 Byte的加密结果,记作C1

    如此一来可以想象,CFB Mode第一个Block的第一个Byte和CFB-8的第一个Byte是相同的。

  3. CFB处理完了一个Block,CFB-8处理了一个字节。我们让CFB Mode暂时先等待一下CFB-8 Mode。
  4. CFB-8 Mode中,将C0左移8 bits,并且将#2中加密好的一个Byte从右边移入C0中,记作S(C0, C1)。原有C0的第二个Byte现在变成了第一个。
  5. CFB-8 Mode会继续将S(C0, C1)进行加密,之后用于和第二个明文Byte进行异或,得到第二个Byte的加密结果。正如我们所想的,S(C0, C1)和C0的加密结果肯定是不一样的。

结论:CFB Mode和CFB-8 Mode,在上述条件下,只有第一个字节的加密结果是相同的。那么,我们也可以把这个结论更加延伸一下:CFB Mode和CFB-X Mode,在加密算法,Key,IV,明文数据都相同的情况下,只有前X Bits的加密结果是一样的。

我们可以利用下面的代码来验证我们的结论:

public class CFBXCipher {
    private Cipher cipher;

    public CFBXCipher(Key key, int cipherMode, byte[] iv, int length) {
        try {
            IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
            cipher = Cipher.getInstance(String.format("AES/CFB%d/NoPadding", length * 8));
            cipher.init(cipherMode, key, ivParameterSpec);
        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException ex) {
            throw new RuntimeException(ex);
        }
    }

    public byte[] encrypt(byte[] data) {
        try {
            byte[] cipherData = cipher.doFinal(data);
            return cipherData;
        } catch (BadPaddingException | IllegalBlockSizeException ex) {
            throw new RuntimeException(ex);
        }
    }

    /**
     * 以下代码将打引出,相同数据在不同的CFB Mode下的加密结果,从CFB-8 ~ CFB-128
     * @param args
     * @throws Exception
     */
    public static void main(String[] args) throws Exception {
        SecretKey key = new SecretKeySpec(new byte[] { 67, 96, -48, -65, 18, -124, -14, 109, -32, 20, 85, -22, -96, -20, 109, -14, -53, 28, -49, -33, 39, -80, -42, 43, 123, -48, -35, 122, 84, 77, 53, 12 }, "AES");
        byte[] iv = { 111, 50, -91, -79, -45, 75, -30, 110, 75, -83, -27, 7, -94, -35, -41, -2 };

        /*AES 的 Block Size是16 Byte,因此Actual Size不能大于16 Byte*/
        for (int i=1;i<=16;i++) {
            CFBXCipher cipher = new CFBXCipher(key, Cipher.ENCRYPT_MODE, iv, i);
            byte[] data = "ABCDEFGHIJKLMNOP".getBytes("UTF-8");

            byte[] cipherData = cipher.encrypt(data);
            System.out.println(String.format("CFB-%d\t%s", i * 8, Arrays.toString(cipherData)));
        }

    }
}
    
运行上面的程序,将得到如下输出:
CFB-8   [22, -16, 36, -101, -62, -26, -15, -5, 31, -63, -8, -28, 111, -102, 77, -72]
CFB-16  [22, 120, 57, -25, 52, 56, 114, -39, -63, 1, -107, -44, 102, 68, 61, 58]
CFB-24  [22, 120, 119, -4, 12, -119, -2, 82, -120, 68, -113, 79, 44, 30, 39, 96]
CFB-32  [22, 120, 119, 22, 84, 28, -41, -4, 99, -108, -60, 104, 57, 6, -103, -11]
CFB-40  [22, 120, 119, 22, -48, -5, 82, 113, 7, -6, -15, -100, 104, -66, 20, 23]
CFB-48  [22, 120, 119, 22, -48, 82, 124, -5, -102, -26, 40, 16, -91, -44, 65, -84]
CFB-56  [22, 120, 119, 22, -48, 82, 85, -29, 40, -41, -123, -65, 116, -108, 28, -19]
CFB-64  [22, 120, 119, 22, -48, 82, 85, -47, -85, -19, 123, -54, 62, -118, -84, 73]
CFB-72  [22, 120, 119, 22, -48, 82, 85, -47, -126, -34, -11, 100, -125, -61, 120, -37]
CFB-80  [22, 120, 119, 22, -48, 82, 85, -47, -126, -103, 83, 118, -56, 103, 60, 52]
CFB-88  [22, 120, 119, 22, -48, 82, 85, -47, -126, -103, 109, -116, -71, -119, 48, -124]
CFB-96  [22, 120, 119, 22, -48, 82, 85, -47, -126, -103, 109, -93, -89, 113, -28, -62]
CFB-104 [22, 120, 119, 22, -48, 82, 85, -47, -126, -103, 109, -93, 58, -79, -55, -47]
CFB-112 [22, 120, 119, 22, -48, 82, 85, -47, -126, -103, 109, -93, 58, -103, 48, -104]
CFB-120 [22, 120, 119, 22, -48, 82, 85, -47, -126, -103, 109, -93, 58, -103, -54, 80]
CFB-128 [22, 120, 119, 22, -48, 82, 85, -47, -126, -103, 109, -93, 58, -103, -54, -11]
    

仔细观察这个输出结果,完全符合我们上面的结论。

可能会有这样的疑问:为什么CFB-8中,每进行一个Byte的加密后,就要加密一次移位器?为什么不是等到一个完整的Block加密完,再进行移位器的加密,这样就可以让CFB Mode和CFB-8 Mode的加密结果完全一样了?

原因是这样的,如果Block Size正好是Actual Size的整数倍,那么的确可以这样进行,即提高了效率,又能使CFB和CFB-8产生同样的结果。但是CFB被设计成一种更加通用的Mode,也就是说不强制要求Actual Size能够整除Block Size,只要Actual Size小于Block Size即可。想想看,如果Block Size不是Actual Size的整数倍,那么就没法找到一个合适的时间去加密移位器。因此,CFB Mode被设计为这样的加密流程。

错误传递性

如果密文中的一个bit损坏,那么,会对解密后的明文造成什么影响呢?

我们先从最简单的情况开始分析——当Actual Size 和Block Size相等的时候:

CFB Mode在解密的时候,并没有直接对Cipher Block进行解密,而是将Cipher Block和Encrypted Block通过异或操作来计算Plain Block。

  • 对Ci解密,即Pi = EK(Ci-1) ⊕ Ci。如果Ci中有一个bit损坏,并不会影响整个Ci的解密过程,受到影响的只有损坏的那一个bit。
  • 对Ci+1进行解密。同样,我们套用解密公式:Pi+1 = EK(Ci) ⊕ Ci+1。因为我们知道Ci中有一个bit损坏了,这会导致EK(Ci)产生一个和正确数据完全不同的结果(因为是整个Block进行加密的),从而会导致整个Pi+1都是错误的,因此损坏的数据是一个Block Size
  • 对Ci+2进行解密。使用公式计算Pi+2 = EK(Ci+1) ⊕ Ci+2。因为,损坏的bit在Ci中,而解密Ci+2的时候,和Ci没有任何关系,所以损坏的bit对解密Ci+2不会造成任何影响。

综上所述,当密文中1个bit损坏,且Actual Size和Block Size相等的情况下,将会导致解密结果中有1 bit + 1 Block Size的数据损坏,其中1 Bit就对应着密文中损坏的1 bit,而1个 Block Size对应着损坏的1 Bit所在Block之后的一个Block。

接下来,我们再来分析一下Actual Size和Block Size不相等的情况:

CFB Mode加入了Shift Register来处理Actual Size和Block Size不等的情况。但是无论如何Shift,损坏的1 bit始终是无法正确解密的,所以,在最终的解密结果中,肯定会有1 Bit数据受到影响。

因为解密过程是EK(Shift Register) ⊕ Ci所得到的结果,又因为,除了损坏的1 Bit,其它的加密数据都是完好的。所以除了损坏的1 Bit,其它的Ci都是正确的。现在,我们可以把问题简化为“只要保证EK(Shift Register)是正确的,就能保证解密结果的正确性”。那么,我们可以把问题进一步简化“只要保证Shift Register中的数据是正确的,就可以保证解密结果的正确性”。现在我们来想想,什么时候Shift Register中的数据会不正确呢?那就是,当损坏的1 Bit数据驻留在Shift Register中的时候。我们知道,Shift Register每次Shift长度为Actual Size的数据,每次Shift后会进行加密,并且和密文进行异或,得到长度为Actual Size的明文数据。那么受到影响的明文数据量是:T * Actual Size.其中T表示损坏的1 Bit数据从进入Shift Register开始,到离开Shift Register为止,这段期间Shift的次数。那么,问题进一步转化,我们只需要求出这1 Bit在Shift Register的Shift次数即可。这里又分为两种情况:

如果Actual Size能够整除Block Size,那么Shift的次数将是Block Size / Actual Size.

如果Actual Size不能整除Block Size,那么还需要关注损坏的1 Bit在经历了Block Size / Actual Size次Shift之后是否还驻留在Shift Register中。如果损坏的1 Bit已经不在Shift Register了,那么Shift次数就是Block Size / Actual Size;同理,如果还存在,那么Shift的次数是Block Size / Actual Size + 1。

现在我们将算好的T放入上面的公式(T * Actual Size)中,就可以得到受到影响的数据量了。

最小值:Block Size / Actual Size * Actual Size

最大值:(Block Size / Actual Size + 1) * Actual Size

综上所述,当密文中1个bit损坏,且Actual Size和Block Size不相等的情况下,将会导致解密结果中有1 bit + Block Size / Actual Size * Actual Size 到 1 bit + (Block Size / Actual Size + 1) * Actual Size的数据损坏。

让我们写段程序来验证以下上面的结论:

public class CFBXPropagation {
    private Cipher cipher;

    public CFBXPropagation(Key key, int cipherMode, byte[] iv, int length) {
        try {
            IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
            cipher = Cipher.getInstance("AES/CFB" + (length * 8) + "/NoPadding");
            cipher.init(cipherMode, key, ivParameterSpec);
        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException ex) {
            throw new RuntimeException(ex);
        }
    }

    public byte[] encrypt(byte[] data) {
        try {
            byte[] cipherData = cipher.doFinal(data);
            return cipherData;
        } catch (BadPaddingException | IllegalBlockSizeException ex) {
            throw new RuntimeException(ex);
        }
    }

    public byte[] decrypt(byte[] cipherData) {
        try {
            byte[] plainData = cipher.doFinal(cipherData);
            return plainData;
        } catch (BadPaddingException | IllegalBlockSizeException ex) {
            throw new RuntimeException(ex);
        }
    }

    public static void main(String[] args) throws Exception {
        SecretKey key = new SecretKeySpec(new byte[] { 67, 96, -48, -65, 18, -124, -14, 109, -32, 20, 85, -22, -96, -20, 109, -14, -53, 28, -49, -33, 39, -80, -42, 43, 123, -48, -35, 122, 84, 77, 53, 12 }, "AES");
        byte[] iv = { 111, 50, -91, -79, -45, 75, -30, 110, 75, -83, -27, 7, -94, -35, -41, -2 };

        byte[] data = "这是一段加密过的消息,如果加密数据中的1 bit出现问题,会对解密结果造成什么样的影响呢?".getBytes();
        System.out.println(Arrays.toString(data));

        CFBXPropagation encryptCipher = new CFBXPropagation(key, Cipher.ENCRYPT_MODE, iv, 1);
        byte[] cipherData = encryptCipher.encrypt(data);
        /*此处通过改变第9个byte的值,来模拟加密数据损坏*/
        cipherData[8]++;

        CFBXPropagation decryptCipher = new CFBXPropagation(key, Cipher.DECRYPT_MODE, iv, 1);
        byte[] plainData = decryptCipher.decrypt(cipherData);
        System.out.println(Arrays.toString(plainData));
        System.out.println(new String(plainData));
    }
}
    

执行上面的程序,将得到如下输出:

[-43, -30, -54, -57, -46, -69, -74, -50, -68, -45, -61, -36, -71, -3, -75, -60, -49, -5, -49, -94, -93, -84, -56, -25, -71, -5, -68, -45, -61, -36, -54, -3, -66, -35, -42, -48, -75, -60, 49, 32, 98, 105, 116, -77, -10, -49, -42, -50, -54, -52, -30, -93, -84, -69, -31, -74, -44, -67, -30, -61, -36, -67, -31, -71, -5, -44, -20, -77, -55, -54, -78, -61, -76, -47, -7, -75, -60, -45, -80, -49, -20, -60, -40, -93, -65]
[-43, -30, -54, -57, -46, -69, -74, -50, -67, 2, 90, 66, -37, -29, 9, 121, -24, 64, -17, 103, -88, 41, 82, -44, 31, -5, -68, -45, -61, -36, -54, -3, -66, -35, -42, -48, -75, -60, 49, 32, 98, 105, 116, -77, -10, -49, -42, -50, -54, -52, -30, -93, -84, -69, -31, -74, -44, -67, -30, -61, -36, -67, -31, -71, -5, -44, -20, -77, -55, -54, -78, -61, -76, -47, -7, -75, -60, -45, -80, -49, -20, -60, -40, -93, -65]
这是一段?ZB坫    y鐯飃?R?用苁葜械? bit出现问题,会对解密结果造成什么样的影响呢?
    

第一行是原始数据,第二行是加密数据损坏1bit后的解密结果。仔细比对这两行数据,我们可以发现一共有17 Bytes是不一样的,正好是1 Bit + 1 Block的数据量,其中1 Bit的损坏被体现在第一个不同的Byte中。

优点:

  1. 可以模拟Stream Cipher,处理需要即时加密的数据。同时,不再需要对数据进行padding。
  2. 并行解密,因为在解密的时候,每个Block仅和前一个Block的加密结果有关,所以多个Block可以并行解密。

缺点:

  1. 尽管可以模拟Stream Cipher,但是降低了效率。因为每次加密一个Actual Size大小的数据,实际上都要加密一次移位器中的数据,而移位器中的数据长度是Block Size。因此CFB-X Mode的效率,要比正常的CBC或者原始的CFB慢 BlockSize / X 倍。同时,增大X的值,效率将会不断的提高,直到X = Block Size,CFB的效率将会和CBC的效率一样。
  2. 因为加密的过程中,需要使用前一个Block的加密结果,因此无法进行多个Block的并行加密。

OFB - Output feedback

与CFB Mode类似的是OFB Mode,不同点是OFB Mode每次不是加密上一步的Cipher Block,而是加密上一步产生的Encrypted Block,并且将得到的结果和Plain Block进行异或,得到这个Block的加密结果。也就是说加密的部分和Plain Data只有异或的关系,没有任何加密的关系。这里对OFB Mode不再做深入介绍,详细信息请参考维基百科,加密解密流程图如下:

OFB encryption.svg

CFB decryption.svg

总结

本文主要介绍了CFB Mode。通过CFB Mode我们可以模拟Stream Cipher来对任意长度的数据进行加密。其中也包含了我在学习CFB Mode的时候的一些自己的分析。

对于这些东西,其实记住了结论即可(比如错误传递性)。之所以这里写下详细的分析过程,都是为了更好的理解CFB Mode。例如,我就是通过分析CFB Mode的错误传递机制,才修正了之前对于CFB Mode原理的一项错误理解。基本使用的分析方法如下:

  1. 查找原理,通过书籍/网上资料对需要分析的东西进行查找,了解其理论原理。
  2. 理解原理,对于查找到的资料进行自我理解,并且在原有资料的基础上充实细节。
  3. 对资料中的原理,细节进行实验验证。
  4. 如果理解的东西无法通过验证,需要进一步分析原因。可能需要查找更多的资料,或者查看源码等。
  5. 解决问题的方式不仅仅有一种,活学活用。

最后附上一段我在研究CFB Mode的过程中写的一个简单的模拟程序,用于模拟实现CFB Mode,其中缺少了一些应有的错误验证,仅供参考:

public class CFBXSimulator {

    private InternalAESCipher cipher;

    private int numBytes = 0;

    private byte[] shiftRegister = new byte[16];

    private byte[] iv = new byte[16];

    private static final int BLOCK_SIZE = 16;

    public CFBXSimulator(Key key, byte[] iv, int numBits) {
        this.cipher = new InternalAESCipher(key);
        this.iv = iv;
        System.arraycopy(this.iv, 0, shiftRegister, 0, iv.length);
        numBytes = numBits / 8;
    }

    public void reset() {
        System.arraycopy(this.iv, 0, shiftRegister, 0, iv.length);
    }

    public byte[] encrypt(byte[] plainData) {
        byte[] out = new byte[plainData.length];
        int loopCount = plainData.length / numBytes;
        for (int i = 0; i < loopCount; i++) {
            byte[] cipherRegister = cipher.encrypt(shiftRegister);
            System.arraycopy(shiftRegister, numBytes, shiftRegister, 0, BLOCK_SIZE - numBytes);
            for (int j = 0; j < numBytes; j++) {
                out[i * numBytes + j] = (byte) (plainData[i * numBytes + j] ^ cipherRegister[j]);
                shiftRegister[(BLOCK_SIZE - numBytes) + j] = out[i * numBytes + j];
            }
        }
        return out;
    }

    public byte[] decrypt(byte[] cipherData) {
        byte[] out = new byte[cipherData.length];
        int loopCount = cipherData.length / numBytes;
        for (int i = 0; i < loopCount; i++) {
            byte[] cipherRegister = cipher.encrypt(shiftRegister);
            System.arraycopy(shiftRegister, numBytes, shiftRegister, 0, BLOCK_SIZE - numBytes);
            for (int j = 0; j < numBytes; j++) {
                out[i * numBytes + j] = (byte) (cipherData[i * numBytes + j] ^ cipherRegister[j]);
                shiftRegister[(BLOCK_SIZE - numBytes) + j] = cipherData[i * numBytes + j];
            }
        }
        return out;
    }
    
    private class InternalAESCipher {
        private Cipher cipher;

        public InternalAESCipher(Key key) {
            try {
                cipher = Cipher.getInstance("AES/ECB/NoPadding");
                cipher.init(Cipher.ENCRYPT_MODE, key);
            } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException ex) {
                throw new RuntimeException(ex);
            }
        }

        public byte[] encrypt(byte[] data) {
            try {
                byte[] cipherData = cipher.doFinal(data);
                return cipherData;
            } catch (IllegalBlockSizeException | BadPaddingException ex) {
                throw new RuntimeException(ex);
            }
        }
    }

    public static void main(String[] args) throws Exception {
        SecretKey key = new SecretKeySpec(new byte[] { 67, 96, -48, -65, 18, -124, -14, 109, -32, 20, 85, -22, -96, -20, 109, -14, -53, 28, -49, -33, 39, -80, -42, 43, 123, -48, -35, 122, 84, 77, 53, 12 }, "AES");
        byte[] iv = { 111, 50, -91, -79, -45, 75, -30, 110, 75, -83, -27, 7, -94, -35, -41, -2 };
        byte[] data = "这是一段加密过得消息,如果加密数据中的1 bit出现问题,会对解密结果造成什么样的影响呢?".getBytes("UTF-8");
        System.out.println(Arrays.toString(data));

        CFBXSimulator simulator = new CFBXSimulator(key, iv, 8);
        byte[] cipherData = simulator.encrypt(data);
        System.out.println(Arrays.toString(cipherData));
        simulator.reset();
        System.out.println(new String(simulator.decrypt(cipherData), "UTF-8"));
    }
}
    

标准的Java实现请参考JDK源码中的CipherFeedback类。

本文图片来自维基百科

参考资料:

  • 维基百科
  • 《Java Cryptography》—— Jonathan B. Knudsen

Thanks for reading

Wednesday, January 22, 2014

Cipher Mode - EBC & CBC & PCBC

使用Block Cipher的时候可以为Cipher指定不同的Mode。Mode的作用是在加密Block之前或者之后对Block进行一些处理,目的是为了增加安全系数。

EBC - Electronic Codebook

最简单的一种Cipher Mode是ECB(Electronic Codebook),它没有对Block进行任何处理。利用加密算法,对每一个Block无差别地进行加密,并且进行输出。解密的过程则是相反,对每个Block独立进行解密。

ECB encryption.svg

ECB decryption.svg

优点:

  1. 速度比较快,因为Block之间是彼此独立的,甚至可以同时加密或者解密多个Block。
  2. 错误隔离,对于加密的数据,如果某个bit损坏,影响的只是损坏bit所在的Block,其它Block中的数据依然可以正常解密。

缺点:

  • 安全性低,对于数据完全相同的两个Block,将会产生两个完全相同的加密结果。因此在对比多次消息后,可能会被推测出部分消息,或者对某些Block中的内容进行篡改。正因为EBC的安全隐患,所以EBC很少用于正式产品中。除非是用于加密一些完全随机的数据,比如说一个Hash结果等。

CBC - Cipher-block chaining

为了避免出现两个相同的Block产生相同的加密结果,IBM在1976年发明了CBC Mode。它的基本原理是每个Block在加密之前,先与之前的一个Block的加密结果进行异或(XOR),然后再将异或的结果进行加密。对于第一个Block,因为之前没有任何加密过的Block,因此需要一个初始化向量IV(Initialization Vector),与第一个Block进行异或。

加密的流程如下:

CBC encryption.svg

因为A XOR B XOR B = A, 所以在解密的时候需要先将Block进行解密,然后将解密的结果与它之前的Block的密文进行异或,进而得到明文。当然,对于第一个密文Block解密后,依然需要与IV进行异或运算来计算第一个Block的明文,而且IV必须和加密时候使用的IV一致。流程如下:

CBC decryption.svg

优点:

  1. 即使加密前是两个完全相同的Block,在加密之后的结果也是不相同的。因为每一个Block的加密都以赖于前一个Block的加密结果。
  2. 因为在解密一个单独的Block的过程中,仅仅以赖于前一个Block的密文,所以每个Block可以独立解密。可以同时对多个block并行解密。

缺点:

  1. 速度较EBC慢,很容易理解,因为多了一步异或操作。
  2. 错误局部传递,当加密数据中的一个bit损坏,那么会造成这个损坏bit所在的block以及之后的一个block无法正常解密。错误的传递比EBC多了一个Block。
  3. 加密时,每个Block需要用到之前一个Block的加密结果,因此无法并行的加密多个Block。

PCBC - Propagating Cipher-block chaining

PCBC和CBC非常相似,唯一的不同点是CBC在加密之前需要和之前Block的密文进行异或操作,而PCBC需要和前一个Block的密文和明文都进行异或操作。类似的,PCBC也需要一个IV,用于处理第一个Block。流程如下:

PCBC encryption.svg

PCBC decryption.svg

优点:

  1. 即使加密前是两个完全相同的Block,在加密之后也不会产生相同的结果。因为每一个Block的加密,都以赖于前一个Block。
  2. 对于明文或者密文的任何一点改动,将影响到后续所有的Block的加密或者解密的结果。

缺点:

  1. 速度比CBC更慢,因为又多进行了一次异或操作。
  2. 错误传递性较强,如果一个bit损坏,将会导致所有后续的所有Block无法解密。
  3. 无论加密还是解密,都需要用到前一个Block的加密或者解密的结果,因此无法同时加密或者解密多个Block。

关于IV

CBC和PCBC都需要使用IV来加密第一个Block。因为这两种Mode在加密一个Block的时候都需要使用前一个Block的信息。而对于第一个Block,之前没有任何信息,所以需要使用一个IV来“伪造”一个Block。

  • 因为需要“伪造Block”,那么IV的长度需要与Block Size相一致。
  • IV需要是一组随机的Byte。
  • IV不需要进行保密,可以和加密信息一起提供给解密方。

使用方法

在Java中,使用各种Mode的方法基本都是一样的。一个主要的区别是EBC不需要IV,而其它两种Mode需要使用IV对Cipher进行初始化。下面是一个使用CBC进行加密的例子。

public class CBCDemo {
    public static byte[] encrypt(byte[] data, Key key, byte[] iv) {
        try {
            IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            System.out.println(cipher.getBlockSize());
            cipher.init(Cipher.ENCRYPT_MODE, key, ivParameterSpec);
            byte[] cipherData = cipher.doFinal(data);
            return cipherData;
        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | BadPaddingException | IllegalBlockSizeException ex) {
            throw new RuntimeException(ex);
        }
    }

    public static void main(String[] args) throws Exception {
        /**
         * 生成AES Key
         */
        byte[] rawKey = { 33, -52, -105, 76, -47, 123, 109, 119, 5, 2, 124, 112, -116, 3, -120, -126 };
        SecretKey key = new SecretKeySpec(rawKey, "AES");

        /**
         * 构造IV
         */
        byte[] iv = { -26, -29, -38, -70, 127, -111, 47, -98, -119, 78, -30, -8, 6, 65, -100, -93 };
        byte[] data = "Hello CBC".getBytes("UTF-8");

        /**
         * 加密
         */
        byte[] cipherData = encrypt(data, key, iv);
        System.out.println(DatatypeConverter.printBase64Binary(cipherData));
    }
}

注意,这段代码中的IV是我指定的,如果在初始化Cipher的时候不指定IV,那么Cipher的实现类将会随机生成一个IV,为了能够解密,需要把Cipher生成的IV记录下来。可以通过调用Cipher.getIV()来获取当前Cipher的IV。

对于解密,大致过程和加密是一样的,只是在初始化Cipher的时候把状态换成Cipher.DECRYPT_MODE即可。

本文图片来自于维基百科

参考资料:

  • 《Java Cryptography》—— Jonathan B. Knudsen
  • 维基百科

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 加密与解密的艺术》—— 梁栋

Thursday, January 16, 2014

Java Cryptography - KeySpec & KeyFactory

上篇文章中我们说道:在进行加密/解密的过程中,我们需要使用Key。但是Key接口没有提供用于获取Key中属性的方法。如果想要获取Key中的属性,需要通过KeySpec。

Key和KeySpec之间的转换需要借助于KeyFactory来完成

KeySpec

KeySpec是一个接口,同时也代表了一系列类——Key Specification。前面说过,Key接口是Key的不透明表现形式(Opaque Representation);与之相对,KeySpec是Key的透明表现形式(Transparent Representation),可以从Key Specification中得到一些Key内部的属性。

对于每种加密算法,有各自的Key Specification实现,里面包含了对应算法的Key所需要的信息。比如RSA Public Key的Key Specification(RSAPublicKeySpec),包含了modulus和publicExponent属性;而DSA Public Key的Key Specification(DSAPublicKeySpec),包含了DSA算法中需要的p, q, g, y属性。

同时我们也可以看到在KeySpec接口中,没有包含任何方法或者属性。这个接口仅仅用于标识对象的类型,并不提供实际的功能。

注意:一种加密算法的Key,可能会对应多种KeySpec。比如RSA Public Key就对应了两种KeySpec:RSAPublicKeySpec、X509EncodedKeySpec。那么,这两种KeySpec有什么不同呢?

KeySpec的分类

按照KeySpec对数据的组织形式不同,我们可以将KeySpec大致分为两类:

  • 参数形式:将key中的数据组织成不同的参数,保存在属性中。比如我们之前所说,我想要获取RSA Public Key的modulus信息,就可以使用RSAPublicKeySpec,通过它的getModulus方法来获得modulus信息。当然,也可以通过setModulus方法来通来设置KeySpec的modulus属性。
  • 编码形式:将key中的数据编码后(以下称之为Encoded Key)保存在属性中。有点类似于Key接口中的getEncoded方法。但是,还记得吗?通过Key接口,只能获取Encoded Key,但是,没有办法利用Encoded Key重新构造Key。利用编码形式的KeySpec就可以实现这一点。比如X509EncodedKeySpec,我们可以在构造方法中传入X509编码Encoded Key,由此来构造一个X509EncodedKeySpec。当然,也可以使用getEncoded方法,获取X509编码的Key。

SecretKeySepc

对称加密算法的Key相对比较简单,它们使用的都是SecretKey,其内部存储的都是一个原始的byte数组。因此,尽管算法不同,但是Key Specification中的数据的表现形式都是一样的。因此Java为对称加密算法提供了通用的Key Specification——SecretKeySpec。

我们可以通过传入原始的Encoded Key以及算法名称来构造一个SecretKeySpec。如果想要为其它的算法生成SecretKeySpec,只需要将调用构造方法时传入的算法名称改成需要的即可。但是这里有一点需要注意一下:输入的Encoded Key必须要符合特定算法要求的Key Size,否则当Key在使用的时候会抛出异常。

当然,针对某些对称加密算法,Java也提供了更加具有针对性的Key Specification。比如DES算法。我们可以使用通用的SecretKeySpec类来构建DES Key。同时,Java也提供了DESKeySpec类,来单独的完成DES Key Specification的相关操作。通过源码可以看到,DESKeySpec类中加入了一些对于Key的验证的逻辑。

KeyFactory & SecretKeyFactory

除了SecretKeySpec能够直接被当作Key使用以外,大多数的KeySpec都不能被当作Key直接被用于加密解密。为了在Key和KeySpec之间转换,我们需要利用KeyFactory和SecretKeyFactory.

KeyFactory & SecretKeyFactory的作用

  1. 利用Key构造对应的KeySpec
  2. 利用KeySpec构造对应的Key

KeyFactory和SecretKeyFactory有什么区别呢?没错,你猜得对。KeyFactory是作用于非对称密钥(Asymmetric Key)的,SecretKeyFactory是作用于对称密钥(Symmetric Key)的。我一直觉得在JCA中的Generator和Factory的命名比较奇怪:作用于对称密钥的是KeyGenerator & SecretKeyFactory,作用于非对称密钥的是KeyPairGenerator & KeyFactory。注意到了么?Generator和Factory的前缀正好是对调的。不知道Sun当时为什么要这样命名,是否是有什么历史原因,我没有深究。

因为KeyFactory和SecretKeyFactory在使用上基本是一样的,通过它们暴露的接口也可以发现,基本所有的方法签名都是一致的。因此,下面虽然用的是KeyFactory,但是对于SecretKeyFactory同样适用。只需要记住,在对称加密算法的时候使用SecretKeyFactory;在非对称加密算法中使用KeyFactory即可。

KeyFactory使用方法

KeyFactory是Engine类,每当我意识到某个类是Engine类的时候,我总是很高兴,因为我基本不需要看文档就知道大概的用法了。

  1. 调用getInstace方法,从JCA中请求KeyFactory实例
  2. 调用KeyFactory中提供的方法,从而实现Key和KeySpec之间的转换。
Key -> KeySpec

把Key转换为KeySpec需要解决两个问题,也就是说KeyFactory需要解决这两个问题:

  1. 每种算法的Key和KeySpec的构成都是有区别的,所以不同算法的Key需要使用不同的KeyFactorySpi实现;

    可以通过在getInstace的时候,传入对应的算法名称解决

  2. 一个Key可能会对应多种KeySpec(还记得KeySpec的数据组织形式么?),因此我们在通过KeyFactory获取KeySpec的时候,需要让KeyFactory知道我们需要的是哪一种KeySpec。

    在获取KeySpec的时候,通过传入KeySpec的类型来告知KeyFactory我们需要哪种类型的KeySpec

下面是使用SecretKeyFactory将SecretKey转换成KeySpec的例子:

public static DESKeySpec getDESKeySpec(SecretKey key) {
    try {
        //获取实现了DES算法的SecretKeyFactory
        SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("DES");
        //获取DESKeySpec
        DESKeySpec keySpec = (DESKeySpec)keyFactory.getKeySpec(key, DESKeySpec.class);
        return keySpec;
    } catch (NoSuchAlgorithmException | InvalidKeySpecException ex) {
        throw new RuntimeException(ex);
    }
}
   

我们再来看一个KeyFacotry的例子:

public static KeySpec getRSAPrivateKeySpec(Key key) {
    try {
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");

        //获取参数形式的KeySpec
        RSAPrivateKeySpec parameterKeySpec = keyFactory.getKeySpec(key, RSAPrivateKeySpec.class);

        //获取编码形式的KeySpec
        PKCS8EncodedKeySpec encodedKeySpec = keyFactory.getKeySpec(key, PKCS8EncodedKeySpec.class);

        //根据需要返回特定的KeySpec
        return encodedKeySpec;
        //return parameterKeySpec;
    } catch (NoSuchAlgorithmException | InvalidKeySpecException ex) {
        throw new RuntimeException(ex);
    }
}
   
KeySpec -> Key

同样,这个转换也是需要由KeyFactory来完成。因为一个KeySpec只能对应于一种Key,所以将KeySpec转换成Key的时候,不再需要传入目标Key的类型了。只需要调用相应的方法,传入KeySpec,就可以得到对应的Key。

  • SecretKeyFactory中调用generateSecret方法。
  • KeyFactory中调用generatePublic(公钥)或者generatePrivate(私钥)。

下面的代码将DESKeySpec转换成SecretKey:

public static SecretKey generateDESSecretKey(DESKeySpec keySpec) {
    try {
        SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("DES");
        SecretKey key = keyFactory.generateSecret(keySpec);
        return key;
    } catch (NoSuchAlgorithmException ex) {
        throw new RuntimeException(ex);
    } catch (InvalidKeySpecException ex) {
        throw new RuntimeException(ex);
    }
}
   

同样,在给出一个转换RSAKeyPrivateKeySpec到RSAPrivateKey的例子:

public static RSAPrivateKeySpec getRSAPrivateKeySpec(Key key) {
    try {
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        RSAPrivateKeySpec keySpec = keyFactory.getKeySpec(key, RSAPrivateKeySpec.class);
        return keySpec;
    } catch (NoSuchAlgorithmException ex) {
        throw new RuntimeException(ex);
    } catch (InvalidKeySpecException ex) {
        throw new RuntimeException(ex);
    }
}
   

SecretKeySpec & SecretKey的特殊性

SecretKeySpec和SecretKey是Java默认实现的对称加密算法的通用Key。注意,他们之间不能通过SecretKeyFactory进行转换。其实想想,也无需使用SecretKeyFactory,它们的转换方法如下:

  1. 如果要把SecretKey转换成SecretKeySpec,可以直接通过SecretKey.getEncoded方法获取encoded key,然后在构造SecretKeySpec的时候,直接通过构造方法传入encoded key即可;
  2. 如果要把SecretKeySpec转换成SecretKey则更加简单,完全不需要额外的操作,因为SecretKeySpec已经继承了SecretKey,也就是说SecretKeySpec就是SecretKey,可以直接使用。

最后,给出一个Key与KeySpec的对应关系表

Symmetric Key

Algorithm Key KeySpce
DES DESKey DESKeySpec
Symmetric SecretKey SecretKeySpec

Asymmetric Key

Algorithm Key KeySpce
DiffieHellman DHPublicKey DHPublicKeySpec
X509EncodedKeySpec
DHPrivateKey DHPrivateKeySpec
PKCS8EncodedKeySpec
DSA DSAPublicKey DSAPublicKeySpec
X509EncodedKeySpec
DSAPrivateKey DSAPrivateKeySpec
PKCS8EncodedKeySpec
RSA RSAPublicKey RSAPublicKeySpec
X509EncodedKeySpec
RSAPrivateKey RSAPrivateKeySpec
RSAPrivateCrtKeySpec
PKCS8EncodedKeySpec

总结

Key在加密/解密过程中是不可或缺的,没有Key一切都无从谈起。

Key的生成需要使用Generator类:KeyGenerator 和 KeyPairGenerator

想要获取Key中的属性,需要将Key转换为KeySpec,然后,从KeySpec中获得需要的属性

想要利用已有的Key的属性重建Key,也需要先去构造KeySpec,并且设置好Key的属性,然后,才能将KeySpec转换为Key

SecretKey是所有对称加密算法通用的Key类型。同样,SecretKeySpec也是所有对称加密算法通用的KeySpec类型

Key和KeySpec之间的转换需要通过Factory来完成(SecretKeyFactory, KeyFactory)

SecretKey和SecretKeySpec之间的转换不能使用KeyFactory

Wednesday, January 8, 2014

Java Cryptography - Key

无论是哪一种加密算法,Key(密钥)都是不可或缺的。根据加密算法的不同,相应的key也是不一样的。在进行加密/解密的过程中,我们可能会涉及到这几个问题:Key的生成;Key的保存;根据保存的数据重新构造Key。下面将会对这几个问题一一讲解。

Key的分类

首先根据加密算法种类的不同,key可以分成两大类:
  1. SecretKey-对称密钥:对应于对称加密算法(Symmetric algorithm),即加密密钥和解密密钥使用的是同一个密钥,代表算法有DES, AES 等。
  2. KeyPair-非对称密钥;对应于非对称加密算法(Asymmetric algorithm),即分为Public Key(公钥)和Private Key(私钥)。使用Public Key加密的信息,只有用Private Key才能够解密;反之,使用Private Key加密的信息,也只有用对应的Public Key才能解密。代表算法有DSA, RSA 等。

Key的生成

根据Key的种类不同,生成方式也不一样。我们主要使用两个类来进行Key的生成:
  • KeyGenerator类,用于生成SecretKey
  • KeyPairGenerator类,用于生成KeyPair

KeyGenerator

使用KeyGenerator来生成SecretKey非常简单。首先,这是一个Engine类。好了那么我们就知道了它大概的用法:
  1. 调用静态的getInstance方法,通过Algorithm和Provider获取一个KeyGenerator的实例
  2. 调用init方法对KeyGenerator的实例进行初始化:主要是指定一个Key Size和一个可选的随机数生成器(RNG),如果没有传入RNG,那么KeyGenerator将会使用默认的RNG——SecureRandom
  3. 调用generateKey方法来生成Secretkey
下面是生成DES Key的示例代码:
public static SecretKey generateDESKey() {
    try {
            KeyGenerator kg = KeyGenerator.getInstance("DES");
            /**
             * 注意在SunJCE的默认实现中,要求DES Key Size必须是56,
             * 此处如果传入其它数值,将会抛出InvalidParameterException("Wrong keysize: must be equal to 56")
             * 详情请参见com.sun.crypto.provider.DESKeyGenerator.engineInit() 方法
             */
            kg.init(56);
            SecretKey key = kg.generateKey();
            return key;
    } catch (NoSuchAlgorithmException ex) {
            throw new RuntimeException(ex);
    }
}
    
每种加密算法对Key都有不同的要求,尤其是Key Size,根据算法的不同,需要不同的Key Size。下表来自于Java官方文档,里面列出了JDK默认实现的加密算法所需要的Key Size,以及默认值:
Algorithm Name Default Keysize Restrictions/Comments
AES 128 Keysize must be equal to 128, 192, or 256.
ARCFOUR (RC4) 128 Keysize must range between 40 and 1024 (inclusive).
Blowfish 128 Keysize must be a multiple of 8, ranging from 32 to 448 (inclusive).
DES 56 Keysize must be equal to 56.
DESede (Triple DES) 168 Keysize must be equal to 112 or 168.
A keysize of 112 will generate a Triple DES key with 2 intermediate keys, and a keysize of 168 will generate a Triple DES key with 3 intermediate keys.
Due to the "Meet-In-The-Middle" problem, even though 112 or 168 bits of key material are used, the effective keysize is 80 or 112 bits respectively.
HmacMD5 512 No keysize restriction.
HmacSHA1 512 No keysize restriction.
HmacSHA256 256 No keysize restriction.
HmacSHA384 384 No keysize restriction.
HmacSHA512 512 No keysize restriction.
RC2 128 Keysize must range between 40 and 1024 (inclusive).

KeyPairGenerator

在说KeyPairGenerator之前,有必要先说一下KeyPair类。
KeyPair类没有什么实际的功能,其实它的唯一作用就是创建了一个类,用于组织Public Key和Private Key。对应的key保存在这个类中对应的属性里面(publicKey & privateKey)。我们可以通过调用getPublic和getPrivate方法获取这相应的Key。
KeyPairGenerator的使用方法和KeyGenerator基本一样,因为它也是一个Engine类:
  1. 调用静态的getInstance方法,通过Algorithm和Provider获取一个KeyPairGenerator的实例
  2. 调用initialize方法对KeyPairGenerator的实例进行初始化:主要是指定一个Key Size和一个可选的随机数生成器(RNG),如果没有传入RNG,那么KeyPairGenerator将会使用默认的RNG——SecureRandom
  3. 调用genKeyPair方法或者generateKeyPair方法来生成KeyPair。(genKeyPair方法内部直接调用了generateKeyPair方法)
下面是使用KeyPairGenerator生成RSA KeyPair的示例代码:
public static KeyPair generateRSAKeyPair() {
    try {
            KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
            kpg.initialize(2048);
            KeyPair keyPair = kpg.genKeyPair();
            return keyPair;
    } catch (NoSuchAlgorithmException ex) {
            throw new RuntimeException(ex);
    }
}
        
对于KeyPairGenerator来说,每种算法对于Key Size也有不同的要求。而且,即使是相同的算法,在不同的Provider实现中要求的Key Size也是不一样的。具体信息可以查看Java的官方文档(http://docs.oracle.com/javase/7/docs/technotes/guides/security/SunProviders.html)。
比如上面所用的RSA算法,在Java的安装文件中有两个Provider都提供了RSA算法的实现:SunRSASign Provider和SunMSCAPI Provider。在SunRSASign中要求Key Size在[512 bits, 65536 bits]之间;而在SumMSCAPI中要求RSA的Key Size在 [512 bits, 16384 bits]之间。所以,如果不清楚算法实现对Key Size的要求,最好的方法就是查看上面提到的Java官方文档,或者通过查看源码,自己发现要求的KeySize。

Key接口

在Java中,Key接口是所有具体Key的接口。无论是Symmetric Key还是Asymmetric Key都需要实现Key接口。Key接口是Key的一种不透明的表现形式(Opaque Representation),与之相对的是透明的表现形式KeySpec(Transparent Representation)。关于KeySpec的相关信息这里暂时不说。Key接口中只提供了非常有限的三个方法访问Key中封装的数据:
/**
 * 返回能够使用这个Key的加密算法。
 * 比如生成的是AES Key,那么这个方法将返回"AES"。
 */
public String getAlgorithm();

/**
 * 返回Key的编码格式。
 * Key的种类不同,保存的信息也不一样。
 * 有的Key比较简单,只是保存了一些字节比如AES和DES,
 * 调用这些Key的getFormat方法将返回RAW,意思是原本的字节信息;
 * 另外有些key包含的信息就比较复杂了,
 * 比如RSA的Public Key,里面包含了modulus以及publicExponent,
 * 因此就需要把这些信息按照一定的编码方式来进行编码。
 * 通过调用getFormat这个方法,就会返回使用的编码方式。
 */
public String getFormat();

/**
 * 这个方法根据getFormat方法返回的编码方式,对Key进行编码,并返回编码后的结果。
 * 这个方法可以用于将程序中生成的Key导出为byte 数组的形式。
 * 利用导出的byte数组,我们可以完成许多功能。
 * 比如将byte数组保存在文件中,这样就将Key持久化到了介质上;
 * 或者将byte数组通过网络传输给另一个太机器,实现Key的共享等等。
 */
public byte[] getEncoded();
    
介绍了Key接口中的方法,我们就可以明白为什么说“Key接口是Key的不透明表现形式(Opaque Representation)”了。因为通过Key接口我们不能得到Key中的各项信息,能够得到的仅仅是经过编码后的Key。比如我无法从Key中得到RSA Public Key里面的modulus信息。
下面是将key导出到文件的一个简单例子:
public static void saveKey(Key key, Path path) {
    //获取编码后的key
    byte[] keyData = key.getEncoded();

    //将编码后的key转换成base64的字符串,方便在文件中查看
    String keyStr = DatatypeConverter.printBase64Binary(keyData);

    try {
            Files.write(path, keyStr.getBytes("UTF-8"), StandardOpenOption.WRITE);
    } catch (IOException ex) {
            throw new RuntimeException(ex);
    }
}
 

Key接口的局限

我们可以看到在Key接口中只提供了getEncoded方法,并没有对应的set方法。那么这样就有个问题了,我将Key导出成byte 数组之后,如何通过这个byte 数组还原Key呢?
另外,如果我就是想要获取Key中的信息呢?比如我想看看我的RSA Public Key中modulus到底是什么,应该怎么办呢?
想要回答以上两个问题,仅仅使用Key接口是做不到的。需要用到KeySpec和KeyFactory。

Thursday, January 2, 2014

JCA Engine - MessageDigest

MessageDigest 消息摘要简介
消息摘要,Hash,杂凑法说的其实是同一件事情。通过消息摘要算法,可以将一个任意长度的信息作为输入,同时输出固定长度的摘要信息。对于消息摘要算法具有几个特性:
  • 对于相同的数据,相同的消息摘要算法,无论进行多少次消息摘要,得到的结果都是一样的。
  • 原始数据中一个很小的改动,将产生完全不一样的消息摘要结果。
  • 很难通过一个消息摘要的结果反推出原始的数据。
  • 很难通过计算的方式得到两个消息具有相同的消息摘要。

针对这些特性,消息摘要算法主要有下面几种应用场景:
  • 验证消息是否被篡改。比如从网络上下载了一个文件,我们可以通过比对这个文件的摘要信息来确定这个文件在传输过程中是否被有意或者无意的篡改过。因为如果数据被篡改了,那么通过摘要算法计算出来的消息摘要一定和原来不一样了。这一点也被广泛应用在数字证书上。
  • 密码验证。很多系统需要对用户进行管理,用户必须通过自己的用户名和密码来登录系统,而后才能进行操作。显然,将用户的密码存在数据库中是不安全的,这样当系统被黑以后,所有用户的密码信息就会泄漏。因此目前比较流行的做法是这样的,系统不会存储用户的密码,而是在用户注册的时候只保存一个用户密码的摘要。在用户登录的时候,先将用户输入的密码进行消息摘要,通过和数据库中保存的摘要进行比对,来验证密码是否正确。

消息摘要算法
目前主流的消息摘要算法可以分为三个系列:MD系列,SHA系列,MAC系列。
  • MD(Message Digest)系列包括MD2, MD3, MD4, MD5. 他们的摘要长度都是128 bits,也就是16个字节,可以映射为32个16进制位。你可能之前见到过使用MD5消息摘要的结果,都是长度为32的16进制字符串。
  • SHA(Secure Hash Algorithm)系列是从MD4发展而来,它包括包括SHA-1(摘要长度160bits-20Bytes), SHA-224, SHA-256, SHA-384, SHA-512,通常将后四种算法并称为SHA-2,并且摘要长度和算法名称保持一致,如SHA-256,表示摘要长度为256 bits.
  • MAC(Message Authentication Code)系列,是在原有的MD和SHA算法的基础上加入了密钥的消息摘要算法。也就是说对同一段数据,只有使用相同的密钥,才能保证摘要结果是一致的。这样可以避免一些恶意的修改,因为消息摘要算法是公开的,黑客可能会将数据和消息摘要一起进行修改。如果使用了MAC,只要密钥没有被黑客获取到,那么黑客就无法重新计算消息摘要,进而也就可以保证消息不被篡改。MAC也被称作Hmac(keyed-Hash Message Authentication Code),主要包含的算法有:HmacMD2, HmacMD4, HmacMD5, HmacSHA1, HmacSHA224, HmacSHA256, HmacSHA384, HmacSHA512,摘要长度与实际的算法的摘要长度相同。

使用方法
Java中提供了大部分的算法实现。为了进行消息摘要,我们需要使用MessageDigest这个JCA Engine类。同样,因为MessageDigest是Engine类,那么我们就可以大致推测出它的使用方法:
  1. 通过getInstance 方法获取实例,为什么要这样做请参见本系列的概述。
  2. 根据自己的需求,调用任何一个update方法的重载,将需要进行消息摘要的数据传入MessageDigest对象中。
  3. 调用digest方法得到消息摘要的结果,如果需要进行消息摘要的数据已经全部加载到了内存,那么也可以跳过第二步,直接在调用digest方法的时候一次性的传入数据。
  4. 得到的消息摘要是byte数组,为了便于存储,需要进行一些格式的转换,一般采用16进制的表示方法,一个byte对应于2个16进制位。
注意:调用digest方法之后,MessageDigest对象将会被reset。整个消息摘要的过程即宣布结束,也就是说之后如果再通过update方法传入的数据将作为新一轮消息摘要的数据,而不会附加到调用digest方法之前提供的数据后面。例:
public class SHA256Digest {
    public static void main(String[] args) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            String message = "The message cannot be tampered.";
            byte[] data = message.getBytes("UTF-8");
            byte[] signature = md.digest(message.getBytes("UTF-8"));
            System.out.println(DatatypeConverter.printHexBinary(signature));
        } catch (NoSuchAlgorithmException | UnsupportedEncodingException ex) {
            throw new RuntimeException(ex);
        }
    }
}
    
执行程序将得到一个64位的16进制字符串:
        E08612E56A73BD9EF587A0C99322797228B0A62588BC956DBFA4A891AD4C1FE1
    
对于MD系列和SHA系列的消息摘要算法的使用方法都是类似的,唯一的不同就是获取MessageDigest实例的时候传入的Algorithm,Java官方文档中包括了所有系统自带的算法的Algorithm 名称。

同时这里顺带提一句,DatatypeConverter中提供了许多静态方法,用于将byte数组转换为各种格式的字符串(Hex, Base64...),同时也提供了从各种字符串形式转换回byte数组的方法。在加密解密的时候使用这个类会非常方便,否则我们就要自己去编写byte数组到16进制字符串的转换方法法。

想要知道如何自己写代码将字节数组转换成16进制的表现形式,请查看DataTypeCovnerter.printHexBinary方法的源码。
对于MAC系列的消息摘要算法使用起来比较特殊,因为里面涉及到了key,这里暂时不做详细的解释,仅仅给出例子:
public static void main(String[] args) {
    try {
        //随机生成一个SecretKey
        KeyGenerator kg = KeyGenerator.getInstance("HmacSHA256");
        SecretKey key = kg.generateKey();

        Mac mac = Mac.getInstance("HmacSHA512");    //从JCA中请求实现了HmacSHA256算法的实例
        mac.init(key);    //使用生成好的SecretKey对mac进行初始化

        byte[] digest = mac.doFinal("Hello Kevin!".getBytes("UTF-8"));    //注意这里调用的是doFinal方法,而不是digest方法
        System.out.println(DatatypeConverter.printHexBinary(digest));
    } catch (NoSuchAlgorithmException | InvalidKeyException | UnsupportedEncodingException ex) {
        throw new RuntimeException(ex);
    }
}
    
多次执行上述代码,将得到不同的结果:
  • 第一次:
    CEBCFBE5D7E175C0EC7FF006FCE4B43DAF4D711360C7BFDC85EDBC792D0F21687E6DA58516339B8B8C689479AE02BB5F45B740B61054348165F119A8016C502D
  • 第二次:
    EB2E3E53F6231275296E90118E000901F71E278629D9C917C059D8D625745F3D0E7363CC0E686F189633D9C322CD7CE0E3F13C9891BB28F59FDA0B4DDDD2578E
应该能够理解,为什么每次生成的消息摘要都不一样。因为在上述代码中,key是随机生成的,所以每次key都不一样,因此计算出来的消息摘要也不同。

关于安全性的补充
对于MD系列算法,已经被证实是不安全的。2004年被山东大学的王小云教授带领的团队破解。利用王小云教授的算法,在数小时之内就可以找到MD5碰撞。 详细的新闻报道请看这里

Thursday, December 26, 2013

JCA Engine - SecureRandom

SecureRandom 是JCA中最简单的Engine类。它的作用是生成随机数。很多和Java Security相关的操作,都离不开随机数。比如,KeyGenerator需要随机数生成器来随机生成key;Cipher可能需要随机数生成来生成IV(Initialization Vector)。

java.security.SecureRandom vs java.util.Random

注意:为了方便,下文使用SecurityRandom代替java.security.SecureRandom;使用Random代替java.util.Random.

说道SecureRandom就不得不说我们比较熟悉的Random类。这两个类都是随机数生成器,那么它们之间有什么区别呢?Java API为什么要提供两个完成相同功能的类呢?

在Random的API文档中有这样一段话:

Instances of java.util.Random are not cryptographically secure. Consider instead using SecureRandom to get a cryptographically secure pseudo-random number generator for use by security-sensitive applications.

大致的意思是说,Random的实例是不安全的。如果想要在安全级别比较高的系统中使用随机数生成器,请考虑使用SecureRandom.

也就是说Random不安全。我在读到这里的时候就产生了一个疑问:随机数生成器有什么不安全的呢?它的“不安全表现在什么地方呢”?经过一番研究,了解到这里所谓的不安全,实际上是说,生成的随机序列是可以被预测的。也就是说,我知道了几个生成的随机数后,就可以推测出接下来将会生成的随机数是什么。

发现有一点可怕了吧… 设想这样一种情况:用户登录系统之后,系统会生成一个随机数作为用户操作系统的token,并提供给用户。这个用户每次操作的时候,都需要提供这个token。此时,如果这个随机数生成器是不安全的,那么黑客则可以预测出来生成的随机数序列,进而预测出将会分配给用户的token。这样一来,也就可以未经过用户允许的情况下,操作用户的数据。

关于如何预测Random生成的随机数,可以参考这篇文章。我从这篇文章中摘录了一些代码,用来演示破解随机数生成器的过程。

这段代码会连续输出10个随机数:
Random random = new Random();
for (int i=0;i<10;i++) {
    int randomValue = random.nextInt();
    System.out.println(String.format("v%d: %s", i + 1, randomValue));
}
本次运行结果是这样的:
v1: -1116240189
v2: 1675702140
v3: -227598907
v4: -452268948
v5: 2006095225
v6: 584972179
v7: -1039927173
v8: -656175616
v9: -704109609
v10: 11273124
作为黑客只需要知道任意两个连续的随机数,就可以破解这个随机数生成器了。为了简单,我假设黑客知道了v1和v2:
long multiplier = 0x5DEECE66DL;
long addend = 0xBL;
long mask = (1L << 48) - 1;

int v1 = -1116240189;
int v2 = 1675702140;

for (int i = 0; i < 65536; i++) {
    long seed = (((long) v1) << 16) + i;
    if (((seed * multiplier + addend) & mask) >>> 16 == v2) {
        System.out.println("Seed found: " + seed);
        break;
    }
}
黑客通过执行这段代码,将得到如下结果:
Seed found: -73153916970526
看到了吗,我们反向计算出来了这个随机数生成器的seed。已经接近成功了不是吗?剩下的我们只要初始化一个Random对象,并且设置它的seed为-73153916970526,就可以得到和系统中一样的随机数序列:
long multiplier = 0x5DEECE66DL;
long mask = (1L << 48) - 1;
/*
这里之所以要对seed的值处理一下,是因为我们通过计算得到的seed,是经过了Random类处理过的,并不是系统创建Random时传入的值,因此这个seed是不能通过Random的构造方法传入的。请参见Random.initialScramble这个方法。这里只是做了这个方法的反向运算,使其最后设置的seed符合我们计算出来的值。
*/
long seedValue = -73153916970526L ^ multiplier;
Random random = new Random(seedValue);
for (int i=0;i<10;i++) {
    int predictValue = random.nextInt();
    System.out.println(String.format("v%d: %s", i + 1, predictValue));
}
运行程序,得到如下结果:
v1: 1675702140
v2: -227598907
v3: -452268948
v4: 2006095225
v5: 584972179
v6: -1039927173
v7: -656175616
v8: -704109609
v9: 11273124
v10: -1299046198
咱们可以对比着看一下,这样更明显些:
系统输出 黑客计算
v1: -1116240189 --
v2: 1675702140 v1: 1675702140
v3: -227598907 v2: -227598907
v4: -452268948 v3: -452268948
v5: 2006095225 v4: 2006095225
v6: 584972179 v5: 584972179
v7: -1039927173 v6: -1039927173
v8: -656175616 v7: -656175616
v9: -704109609 v8: -704109609
v10: 11273124 v9: 11273124
-- v10: -1299046198
看到了吗,黑客通过两个连续的随机值,计算出了之后所有的随机序列。

这就是我们为什么要在安全要求比较高的系统中使用SecureRandom的原因。

那么这里就又有一个疑问了:既然SecureRandom要比Random安全,我们不如把所有使用Random的代码都换成SecureRandom,从而使系统更加安全,不是吗?当然不是啦!!

要知道“有一利,必有一弊”。这里是“以性能换安全”。也就是说,SecureRandom的性能要比Random的性能差,因此在一些与安全无关的情况下,还是建议使用Random,来达到更高的效率。关于为什么会有效率问题,请参考这篇文章

使用方法

好了,是时候说说怎么使用SecureRandom了。其实使用SecureRandom类特别简单,首先说明,这是JCA中的一个Engine类(参考本类主题的Overview)。那么提到Engine类,我们就知道怎么样获得它的实例了:

SecureRandom.getInstance(algorithm);

通过传入一个生成随机数的算法名称,向JCA请求一个实现了这个算法的随机数生成器。默认的随机数生成器算法是SHA1PRNG;也可以选择性地传入一个provider,如果没有传入provider,那么JCA会根据查找策略,从注册到JCA的Provider中查找实现了这个算法的SPI。

接下来可以选择性的调用setSeed方法来设置这个随机数生成器的seed。如果不设置seed,那么SecureRandom的实现类将会完全随机产生随机序列。

如果把两个SecureRandom设置为相同的seed,那么这两个SecureRandom将产生相同的随机序列。

可以随时再次调用setSeed方法,来更新seed。但是请注意,重新设置seed的时候,并不是简单的用新的seed替换原有seed,而是将新的seed作为原有seed的补充。这样做的好处是,当使用一个值多次调用setSeed后,得到的依然是完全随机的序列,不会产生于之前相同的序列。请看下面的演示:
random1.setSeed(160L);
System.out.println(String.format("Random1: %d", random1.nextInt()));
random1.setSeed(160L);
System.out.println(String.format("Random1: %d", random1.nextInt()));
random2.setSeed(160L);
System.out.println(String.format("Random2: %d", random2.nextInt()));
以上程序中random1 和 random2 分别是两个刚刚实例化的SecureRandom对象。注意,这两个random对象都没有设置过seed。执行以上程序,将得到下面的结果:
Random1: 718216388
Random1: 862077552
Random2: 718216388
由此可见,在random1上,第二次将seed设置成160也无法使SecureRandom变回第一次设置完seed时候的状态了,因此证明了重新设置seed不会替换原有的seed,而是作为原有seed的补充。

而random2是第一次设置seed,而且seed与random1 的第一个seed相同,所以能够生成与random1相同的随机序列。

seed设置完毕后就可以调用SecureRandom中的nextXxx方法来获得随机数,这里API中讲解的非常详细,这里不再赘述。

其中有一个方法使用起来比较方便,而且我之前没有注意到。在这里提一下:

synchronized public void nextBytes(byte[] bytes)

通过传入一个byte数组,这个方法将会用随机的byte填充整个byte数组,这个方法用于生成IV或者Key的时候非常方便,值得记住。

Monday, December 23, 2013

JCA - Java Cryptography Architecture Overview

JCA是Java关于加密解密以及方法摘要等一系列相关操作的API。JCA本身并不包含任何实现。它只是提供了API接口,供服务提供者进行实现。另一方面JCA又进行了服务的抽象。使得在开发过程中,不需要关心服务的具体实现。比如,我想使用SHA-256算法,对一段消息进行消息摘要。我只需要向JCA发出请求:“我需要一个实现了SHA-256算法的消息摘要服务对象”。JCA会根据特定的算法寻找对应实现了这个算法的服务类,并实例化其对象,返回给开发者。

在JCA中分别有几种不同的类:Engine类,SPI类,Provider类。一下会分别解释这几种类的作用以及关系。

Engine类
Engine类是供外部使用某种特定服务的类。比如,用于消息摘要的MessageDigest类;用于生成随机数的SecureRandom类;用于加密的Cipher类等等。它并不完成具体的功能,而是将所有的操作,通过方法调用的方式,转发到它所依赖的Spi类中,并由Spi类来完成功能。

在使用Engine类的时候有一些通用的规则:
  1. Engine类无法直接通过构造方法来实例化。只能通过调用其静态的getInstance方法获得实例。调用getInstance方法时,需要一个算法名称和一个可选的Provider。 
  2. 拿到Engine类的实例之后,可能需要调用它的Init方法(根据不同的Engine类可能有不同Init方法),对这个Engine对象进行初始化。不同的Engine类,需要不同的初始化参数。 
  3. 调用特定Engine相关的一些方法,执行需要的操作。这一步差异较大,具体情况,具体分析。(doFinal, digest 等...) 

SPI(Service Provider Interface) 类
提供某种服务的特定算法实现。他们都继承了对应的XxxSpi接口。比如,采用MD5算法进行消息摘要的MD5类和采用SHA256算法进行消息摘要的SHA2类,继承了MessageDigestSpi;采用AES算法进行数据加密的AESCipher类和采用DES算法进行数据加密的DESCipher类,继承了CipherSpi。
根据上面Engine类的描述,Engine类会把实际的操作委托给SPI类来完成。

Provider类
首先要区分下Provider和Provider类。实际上可以将Provider理解为一个或一组jar包。这些jar包中包含了一些实现好的SPI类。这些jar包可能是Java安装包中自带的,也可能是某些组织为了使用一些特定的算法,而自己开发的,也可能是从网上下载的第三方jar包。无论渠道是怎样的,每一组jar包就称为一个 Provider。那么Provider类就是用于描述和初始化这个Provide的。比如告知JCE这个Provide中提供了哪些服务,以及实现了特定服务的哪些算法等。因此每一组可以被称为Provider的jar包中,都会包含一个用于描述这个Provider的Provider类。

Engine/SPI/Provider之间的关系
  • Provider类描述了Provider本身(name, version...),以及它所包含的SPI,JCA会从Provider类中获取这些信息;
  • Engine类依赖于SPI类来完成功能,每一个Engine类内部都会有一个以Spi结尾的属性。Engine类就是通过这个对象来调用Spi中的方法的;
  • 当向JCA请求一个Engine时(调用getInstace),JCA会根据请求时的参数,并根据Provider类中提供的信息,找到对应的SPI类,并实例化之。将SPI对象装配到Engine对象中返回。从而供外部调用Engine类中的方法时,进行操作转发。
Built-in Provider
在JDK的安装文件中,包含了11个内置的Provider:SunPKCS11, SUN, SunRsaSign, SunJSSE, SunJCE, SunJGSS, SunSASL, XMLDSig, SunPCSC, SunMSCAPI, SunEC。关于每种Provider中提供了什么服务,请参见Java的官方文档

这就是JCA的一个简单的描述,只有正确理解了这个通用的架构,才能更好的理解Java Security。