2015年4月

吐槽微信的AES加密设计

微信公众号平台的消息可以使用AES加密,他们在技术文档中说明,他们使用了256位AES加密算法,并按pkcs#7标准对加密数据进行补位,也提供了官方的PHP版加解密库程序。

我浏览了一下他们提供的PHP程序,不得不吐槽一下这个DEMO的开发者。看代码的感觉完全是一个非PHP程序员一边查着手册一边写出来的。代码里有不少低效率的写法和实现,所以我决定要重写一遍他们的加解密库。

考虑到OpenSSL库实现的AES性能是mcrypt库的好几倍,所以我优先考虑基于OpenSSL函数来开发。结果问题就出来了,AES加密算法规定的block-size是128位,不是整数倍block-size的数据需要先补位才能进行AES加密。腾讯选择pkcs#7规范进行补位我没意见,可是你定义的最大补位长度为什么是32字节?按理说最大补位长度应该和AES的block-size一样,128位应该是16字节才对呀。腾讯的这个奇怪规定直接导致无法使用OpenSSL库内置的补位支持。PHP的OpenSSL函数库到PHP5.4版以上才加入了0补位的选项参数,这才能实现自定义补位,这直接导致我的代码无法兼容PHP5.3以前的版本。

我把微信公众号开发用到的库整理出来,放到github上,目前先有这个加解密库,后续准备把微信的Web Service API接口也封装一下,逐渐发布。项目地址: https://github.com/SyuTingSong/php-wechat

理解PHP的AES加密

这是一篇翻译的文章,原文在这里。为排版效果,有改动。

理解PHP的AES加密

这一段PHP代码给出了PHP的AES加密的一个基本轮廓

首先要了解的东西是下面几个常量:

MCRYPT_RIJNDAEL_128
MCRYPT_RIJNDAEL_192
MCRYPT_RIJNDAEL_256

你可能认为MCRYPT_RIJNDAEL_256意味着256位加密技术,但其实不是的。这三个选项定义的是Rijndael加密过程中所使用的块尺寸(block-size),而与密钥长度(即所谓的加密强度)完全无关。(后面会解释AES加密中的加密强度如何设置。)

Rijndael是一种块加密算法,它在一系列互不相关的数据块上分别进行操作。所以必须对数据进行补位操作,以保证被加密的数据长度正好是块尺寸的整数倍。(PHP使用NULL字节进行补位) 因此,如果你指定了MCRYPT_RIJNDAEL_256,你加密输出的数据长度一定是32字节(256位)的整数倍;如果你指定了MCRYPT_RIJNDAEL_128,你的加密输出则一定是16字节的整数倍。

注意:严格地说,AES和Rijndael并不完全等价(尽管实践中它们经常互换使用),因为Rijndael支持更大范围的块尺寸和密钥长度;AES使用固定的128位块尺寸,而密钥长度可以是128位、192位或者256位。Rijndael的块尺寸和密钥长度只要求使用32位的整数倍,最小128位,最大256位。

简而言之:如果你想和AES兼容,请永远使用MCRYPT_RIJNDAEL_128。

那么,第一步自然是初始化加密器对象

$cipher = mcrypt_module_open(MCRYPT_RIJNDAEL_128, '', MCRYPT_MODE_CBC, '');

我们使用CBC模式(cipher-block chaining)进行加密。块加密模式详见这里。要使用CBC模式,需要提供一个初始向量(IV)。初始向量的长度必须和块尺寸相当(不一定等于密钥长度)。既然我们的块尺寸是128位,所以IV也应该是128位(16字节)。也就是说,对于AES加密方法,初始向量总是16字节,不论加密强度如何。

这里是验证初始向量长度的代码:

$iv_size = mcrypt_enc_get_iv_size($cipher);
printf("iv_size = %d\n",$iv_size);

那么在PHP中,如何进行256位而不是128位的AES加密?答案是使用32字节长的密钥。

譬如:

$key256 = '12345678901234561234567890123456';
$key128 = '1234567890123456';

我们的128位初始向量既可以用于128位加密,又可以用于256位加密。

$iv =  '1234567890123456';

printf("iv: %s\n",bin2hex($iv));
printf("key256: %s\n",bin2hex($key256));
printf("key128: %s\n",bin2hex($key128));

这是要加密的原文:

$cleartext = 'The quick brown fox jumped over the lazy dog';
printf("plainText: %s\n\n",$cleartext);

mcrypt_generic_init函数初始化的时候,需要同时提供密钥和初始向量。密钥的长度决定了我们是在进行128位、192位还是256位加密。这里我们先进行256位加密:

if (mcrypt_generic_init($cipher, $key256, $iv) != -1)
{
    // 如果$cleartext的长度并非恰好是块尺寸的整数倍,PHP会用NULL字符进行补位
    $cipherText = mcrypt_generic($cipher,$cleartext );
    mcrypt_generic_deinit($cipher);

    // 以十六进制显示最终结果:
    printf("256-bit encrypted result:\n%s\n\n",bin2hex($cipherText));
}

这次我们进行128位加密:

if (mcrypt_generic_init($cipher, $key128, $iv) != -1)
{
    // 如果$cleartext的长度并非恰好是块尺寸的整数倍,PHP会用NULL字符进行补位
    $cipherText = mcrypt_generic($cipher,$cleartext );
    mcrypt_generic_deinit($cipher);

    // 以十六进制显示最终结果:
    printf("128-bit encrypted result:\n%s\n\n",bin2hex($cipherText));
}

结果

你可以用下列测试来指导针对你自己的AES实现进行的测试

256位密钥CBC模式

IV = '1234567890123456'
 (hex: 31323334353637383930313233343536)
Key = '12345678901234561234567890123456'
 (hex: 3132333435363738393031323334353631323334353637383930313233343536)
PlainText:
 'The quick brown fox jumped over the lazy dog'
CipherText(hex):
 2fddc3abec692e1572d9b7d629172a05caf230bc7c8fd2d26ccfd65f9c54526984f7cb1c4326ef058cd7bee3967299e3

128位密钥CBC模式

IV = '1234567890123456'
 (hex: 31323334353637383930313233343536)
Key = '1234567890123456'
 (hex: 31323334353637383930313233343536)
PlainText:
 'The quick brown fox jumped over the lazy dog'
CipherText(hex):
 f78176ae8dfe84578529208d30f446bbb29a64dc388b5c0b63140a4f316b3f341fe7d3b1a3cc5113c81ef8dd714a1c99

为什么Wireshark无法解开使用Diffie-Hellman加密的SSL数据

前段时间升级了服务器的OpenSSL库,今天突然发现使用Wireshark已经无法解密https内容。查了一下资料才知道这是因为新版OpenSSL支持了Diffie-Hellman系列加密算法导致的。我的抓到的数据显示,密钥交换的过程使用的是一种叫ECDH的密钥交换算法。

以前的SSL通信,都是在客户端验证完服务器的证书值得信赖之后,产生随机密钥并直接用服务器的公钥加密发送出去,所以在向Wireshark提供了服务器的私钥之后,Wireshark能用私钥解开网络上传输的客户端生成的密钥,并用它解后续的所有SSL数据。然而用DH算法交换的密钥根本就不会在网络上传输,所以Wireshark自然无法解密了。

下面是从维基百科查到的一个简单版本的DH密钥交换过程:

  1. 一方选择质数p=23和基数g=5发送给另一方,这两个数可以公开明文传输;
  2. 甲方生成私钥a=6,并计算公钥A = ga mod p = 56 mod 23 = 8;
  3. 乙方生成私钥b=15,并计算公钥B = gb mod p = 515 mod 23 = 19;
  4. 甲乙双方互换公钥;
  5. 甲方计算s = Ba mod p = 196 mod 23 = 2;
  6. 乙方计算s = Ab mod p = 815 mod 23 = 2;
  7. 最后双方的共同计算结果2就是接下来进行对称加密传输的密钥。

Wireshark作为中间人,即使以服务器的SSL私钥解密,取得了p、g、A、B,在不知道a、b任一数值的情况下,也难以计算出s是多少。现实情况中,双方选用的p、g、a、b都会非常大,以确保难以被攻破。

要想在Wireshark上进行debug,只能要么在服务器禁用DH系的所有加密算法,要么干脆直接先用明文协议开发,完成后再接入SSL。

配合nghttpx修改nginx配置

上次介绍了用nghttp2软件包中的nghttpx反向代理让普通的网站支持HTTP2协议。在安装配置完成后实际使用的过程中遇到了一些问题,本文总结了一下解决方案。

开始遇到的两个问题是:经过nghttpx转发之后,request header中的host字段没有了;还有PHP脚本无法得到远端的IP地址。解决第一个问题的方法是在nghttpx的启动参数中加入--no-host-rewrite来通知nghttpx不要修改host标头;第二个问题可以加入--add-x-forwarded-for参数来通知nghttpx加入代理转发IP记录。

修改后的启动命令如下:

# nghttpx -D -f *,443 -b 127.0.0.1,80 --add-x-forwarded-for \
 --pid-file=/path/to/pidfile.pid --no-host-rewrite --errorlog-syslog \
 /path/to/private.key /path/to/certification.crt

加入-D参数后nghttpx会以守护进程方式运行,加入--errorlog-syslog可以把错误信息输出到系统的syslog中,而不再使用STDOUT作为错误日志输出;加入--pid-file之后可以输出一个nghttpx的进程号文件,方便制作成initrc脚本用于管理。

后来又遇到一个问题是,nginx向PHP的fastcgi服务器发送参数时,不知道前面使用了https协议,导致许多PHP项目自动侦测https的代码无法正常工作。这个问题可以修改nginx的配置来解决:

因为nghttpx会在转发的请求头中加入一个X-Forwarded-Proto标头,所以我们可以在nginx.conf中加入一个if语句来确认是否使用了https:

# under server or location scope
if ( $http_x_forwarded_proto ~* https ) {
    set $var_https on;
}

然后在fastcgi.conf中修改传给PHP解释器的参数:

#fastcgi_param  HTTPS    $https if_not_empty;
fastcgi_param  HTTPS    $var_https if_not_empty;

把原来的https判断方法注释掉,用新的$var_https参数。如果使用的不是PHP,而是Python,也可以用同样的方式修改uwsgi_params配置文件。

PS:另外还有一种方法是判断请求标头via中是否包含nghttpx字样,如果包含就认为是https访问的,方法和前面的相似就不再赘述。

让你的网站支持HTTP2

HTTP2和SPDY

SPDY是Google的研究人员为了加速Web浏览体验搞出来的一个试验性协议,旨在降低网络延迟。据这帮研究人员报告1,使用SPDY协议的网站平均等待时间只有用HTTP/1.1协议网站的几分之一。SPDY协议发展到2.0版的时候突然声名雀起,几乎每个浏览器都开始支持它——没错,连最顽固的微软IE浏览器也支持了2

然而就在这个SPDY一统天下的大好时代,Google宣布放弃SPDY协议3。当然Google只是放弃SPDY这个名字而已,SPDY协议中绝大部分的优化手段都被Google写进了HTTP2标准草案里。如今HTTP2协议定稿在即,Google才大方出来宣言放弃SPDY,力争推动各厂尽快切换到HTTP2上。

虽然HTTP2在浏览器上的普及势如破竹比当年SPDY不惶多让,可惜主流的开源Web服务器跟进并不迅速,Apache的nginx目前都只有SPDY模块的支持。还好spdylay4的开发团队动作迅速,马上提供了HTTP2的实现,让我们可以不必多加改动地把服务器部署上HTTP2。

下面介绍一下怎么让自己的网站同时支持SPDY和HTTP2协议。

准备工作

因为SPDY只能支持SSL/TLS加密的连接,我假设网站已经有证书和密钥。如果网站还没有这两样东西,请按这里

我在使用的是Ubuntu 14.04 LTS 64bit 系统,因为系统没有自带spdylay的软件包,所以需要从源码编译。如果服务器只想支持HTTP2而不想支持SPDY协议,也可以不安装spdylay。直接开始nghttp2的安装。

首先要安装编译环境和spdylay的依赖库:

# apt-get install build-essential pkg-config zlib1g-dev libssl-dev libxml2-dev libevent-dev libevent-openssl-2.0-5

再去spdylay的网站下载spdylay的源代码包:

# wget 'https://github.com/tatsuhiro-t/spdylay/releases/download/v1.3.2/spdylay-1.3.2.tar.xz'

安装spdylay

解包和配置:

# tar xpf spdylay-1.3.2.tar.xz
# cd spdylay-1.3.2
# ./configure

配置完成之后会显示一个配置表:

Version:        1.3.2 shared 9:0:2
Host type:      x86_64-unknown-linux-gnu
Install prefix: /usr/local
C compiler:     gcc
CFLAGS:         -g -O2
LDFLAGS:
LIBS:           -lz
CPPFLAGS:
C preprocessor: gcc -E
C++ compiler:   g++
CXXFLAGS:       -g -O2
CXXCPP:         g++ -E
Library types:  Shared=yes, Static=yes
CUnit:          no
OpenSSL:        yes
Libxml2:        yes
Libevent(SSL):  yes
Src:            yes
Examples:       yes

请注意Libevent(SSL)务必要显示为yes,不然编译出来的软件包里不会包含spdy的反向代理。检查好了就可以编译和安装:

# make && make install
# ldconfig

因为新安装的lib不会马上被系统读到,导致运行spdylay程序出现.so文件找不到的情况,所以需要运行ldconfig让系统重新载入动态链接库。

此时可以运行一下SPDY的反向代理程序,看看是否工作良好:

# shrpx
Usage: shrpx [-Dh] [-s|--client|-p] [-b <HOST,PORT>]
             [-f <HOST,PORT>] [-n <CORES>] [-c <NUM>] [-L <LEVEL>]
             [OPTIONS...] [<PRIVATE_KEY> <CERT>]

A reverse proxy for SPDY/HTTPS.

[FATAL] Too few arguments
       (shrpx.cc:1167)

出现这个提示说明spdylay已经安装正常。

安装nghttp2

先要安装nghttp2依赖的软件包:

# apt-get install libev-dev libjansson-dev libjemalloc-dev python-dev cython

下载nghttp2的源代码包:

# wget 'https://github.com/tatsuhiro-t/nghttp2/releases/download/v0.7.11/nghttp2-0.7.11.tar.xz'

解压和配置:

# tar xpf nghttp2-0.7.11.tar.xz
# cd nghttp2-0.7.11
# ./configure

配置输出一个更长的信息表:

Version:        0.7.11 shared 13:0:8
Host type:      x86_64-unknown-linux-gnu
Install prefix: /usr/local
C compiler:     gcc
CFLAGS:         -g -O2
WARNCFLAGS:
LDFLAGS:
LIBS:
CPPFLAGS:
C preprocessor: gcc -E
C++ compiler:   g++
CXXFLAGS:       -g -O2 -std=c++11
CXXCPP:         g++ -E
Library types:  Shared=yes, Static=yes
Python:
  Python:         /usr/bin/python
  PYTHON_VERSION: 2.7
  pyexecdir:      ${exec_prefix}/lib/python2.7/dist-packages
  Python-dev:     yes
  PYTHON_CPPFLAGS:-I/usr/include/python2.7
  PYTHON_LDFLAGS: -L/usr/lib -lpython2.7
  Cython:         cython
Test:
  CUnit:          no
  Failmalloc:     yes
Libs:
  OpenSSL:        yes
  Libxml2:        yes
  Libev:          yes
  Libevent(SSL):  yes
  Spdylay:        yes
  Jansson:        yes
  Jemalloc:       yes
  Boost CPPFLAGS:
  Boost LDFLAGS:
  Boost::ASIO:
  Boost::System:
  Boost::Thread:
Features:
  Applications:   yes
  HPACK tools:    yes
  Libnghttp2_asio:no
  Examples:       yes
  Python bindings:yes
  Threading:      yes

需要确认Features中的Application是yes。可以编译和安装:

# make && make install
# ldconfig

启动nghttpx反向代理

终于到最后一步,用nghttpx为网站提供HTTP2支持。因为nghttpx是反向代理服务器,如果现在的Web服务器已经启用了https,需要先把https支持关掉,不然会发生端口冲突哦。我们使用nghttpx的Base模式就可以了,在443端口上接收https请求,转发到本机的80端口,在先安装了spdylay的情况下,nghttpx是可以同时支持HTTP/1.1、SPDY/3.1、HTTP2的。

# nghttpx -f *,443 -b localhost,80 /path/to/private.key /path/to/certification.crt

如果没有什么错误,现在网站应该可以用支持http2的浏览器访问了,当然不支持http2的浏览器会自动降级到spdy或者http1.1上。

nghttpx有一堆高级的参数可以使用,比如变成一个daemon进程,还有指定运行的用户和组,还有输出日志的方法格式等等,就不在这里详述了,可以运行nghttpx -h阅读帮助资料。


  1. http://dev.chromium.org/spdy/spdy-whitepaper#TOC-Preliminary-results 

  2. http://blogs.msdn.com/b/ieinternals/archive/2013/09/24/internet-explorer-11-changelist-change-log.aspx 

  3. http://blog.chromium.org/2015/02/hello-http2-goodbye-spdy-http-is_9.html 

  4. http://tatsuhiro-t.github.io/spdylay/