Showing posts with label 消息摘要. Show all posts
Showing posts with label 消息摘要. Show all posts

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