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

No comments :

Post a Comment