Thursday, February 20, 2020

How to solve npm install issue for installing node-sass on Windows.


When we run npm install on a project with a dependency on  node-sass, we often get an error like
"Unable to find a sass binary for your platform", and thus a compiling process is triggered to compile the sass binary from source code. Which requires Python, Visual Studio and Dot net Framework are installed on local.

Option 1. Install required build tools, such as Python, Visual Studio and dot net framework. But basically we don't need to manually install them. An npm tool is out there can help us to install all of them. windows-build-tools.
One of a disadvantage of this option is when we install windows-build-tools, it requires administrator privilege to perform installation. And per my company's policy, we are not allowed to acquire administrator privilege. And I have to find another way.

I was thinking, do we have to compile sass binary from source code? Windows is a common platform, it is impossible that there's no existing binary for this platform. The reason of I got the error of "Unable to find a sass binary for your platform" maybe just there's no sass binary for my platform on the registry I was using.
Option 2. After some research, I notice there is an argument for installing sass-binary to indicate the source it uses to fetch the binary. And I saw some said the sass binary for windows platform exists in taobao registry. So with this argument we can direct node-sass download sass binary from taobao.
So run npm install like this:
npm install --sass_binary_site=https://npm.taobao.org/mirrors/node-sass

Or this argument can also be configured in .npmrc file
sass_binary_site=https://npm.taobao.org/mirrors/node-sass

Then node-sass can automatically download sass binary from taobao registry without compiling it.

Reference
node-sass Binary configuration parameters
整理 node-sass 安装失败的原因及解决办法

Sunday, March 24, 2019

Create My Own CA to Issue a Certificate

在这个信息爆炸的时代,网络安全越来越被人们所重视。在浏览网页的时候,你提交的数据会不会被黑客截获?你正在浏览的页面是否就是你认为的页面?还是被黑客伪造后的页面?你下载的文件,是否就是发布者发布的原始文件?还是被黑客植入了病毒之后的版本?……

为了回答以上问题,并且能够让我们拥有更加安全的网络环境,越来越多的网站采用https来让浏览器对服务器进行验证,并且加密浏览器与服务器之间传输的信息。也就是说黑客可以截获信息,但是无法解密信息。

作为一个网站,想要使用https必须有一个经过Trusted CA(Certificate Authority)签名的证书,以及对应的private key. 以前想要申请一个Trusted CA 签名的证书是需要交费的,所以很多小网站或者创业初期的网站为了节约成本而没有采用https,由此导致了网络环境的恶化。

在2016年4月12日,由Internet Security Research Group (ISRG)建立了一个非盈利性的CA ------ Let’s Encrypted. 所有网站的提供者都可以在Let’s Encrypted 上免费为自己的网站申请Trusted CA签名的证书。尽管步骤有些复杂,而且有一些限制,但是能够在不提高成本的情况下提高网站的安全性,对于网站提供者的吸引力是巨大的。之后许多著名的企业也通过对Let’s Encrypted. 提供赞助的方式来进一步改善全球的网络环境。其中Google, Cisco 等等都是广大的赞助者之一。

对于一个开发人员,在网站开发过程中是没有必要申请Let’s Encrypted签名的证书的,首先是网站的开发周期不定,也许今天申请了证书,结果网站还没有上线证书就过期了;再者,一般来讲开发人员都是在本地电脑上进行开发,即使在Let’s Encrypted申请了证书,浏览器也会因为本地的机器名与证书中的域名不匹配而弹出警告。所以在开发过程中,我们更适合自己创建一个CA,为我们的网站创建一个临时的证书,尽管我们的CA并不是Trusted的(之后会讲到如何让它看起来像是Trusted),但是对于开发阶段来说已经足够了。

下面我们将创建自己的CA,并且用这个CA为正处于开发中的网站签署一个证书。在这里我们需要工具openssl. 在Linux 系统中openssl 已经预装好了。

所谓的CA实际上就是一个Self-Signed Certificate 加上对应的Private Key。首先我们来生成我们来生成Private Key (至于Public Key 是可以从Private Key中抽取出来的).

$ openssl genrsa -aes256 -out ca.key 4096

为了安全考虑,如果我们将Private Key保存在文件中,应该用密码将文件保护起来。上面命令中-aes256就是我们采用的加密算法,在执行过程中会要求我们设置密码(pass phrase),之后如果想要读取ca.key中的Private Key就需要用到这个密码。我们看一下这个文件的内容.

$ cat ca.key

会看到文件的内容是一个加密的Private Key

-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,0988C6461A58983453D0B4F3041BDF9D
……
-----END RSA PRIVATE KEY-----

接下来我们要用Private Key 来生成一个Self-Signed Certificate.

$ openssl req -new -x509 -key ca.key -days 3650 -out ca.crt

控制台中会要求我们输入一些信息来拼凑成Distinguished Name,因为我们是自己使用,可以根据自己的喜好进行填写。所有信息输入完成之后,会在当前目录生成ca.crt文件。这个文件就是我们需要的Self-Signed Certificate. 可以通过命令来看一下这个Self-Signed Certificate 中的内容。

$ openssl x509 -in ca.crt -noout –text

其中可以看到Issuer(签署人)和Subject(证书主体)是相同的,这就表示这是一个Self-Signed Certificate.

还可以看到关于Public Key的信息,Public Key是从ca.key文件中抽取出来的。

同时我们也可以看到签名算法sha256WithRSAEncryption,即先通过sha256对Subject进行散列,之后用RSA算法,结合private key对签名进行加密。

接下来我们要利用刚刚创建的CA来为自己的网站签发证书。

假设本地的IP地址是:11.11.11.11

首先和创建CA一样,我们也需要生成Private Key

$ openssl genrsa -aes256 -out 11.11.11.11.key 4096

接下来需要用Private Key 创建一个CSR (Certificate Sign Request),在CSR中会包含我们的主体信息,以及从Private Key中导出的Public Key。

$ openssl req -new -key 11.11.11.11.key -out 11.11.11.11.csr

跟随命令行输入主体的Distinguished Name,这里需要注意的是Common Name必须输入网站服务器的机器名或者IP,如果是本地开发则输入本地的IP/机器名/localhost均可。

有了CSR之后就可以用之前的CA来签发证书了,因为chrome浏览器检查证书与服务器地址是否匹配时,会从Subject Alternative Name中读取证书中描述的服务器地址,所以我们在签署证书的时候需要将Subject Alternative Name写入到证书中的Extensions。为了写入Extension我们需要用到配置文件。

在当前目录建立x509.config 文件,输入以下内容:

[ x509_ext ]
subjectAltName = @alt_names

[ alt_names ]
DNS.1 = localhost
IP.1 = 127.0.0.1
IP.2 = 11.11.11.11

我们打算添加三个Subject Alternative Name 到证书中(localhost, 127.0.0.1, 11.11.11.11),这样做的目的是无论使用这三个中的任何一个访问网站,都可以通过证书的检测。

接下来我们用这个配置文件,将CSR签署为一个真正的证书。

$ openssl x509 -req -in 11.11.11.11.csr -CA ../ca.crt -CAkey ../ca.key -CAcreateserial -extfile x509.config -extensions x509_ext -out 11.11.11.11.crt -sha256

此处需要注意最后一个参数-sha256,因为chrome要求证书的散列算法必须是sha256,所以此处加入这个参数,否则在用chrome访问网站的时候会产生一个警告。

命令执行结束后,在当前目录会生成一个11.11.11.11.crt的证书文件,即用我们自己的CA为我们自己的网站签署的证书。

因为这个过程中使用到的CA是我们自己创建的,所以默认情况下系统是不会信任由这个CA签发的证书的,因此我们需要将CA的证书添加到系统的信任库中,对于不同的系统方法会有所不同。详细信息请参考这里。

测试

为了方便我们可以启动一个https server来进行测试。如果本地装有Node的环境,可以用npm安装http-server到全局。

$ npm install –g http-server

安装完成之后,可以通过命令启动http-server,并且启用SSL,同时指定private key和certificate。

$ http-server -S -C 11.11.11.11.crt -K 11.11.11.11-insecure.key

启动之后我们可以用curl来检测是否成功。

$ curl https://127.0.0.1:8080

如果没有任何错误,并且能够看到返回的response,则证明用自己的CA签署的证书已经被系统信任。

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. 对数据进行签名的过程是怎样的?