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

Friday, January 17, 2014

JCA - Signature 签名

我们可以通过验证消息摘要,来检测文件的完整性。但是消息摘要机制是否可以检测出有人恶意的对文件进行篡改呢?并不能完全检测出来。原因是这样的:首先消息摘要算法是公开的(因为需要用于验证消息的完整性,所以消息摘要算法必须要公开)。其次,尽管某些消息摘要算法提供了密钥机制——比如Hmac算法,但是,其密钥也是对文件接收方公开的。公开的消息摘要算法加上公开的密钥,就给了心存歹意的人修改文件,并且重新计算消息摘要的可能。举个例子:

  1. A给B发送了一个消息M1,并且附带了M1的消息摘要D1
  2. B收到消息,通过消息摘要D1验证了M1的完整性
  3. B把消息M1改成了M2,为了跟加具有欺骗性,B使用和A相同的消息摘要算法重新计算M2的消息摘要D2
  4. B把M2以及D2发送给了C,并且生成M2就是A发送给我的消息,如果不相信,你可以比对消息摘要
  5. C收到M2后,进行消息摘要,发现果然和D2是匹配的。但是实际上消息已经被B篡改了。

如何防止消息被人恶意篡改呢?其实,这里消息可以被篡改的主要原因就是算法和密钥是公开的。但是,如果算法和密钥不公开,那么收到消息的人如何对消息进行验证呢?问题似乎陷入了一个死循环中。终于,非对称加密算法帮助我们完美的解决了这一问题。

所谓非对称加密算法(Asymmetric Algorithm),实际上是指加密和解密所使用的密钥(Key)是不一样的。非对称加密算法的Key是成对出现的,分为公钥(Public Key)和私钥(Private Key)。如果使用公钥加密,则需要使用私钥解密;反之,如果使用私钥加密,则需要使用公钥解密。一般情况下私钥自己保存,公钥公开给对方。

这种利用非对称加密算法对消息进行摘要的方法叫做“签名(Signature)”

因为有了非对称加密算法,因此我们可以设计这样一个验证流程:

  1. 使用私钥对消息进行消息摘要(签名)
  2. 将消息/消息摘要(签名)/公钥,一起发送给接收方
  3. 接收方利用公钥验证消息摘要(签名)

这样一来,消息接收方可以利用公钥对消息摘要(签名)进行验证,但不可能在修改消息后重新计算签名,因为消息接收方是没有消息发送方的私钥的。

我们知道普通的消息摘要算法,无论数据有多长,它们的消息摘要都是固定长度的(MD5-128Bits, SHA1-160Bits)。但是,非对称加密算法主要是用于数据加密用的,因此它的计算结果的长度和原始数据的长度有关,也就是说原始数据越长,计算出的结果也就越长(一般会大于原始数据的长度,因为在加密之前需要进行Padding,稍后详细解释)。显然,这种加密结果并不适合用于消息摘要,因为这产生了很大的资源浪费,也降低了验证签名的效率。

为了解决这个问题,非对称加密算法通常和消息摘要算法一起使用,共同构成了签名算法。签名算法的流程基本上是这样的:

  1. 使用一种消息摘要算法(MD5/SHA...)对需要签名的消息M(不定长)进行消息摘要,得到消息摘要D(定长)。
  2. 使用非对称加密算法(DSA/RSA)对消息摘要D进行加密,并得到签名S。

也就是这样一个流程:M -> D -> S

通过将消息摘要算法和非对称加密算法进行组合使用,我们就完美的达到了计算消息签名的目的。

Java中的使用方法

在Java中需要借助Signature这个Engine类对消息进行签名和验证。

  1. 通过调用Signature.getInstance方法,向JCA请求一个Signature的实例
  2. 针对使用Signature的目的不同(签名或者验证),调用不同的初始化方法,并传入对应的Key(initSign/initVerify)。签名的时候需要传入Private Key,验证的时候需要传入Public Key
  3. 调用update方法,将待签名的数据传入Signature对象
  4. 调用sign方法对数据进行签名;或者,调用verify方法,对传入的签名进行验证。

另外,Signature这个类的对象是有状态的,状态信息保存在内部的state属性中。它有三种可能的状态:

  • UNINITIALIZED - Signature类被实例化之后,没有调用init方法之前,signature就是这个状态
  • SIGN - 调用initSign方法之后,signature的状态变成SIGN
  • VERIFY - 调用initVerify方法之后,signature的状态变成VERIFY

根据Signature状态的不同,可以进行的操作也不同:

  • UNINITIALIZED - 只能进行initSign或者initVerify
  • SIGN - 只能进行update以及sign
  • VERIFY - 只能进行update以及verify
  • 但是允许在任何时候调用init方法,这样将会重新初始化signature,并且清空之前的数据,改变Signature的状态。

签名

public static byte[] sign(byte[] data, PrivateKey key, String algorithm) {
    try {
        Signature signature = Signature.getInstance(algorithm);
        signature.initSign(key);
        signature.update(data);
        byte[] sign = signature.sign();
        return sign;
    } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException ex) {
        throw new RuntimeException(ex);
    }
}

public static String sign(String text, PrivateKey key, String algorithm) {
    try {
        byte[] data = text.getBytes("UTF-8");
        byte[] sign = sign(data, key, algorithm);
        String signHex = DatatypeConverter.printHexBinary(sign);
        return signHex;
    } catch (UnsupportedEncodingException ex) {
        throw new RuntimeException(ex);
    }
}

public static void main(String[] args) {
    String message = "Hello this is a sensitive message";
    KeyPair keyPair = generateKeyPair("RSA", 512);
    String sign = sign(message, keyPair.getPrivate(), "SHA256WithRSA");
    System.out.println("Signature: " + sign);

}
 

执行上面的程序,我的环境输出如下:

Signature: 61447BDAD564E65015CFEDE904AD2BEE7CA0B65CB83FCC09FCE6F18F16A4B54920DBDBBA5F416567F82D57D53360E142DC25DB5185C5E35BB37191DCF6168AD2
 

因为KeyPair每次都是随机生成的,所以每次运行输出都不一样。

下面我们来分析一下这个输出。目前使用的签名算法是SHA256WithRSA——先使用SHA256计算消息摘要,然后对消息摘要的结果进行RSA加密。SHA256的结果是256 Bits,也就是32 Bytes。32 Bytes如果用16进制表示法输出,应该是一个长度为64的字符串。可是为什么这个字符串的长度是128呢?原因就在RSA加密之前,其实还有一个补全(padding)的过程,这一步会将消息摘要的结果补全到一个特定的长度,之后在补全的基础上再进行加密。在Java的默认实现中,这个补全长度和key的长度是一致的。上例中是512 Bits。因此最后加密出来的结果也是512 Bits也就是64 Bytes。使用16进制表示就是长度为128的字符串。

验证

public static boolean verify(byte[] data, byte[] sign, PublicKey key, String algorithm) {
    try {
        Signature signature = Signature.getInstance(algorithm);
        signature.initVerify(key);
        signature.update(data);
        boolean verified = signature.verify(sign);
        return verified;
    } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException ex) {
        throw new RuntimeException(ex);
    }
}

public static boolean verify(String text, String signHex, PublicKey key, String algorithm) {
    try {
        byte[] data = text.getBytes("UTF-8");
        byte[] sign = DatatypeConverter.parseHexBinary(signHex);
        boolean verified = verify(data, sign, key, algorithm);
        return verified;
    } catch (UnsupportedEncodingException ex) {
        throw new RuntimeException(ex);
    }
}

public static void main(String[] args) {
    String message = "Hello this is a sensitive message";
    String sign = ""; //使用上例中计算出的签名
    PublicKey publicKey; //使用上例中对应的PublicKey
    boolean verified = verify(message, sign, publicKey, "SHA256WithRSA");
    System.out.println("Verified: " + verified);

}
 

如果一切正常,运行程序将会得到如下输出:

Verified: true
 

尝试改变message的值,将会得到:

Verified: false
 

这样我们就可以验证消息是否曾被被恶意修改过。

关于密钥

一般而言签名所需要的公钥和私钥都不是在程序中临时生成的,而是之前已经生成好,并且以特定形式保存在文件中的。想要做到这一点有很多种方法,可以通过Key Management API来实现,也可以通过获取key中的属性,自己操作文件进行保存/读取。但是,目前这里暂时不讨论具体的实现方法。

总结

Signature的使用比较简单,在了解了Key的生成之后,基本没有什么特殊的问题。以下内容比较重要:

  1. 为什么要使用Signature,它是为了解决什么问题的?
  2. 什么是非对称加密算法,它有什么特点?
  3. 对数据进行签名的过程是怎样的?

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碰撞。 详细的新闻报道请看这里

Convert signed-byte to unsigned-byte

Java中的byte类型是有符号的(-128 - 127),而C#中的byte是无符号的(0 - 255)。

在单纯使用Java或者C#的时候都不会有问题。但是如果一个程序,分为多个部分,其中涉及到Java与C#进行交互(或者是通过socket传输,或者是读取另外一种语言输出的文件等等),那么就可能会产生问题了。

比如说在C#中,对一个文件进行签名,并将签名的结果,以数字的形式输出到了文件中:
static void Main(string[] args)
{
    X509Certificate2 x509 = new X509Certificate2(@"d:\keystore\personal.p12", "********");
    RSACryptoServiceProvider privateKey = (RSACryptoServiceProvider)x509.PrivateKey;

    string message = File.ReadAllText("d:/message.txt", Encoding.UTF8);
    byte[] messageData = Encoding.UTF8.GetBytes(message);

    byte[] signature = privateKey.SignData(messageData, CryptoConfig.MapNameToOID("SHA1"));

    string signatureStr = string.Join<byte>(";", signature);
    using (StreamWriter sw = new StreamWriter("d:/message.sign", false, Encoding.UTF8))
    {
        sw.WriteLine(signatureStr);
    }
    Console.WriteLine(signatureStr);
    Console.ReadKey();
}
在message.sign文件中保存了如下内容:
39;56;101;161;49;121;247;199;187;136;209;3;231;32;103;129;47;6;171;49;100;112;39;133;228;5;123;76;241;226;238;153;34;62;229;174;127;130;72;186;129;234;162;240;98;53;64;251;115;107;160;46;8;2;164;89;250;208;116;46;112;69;11;98;137;243;158;32;109;233;152;231;246;218;35;178;72;143;123;202;210;125;169;94;20;148;67;37;14;138;230;226;225;248;74;12;74;83;240;209;131;226;165;109;1;43;112;225;248;246;51;37;50;189;184;204;39;194;176;19;122;80;142;8;251;198;109;75
那么,此时使用Java来验证签名的时候,需要调用Signature 类的verify方法,方法签名如下:
public final boolean verify(byte[] signature) throws SignatureException
可以看到,这里需要的是一个byte数组,然而Java中的byte数组是有符号的,所以我们需要将上面签名的结果转换成signed-byte array。

Unsigned-byte 转换 Signed-byte
首先我们先要将上面的每个数字存放在int变量中,原因很简单,无法直接存入byte变量,因为已经超出了signed-byte的范围了;
使用Java的强制转换,将int转换成byte,转换的结果如下:
39;56;101;-95;49;121;-9;-57;-69;-120;-47;3;-25;32;103;-127;47;6;-85;49;100;112;39;-123;-28;5;123;76;-15;-30;-18;-103;34;62;-27;-82;127;-126;72;-70;-127;-22;-94;-16;98;53;64;-5;115;107;-96;46;8;2;-92;89;-6;-48;116;46;112;69;11;98;-119;-13;-98;32;109;-23;-104;-25;-10;-38;35;-78;72;-113;123;-54;-46;125;-87;94;20;-108;67;37;14;-118;-26;-30;-31;-8;74;12;74;83;-16;-47;-125;-30;-91;109;1;43;112;-31;-8;-10;51;37;50;-67;-72;-52;39;-62;-80;19;122;80;-114;8;-5;-58;109;75
我们都知道,数据从int转换成byte的时候会丢失精度,那么上面的这个转换在Java内部是怎么进行的呢?让我们以161为例,说明一下这个过程:
  1. int在内存中占用4 bytes,那么161就是这样的:00000000 00000000 00000000 10100001
  2. byte在内存中占1 byte,因此在转换的过程中上面的4 bytes的前三个字节会被截断,变成:10100001
  3. Java中byte是有符号的,因此,第一位的1表示负数。
  4. 由于计算机内部使用的数字都是用补码表示的,因此,想要得到我们可以看懂的数值,需要将补码转换为原码,得到结果 11011111b = -95。
  5. 当我们输出这个值的时候,就会得到 -95,这个值也就是161对应的signed-byte的值。
这里很奇特不是吗?Signed-byte和Unsigned-byte的对应关系,和我们想象的不一样,最起码和我的思维不一致。我一直认为对应关系是这样的:
128 -- -0(-128)
129 -- -1
130 -- -2

255 -- -127

但实际上却是这样的:
128 -- -0(-128)
129 -- -127
130 -- -126

255 -- -1

至于为什么需要这样对应,我是没有能力解释的。可能是其中包含了一些数学原理,如果有了解的朋友,麻烦帮忙解释一下。

继续把上述验证签名的代码补充完整:
public static void main(String[] args) {
    try {
        Signature signatureEngine = Signature.getInstance("SHA1WithRSA");

        KeyStore ks = KeyStore.getInstance("pkcs12");
        ks.load(new FileInputStream("d:/keystore/personal.p12"), "**********".toCharArray());
        Certificate cert = ks.getCertificate("mykey");

        signatureEngine.initVerify(cert);
        signatureEngine.update(Files.readAllBytes(Paths.get(URI.create("file:///d:/message.txt"))));

        byte[] signature = readSignature(Paths.get(URI.create("file:///d:/message.sign")));

        boolean verified = signatureEngine.verify(signature);
        System.out.println(verified ? "Successful" : "Failed");
    } catch (NoSuchAlgorithmException | KeyStoreException | CertificateException | IOException | InvalidKeyException | SignatureException ex) {
        throw new RuntimeException(ex);
    }
}

private static byte[] readSignature(Path signaturePath) {
    try {
        String signatureStr = Files
                .readAllLines(signaturePath, Charset.forName("UTF-8"))
                .get(0).trim();
        String[] signatureArr = signatureStr.split(";");
        byte[] signature = new byte[signatureArr.length];
        for (int i = 0; i < signatureArr.length; i++) {
            signature[i] = (byte) Integer.parseInt(signatureArr[i]);
        }
        return signature;
    } catch (Exception ex) {
        throw new RuntimeException(ex);
    }
}

Signed-byte 转换Unsigned-byte
可想而知,这里Java的自动转换是帮不了我们的。因为byte本身是有符号的,而且int的精度要比byte大,所以如果直接将byte转换成Int,并不会丢失精度,得到的只是一个精度更大的有符号的int值。比如byte -126 转换成int后会变成int -126,并不会得到上面对应关系中的130。那么想要吧Signed-byte转换成Unsigned应该怎么做呢?首先,我们要明确一点,不可能转换为Unsigned-byte,因为Java不提供这种类型。我们能做到的仅仅是将Signed-byte转换为对应的Unsigned-byte的int表现形式。想要达到这个目的,我们只需要用Signed-byte & 0xFF即可实现。非常奇妙,同样,看看内部是怎么运算的,同样使用-95这个例子:
  1. 0xFF是一个int值,当一个byte和int进行运算的时候,byte会首先被转换成int
  2. -95 对应的二进制是10000000 00000000 00000000 01011111
  3. 计算机内部使用补码来表示,数字int -95对应的补码是11111111 11111111 11111111 10100001
  4. 0xFF对应的机器内部表示是 00000000 00000000 00000000 11111111
  5. 这样进行“与”运算后就会得到这样的二进制代码 00000000 00000000 00000000 10100001
  6. 因为是正数,所以补码和源码一致,无需进行转换,得到10进制表示161

同样,里面的数学原理我无法解释,请知道的朋友帮忙解释下。代码如下:
public static void main(String[] args) {
    byte[] data = {39, 56, 101, -95, 49, 121, -9, -57, -69, -120, -47, 3, -25, 32, 103, -127, 47, 6, -85, 49, 100, 112, 39, -123, -28, 5, 123, 76, -15, -30, -18, -103, 34, 62, -27, -82, 127, -126, 72, -70, -127, -22, -94, -16, 98, 53, 64, -5, 115, 107, -96, 46, 8, 2, -92, 89, -6, -48, 116, 46, 112, 69, 11, 98, -119, -13, -98, 32, 109, -23, -104, -25, -10, -38, 35, -78, 72, -113, 123, -54, -46, 125, -87, 94, 20, -108, 67, 37, 14, -118, -26, -30, -31, -8, 74, 12, 74, 83, -16, -47, -125, -30, -91, 109, 1, 43, 112, -31, -8, -10, 51, 37, 50, -67, -72, -52, 39, -62, -80, 19, 122, 80, -114, 8, -5, -58, 109, 75};
    int[] unsignedData = new int[data.length];
    for (int i = 0; i < data.length; i++) {
        unsignedData[i] = data[i] & 0xFF;
    }
    System.out.println(Arrays.toString(unsignedData));
}
执行上面的代码,得到如下输出:
[39, 56, 101, 161, 49, 121, 247, 199, 187, 136, 209, 3, 231, 32, 103, 129, 47, 6, 171, 49, 100, 112, 39, 133, 228, 5, 123, 76, 241, 226, 238, 153, 34, 62, 229, 174, 127, 130, 72, 186, 129, 234, 162, 240, 98, 53, 64, 251, 115, 107, 160, 46, 8, 2, 164, 89, 250, 208, 116, 46, 112, 69, 11, 98, 137, 243, 158, 32, 109, 233, 152, 231, 246, 218, 35, 178, 72, 143, 123, 202, 210, 125, 169, 94, 20, 148, 67, 37, 14, 138, 230, 226, 225, 248, 74, 12, 74, 83, 240, 209, 131, 226, 165, 109, 1, 43, 112, 225, 248, 246, 51, 37, 50, 189, 184, 204, 39, 194, 176, 19, 122, 80, 142, 8, 251, 198, 109, 75]
所有的signed-byte,被转换成unsigned的形式,通过比对可以发现和原来用C#生成签名是一样的,证明转换没有问题。

总结
  • Unsigned-byte 转换 Signed-byte:直接强制转换即可(int -> byte)
  • Signed-byte 转换 Unsigned-byte:将Signed-byte & 0xFF得到Unsigned-byte

如果不想深究原理,直接记住上面的结论也没有问题。