转:用 Kerberos 为 J2ME 应用程序上锁,第 3 部分: 建立与电子银行的安全通信

用 Kerberos 为 J2ME 应用程序上锁,第 3 部分: 建立与电子银行的安全通信
内容:
向 KDC 服务器发送 TGT 请求
处理 TGT 响应
从票据响应中提取票据和密钥
得到一个服务票据
生成服务票据请求
从服务票据响应中提取服务票据和子会话密钥
创建一个安全通信上下文
向电子银行的业务逻辑服务器发送安全消息
解码服务器消息
示例移动银行应用程序
结束语
参考资料
关于作者
对本文的评价
相关内容:
Simplify enterprise Java authentication with single sign-on
订阅:
developerWorks 时事通讯
设置服务器、请求票据、获取响应

Faheem Khan
自由顾问
2004 年 3 月 27 日

如果您已经学习了本系列的前两部分,那么现在可以开始第三部分,也就是最后一部分,您将设置一个 KDC 服务器,向它发送 Kerberos 票据请求并取得其响应。然后,您将学习处理 KDC 服务器的响应所需的低层 ASN1 处理方法,以便取得票据和会话密钥。取得了服务票据后,将向电子银行的业务逻辑服务器发送一个建立安全上下文的请求。最后,您将学会与电子银行业务逻辑服务器进行实际的安全通信。

回顾本系列的 第一篇文章,它介绍了移动银行 MIDlet 应用程序,并解释了 Kerberos 是如何满足这种应用程序的安全要求的。文章还描述了 Kerberos 用来提供安全性的数据格式。

本系列的 第二篇 文章展示了如何在 J2ME 中生成 ASN.1 数据类型。介绍了如何用 Bouncy Castle 加密库进行 DES 加密,并用用户的密码生成 Kerberos 密钥。最后将这些内容放到一起并生成一个 Kerberos 票据请求。

在本系列文章中开发的 Kerberos 客户不要求某个特定的 Kerberos 服务器,它可以使用所有 KDC 实现。参考资料部分包含了一些可以被 Kerberos 客户机所使用的 KDC 服务器的链接。

不管所选的是什么 KDC 服务器,必须告诉服务器移动银行 MIDlet 的用户在对 TGT 的请求中不需要发送预认证数据( padata本系列第一篇文章的图 2 中显示的 KDC-REQ 结构的第三个字段)。

根据 Kerberos 规范,发送 padata 字段是可以选择的。因此,KDC 服务器通常允许配置特定的用户,使得对于所配置的用户不需要 padata 字段就可以接受 TGT 请求。为了尽量减少 Kerberos 客户机上的负荷,必须告诉 KDC 服务器接受电子银行移动用户的不带 padataTGT 请求。

在这个例子中,我使用了 Microsoft 的 KDC 服务器以试验基于 J2ME 的移动银行应用程序。在本文 源代码下载 中的 readme.txt 文件包含了如何设置 KDC 服务器、以及如何告诉它接受不带 padata 字段的 TGT 请求的指导。(在我的“用单点登录简化企业 Java 认证”一文中,我使用了同一个 KDC 服务器展示单点登录。有关链接请参阅 参考资料。)

向 KDC 服务器发送 TGT 请求
设置了 KDC 服务器后,就向它发送 TGT 请求。看一下 清单 1 中的 getTicketResponse() 方法。它与 本系列第二篇文章中的清单 12 中的 getTicketResponse() 方法是相同的,只有一处不同:这个方法现在包括向 KDC 服务器发送 TGT 请求的 J2ME 代码。在 清单 1中标出了新的代码,所以您可以观察在 清单 12中没有的新增代码。

清单 1NEW CODE 部分中,我以一个现有的 DatagramConnection 对象( dc )为基础创建了一个新的 Datagram 对象( dg )。注意在本文的最后一节中,移动银行 MIDlet 创建了我在这里用来创建 Datagram 对象的 dc 对象。

创建了 dg 对象后, getTicketResponse() 方法调用了它的 send() 方法,向 KDC 服务器发送票据请求。

在向服务器发送了 TGT 请求之后, 清单 1getTicketResponse() 方法接收服务器的 TGT 响应。收到响应后,它将响应返回给调用应用程序。

清单 1. getTicketResponse() 方法

    public byte[] getTicketResponse( )
    {
        byte ticketRequest[];
        byte msg_type[];

        byte pvno[] = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                        1, getIntegerBytes(5));

        msg_type = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                         2, getIntegerBytes(10));

        byte kdc_options[] = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                                 0, getBitStringBytes(new byte[5]));

        byte generalStringSequence[] = getSequenceBytes (
                                         getGeneralStringBytes (userName));
        byte name_string[] = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                              1, generalStringSequence);
        byte name_type[] = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                              0, getIntegerBytes(ASN1DataTypes.NT_PRINCIPAL));
        byte principalNameSequence [] = getSequenceBytes(
		                                  concatenateBytes (name_type, name_string));
        byte cname[] = getTagAndLengthBytes (ASN1DataTypes.CONTEXT_SPECIFIC,
                         1, principalNameSequence);

        byte realm[] = getTagAndLengthBytes (ASN1DataTypes.CONTEXT_SPECIFIC,
                         2, getGeneralStringBytes (realmName));

        byte sgeneralStringSequence[] =
                concatenateBytes(getGeneralStringBytes(kdcServiceName),
                                  getGeneralStringBytes (realmName));
        byte sname_string[] = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                                1, getSequenceBytes(sgeneralStringSequence));
        byte sname_type[] = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                              0, getIntegerBytes(ASN1DataTypes.NT_UNKNOWN));
        byte sprincipalNameSequence [] = getSequenceBytes
                                        (concatenateBytes (sname_type, sname_string));
        byte sname[] = getTagAndLengthBytes (ASN1DataTypes.CONTEXT_SPECIFIC,
                         3, sprincipalNameSequence);

        byte till[] = getTagAndLengthBytes (
                        ASN1DataTypes.CONTEXT_SPECIFIC,
                        5,
                        getGeneralizedTimeBytes (
                                new String("19700101000000Z").getBytes()));

        byte nonce[] = getTagAndLengthBytes(
                                ASN1DataTypes.CONTEXT_SPECIFIC,
                                7,
                                getIntegerBytes (getRandomNumber()));

        byte etype[] = getTagAndLengthBytes(
                                ASN1DataTypes.CONTEXT_SPECIFIC,
                                8,
                                getSequenceBytes(getIntegerBytes(3)));

        byte req_body[] = getTagAndLengthBytes(
                             ASN1DataTypes.CONTEXT_SPECIFIC,
                             4,
                             getSequenceBytes(
                                concatenateBytes(
                                   kdc_options,
                                   concatenateBytes(
                                      cname,
                                         concatenateBytes(
                                            realm,
                                            concatenateBytes(
                                               sname,
                                               concatenateBytes(
                                                  till,
                                                  concatenateBytes
                                                     (nonce, etype)
                                              )
                                           )
                                        )
                                     )
                                  )
                               )
                            );

        ticketRequest = getTagAndLengthBytes(
                           ASN1DataTypes.APPLICATION_TYPE,
                               10,
                                getSequenceBytes(
                                  concatenateBytes(
                                     pvno,
                                      concatenateBytes
                                         (msg_type, req_body)
                                  )
                               )
                            );
        /****** NEW CODE BEGINS ******/
        try {
             Datagram dg = dc.newDatagram(ticketRequest, ticketRequest.length);
             dc.send(dg);
         } catch (IllegalArgumentException il) {
           	 il.printStackTrace();
         } 
         catch (Exception io) {
           	 io.printStackTrace();
         } 

         byte ticketResponse[] = null;
         try 
         {
			Datagram dg = dc.newDatagram(700);
            dc.receive(dg);
        	if (dg.getLength() > 0) {
        		ticketResponse = new byte[dg.getLength()];
        	    System.arraycopy(dg.getData(), 0, ticketResponse, 0, dg.getLength());
        	} else 
                return null;
        } catch (IOException ie){
        	ie.printStackTrace();
        }
        /****** NEW CODE ENDS ******/		

        return ticketResponse;
    }//getTicketResponse

处理 TGT 响应
既然已经收到了来自 KDCTGT 响应,现在该对响应进行处理以便从响应中提取 票据会话密钥

自然,响应处理包括一些低层 ASN.1 处理(就像在本系列第二篇文章中生成票据请求时遇到的低层 ASN.1 生成方法一样)。所以在展示如何使用低层处理方法从票据响应中提取 票据会话密钥 之前,我将实现并解释一些低层 ASN.1 处理方法以及一些低层加密支持方法。

像以前一样,低层 ASN1 处理方法放在 ASN1DataTypes 类中。下面的方法在本文的 源代码下载 中的 ASN1DataTypes.java 文件中:

  1. isSequence()
  2. getIntegerValue()
  3. isASN1Structure()
  4. getNumberOfLengthBytes()
  5. getLength()
  6. getASN1Structure()
  7. getContents()

下面是上面列出的每一个低层 ASN.1 处理方法的说明。

isSequence()
清单 2 中显示的 isSequence() 方法取单个 字节 作为参数,并检查这个 字节 是否是一个 ASN.1 SEQUENCE 字节。如果 字节 值表示一个 SEQUENCE ,那么它就返回 true,否则它返回 false。

清单 2. isSequence() 方法

   public boolean isSequence(byte tagByte)
   {
      if (tagByte == (byte)0x30)
         return true;
      else
         return false;   
   }//isSequence

getIntegerValue()
清单 3 中显示的 getIntegerValue() 方法只取一个输入参数,它是表示一个 ASN.1 INTEGER 数据类型的内容的 字节 数组。它将输入 字节 数组转换为 J2ME int 数据类型,并返回 J2ME int 。在从 ASN.1 INTEGER 中提取了内容字节,并且希望知道它所表示的是什么 integer 值时就需要这个方法。还要用这个方法将长度字节转换为 J2ME int

注意, getIntegerValue() 方法设计为只处理正的 integer 值。

ASN.1 以最高有效位优先(most-significant-byte-first)的序列存储一个正的 INTEGER 。例如,用 ASN.1 表示的十进制 511 就是 0x01 0xFF 。可以写出十进制值的完整位表示(对于 511 ,它是 1 11111111 ),然后对每一个字节写出 十六进制 值(对于 511,它是 0x01, 0xFF ),最后以最高有效位优先的顺序写出 十六进制 值。

另一方面,在 J2ME 中一个 int 总是四字节长,并且最低有效 字节 占据了最右边的位置。在正 integer 值中空出的位置上填入零。例如,对于 511 ,J2ME int 的写法是 0x00 0x00 0x01 0xFF

这意味着在将 ASN.1 INTEGER 转换为一个 J2ME int 时,必须将输入数组的每一个 字节 正确地放到输出 J2ME int 中的相应位置上。

例如,如果输入字节数组包含两个字节的数据 (0x01, 0xFF) ,那么必须像下面这样将这些字节放到输出 int 中:

  • 必须在输出 int 的最左边或者最高有效位置写入 0x00
  • 类似地,必须在与输出 int 的最高有效 字节 相邻的位置上写入 0x00
  • 输入数组的第一个字节 (0x01) 放入输出 int 中与最低有效位置相邻的位置。
  • 输出数组的第二个字节 (0xFF) 放到输出 int 的最低有效或者最右边的位置。

getIntegerValue() 方法中的 for 循环计算每一个 字节 的正确位置,再将这个 字节 拷贝到其相应的位置上。

还要注意因为 J2ME int 总是有四个字节, getIntegerValue() 方法只能处理最多四 字节 integer 值。能力有限的、基于 J2ME 的 Kerberos 客户不需要处理更大的值。

清单 3. getIntegerValue() 方法

   public int getIntegerValue(byte[] intValueAsBytes)
   {
      int intValue = 0;
      int i = intValueAsBytes.length;
     

      for (int y = 0; y < i; y++)
         intValue |= ((int)intValueAsBytes[y] & 0xff) << ((i-(y+1)) * 8);

      return intValue;
   }//getIntegerValue()

isASN1Structure()
清单 4 中显示的 isASN1Structure() 方法分析一个输入字节是否表示具有特定标签号的特定类型的 ASN.1 结构(即, 特定于上下文的 (context specific)、应用程序级 (application level) 或者通用类型 (universal type ))的标签字节(第一个字节)。

这个方法取三个参数。第一个参数( tagByte )是要分析的输入 字节 。第二和第三个参数( tagTypetagNumber )分别表示所要查找的标签类型和标签号。

为了检查 tagByte 是否具有所需要的标签号的标签类型, isASN1Structure() 方法首先用 tagTypetagNumber 参数构建一个新的临时标签字节( tempTagByte )。然后比较 tempTagBytetagByte 。如果它们是相同的,那么方法就返回 true,如果不相同它就返回 false。

清单 4. isASN1Structure() 方法

   public boolean isASN1Structure (byte tagByte, int tagType, int tagNumber)
   {
      byte tempTagByte = (byte) (tagType + tagNumber);

      if (tagByte == tempTagByte)
         return true;
      else
         return false;
   }//isASN1Structure

getNumberOfLengthBytes()
清单 5 显示的 getNumberOfLengthBytes() 方法取一个参数( firstLengthByte )。 firstLengthByte 参数是 ASN.1 结构的第一个长度字节。 getNumberOfLengthBytes() 方法处理第一个长度字节,以计算 ASN.1 结构中长度字节的字节数。这是一个工具方法, ASN1DataTypes 类中的其他方法在需要知道一个 ASN.1 结构的长度字节的字节数时就使用它。

清单 5 中的 getNumberOfLengthBytes() 方法的实现策略如下:

  1. 检查 firstLengthByte 的最高有效位(第 8 位)是否为零。 清单 5 中的 if ( (firstLengthByte)& (1<<8)==0) 这一行完成这一任务。
  2. 如果最高有效位为零,那么长度字节就遵循 单字节 长度表示法。在 本系列的第 1 部分 我们说过有两种长度表示法 ―― 单字节多字节 。在 单字节 长度表示法中总是有一个长度字节。因此,如果最高有效位为零,那么只需返回 1 作为长度字节的字节数。
  3. 如果 firstLengthByte 的最高有效位是 1,这意味着长度字节遵循 多字节 长度表示法。在这时, 清单 5 中的 else 块取得控制。

多字节 长度格式中, firstLengthByte 的最高有效位指定后面有多少长度字节。例如,如果 firstLengthByte 的值是 1000 0010 ,那么最左边的 1(最高有效位)说明后面的长度字节使用 多字节 长度表示法。其他 7 位( 000 0010 )说明还有两个长度字节。因此,在这里 getNumberOfLengthBytes() 方法应当返回 3( firstLengthBytes 加上另外两个长度字节)。

清单 5else 块的第一行( firstLengthByte &= (byte)0x7f; )删除 firstLengthByte 的最高有效位。

else 块中的第二行( return (int)firstLengthByte + 1; )将 firstLengthByte 强制转换为 integer ,在得到的 integer 值中加 1,并返回这个 integer

清单 5. getNumberOfLengthBytes() 方法

   public int getNumberOfLengthBytes (byte firstLengthByte) {
      if ( (firstLengthByte & 1<<8) == 0 )
         return 1;
      else {
         firstLengthByte &= (byte)0x7f;
         return (int)firstLengthByte + 1;
      }
   }//getNumberOfLengthBytes

getLength()
这个方法的目的是检查一个特定的 AS1 结构有多少个字节。处理应用程序通常有一个由多个 ASN.1 结构构成的嵌入层次所组成的字节数组。 getLength() 方法计算特定结构中的字节数。

这个方法取两个参数。第一个参数( ASN1Structure )是一个字节数组,它应当包含至少一个完整的 ASN.1 结构,这个结构本身包含标签字节、长度字节和内容字节。第二个参数( offset )是一个在 ASN1Structure 字节数组中的偏移值。这个参数指定在 ASN1Structure 字节数组中包含的 ASN.1 结构的开始位置。

getLength() 方法返回一个等于从 offset 字节处开始的 ASN.1 结构中的字节总数。

看一下 清单 6,它显示了 getLength() 方法的一个实现:

  1. 第一步是向 getNumberOfLengthBytes() 方法传 ASN.1 结构的第二个字节。这个 ASN.1 结构从 offset 字节开始,所以可以预计 offset 字节实际上就是标签字节。因为所有 Kerberos 结构只包含一个标签字节,所以第二个字节(在 offset 字节后面的那个字节)是第一个长度字节。第一个长度字节说明长度字节的总字节数, getNumberOfLengthBytes() 方法返回长度字节数。 int numberOfLengthBytes = getNumberOfLengthBytes(ASN1Structure [offset+1]); 这一行执行这项任务 。
  2. 如果 getNumberOfLengthBytes() 方法返回一个大于 1 的值,那么必须处理 多字节 长度表示法。在这种情况下,将从 offset + 2 (让过标签字节和第一个长度字节) 开始的长度字节读到一个名为 lengthValueAsBytes 的变量中。然后用 getIntegerValue() 方法将长度值从 ASN.1 字节转换为 J2ME int 。最后,将结果加 1(以补偿不包含在长度值中的标签字节),再将长度值返回给调用应用程序。
  3. 如果 getNumberOfLengthBytes() 方法返回 1,则要处理 单字节 长度表示法。在这种情况下,只要将第一个(也是惟一的一个)长度字节转换为 J2ME int ,对它加 1(以补偿不包含在长度值中的标签字节),并将得到的值返回给调用应用程序。

清单 6 getLength() 方法

   public int getLength (byte[] ASN1Structure, int offset) {
   
      int structureLength;
      int numberOfLengthBytes = getNumberOfLengthBytes(ASN1Structure[offset + 1]);
      byte[] lengthValueAsBytes = new byte[numberOfLengthBytes - 1];

      if (numberOfLengthBytes > 1)
      {
         for (int i=0; i < numberOfLengthBytes-1 ; i++)
            lengthValueAsBytes[i]= ASN1Structure [offset + i + 2];
         
         structureLength = getIntegerValue(lengthValueAsBytes);
      }
      else 
         structureLength = (int) (ASN1Structure[offset+1]);
       
      structureLength += numberOfLengthBytes + 1;
      return structureLength;

   }//getLength()

getASN1Structure
清单 7 中的 getASN1Structure() 方法从一个包含一系列 ASN.1 结构的字节数组中找出并提取特定 ASN.1 结构。这个方法有三个参数。第一个参数( inputByteArray )是输入字节数组,需要从这个字节数组中找到所需要的 ASN.1 结构。第二个参数是一个 int ,它指定要查找的标签的类型。第三个参数指定标签号。

看一下 清单 7 中的 getASN1Strucute() 方法实现。它将 offset 值初始化为零并进入 do-while 循环。

do-while 循环中,将字节数组中第一个字节读入名为 tagByte 的字节中。然后用 isASN1Structure() 方法检查输入数组的第一个字节是否是所需要的 ASN.1 结构。

如果第一个字节代表所需要的结构,那么就用 getLength() 方法找到要返回的所需数量的字节。然后将所需要的字节拷贝到名为 outputBytes 的字节数组中、并将这些字节返回到调用应用程序。

如果第一个字节不代表所需要的结构,那么就要跳到下一个结构。为此,将 offset 值设置为下一个结构的开始位置。

do-while 循环在下一个循环中检查下一个结构,并以此方式检查整个输入数组。如果没有找到所需要的结构,那么 do-while 循环就会退出并返回 null。

清单 7. getASN1Structure() 方法

   public byte[] getASN1Structure (byte[] inputByteArray, int tagType, int tagNumber)
   {
      byte tagByte;
      int offset = 0;

      do {
         tagByte = inputByteArray[offset];

         if (isASN1Structure(tagByte, tagType, tagNumber)) {
            int lengthOfStructure = getLength(inputByteArray, offset);
            byte[] outputBytes = new byte[lengthOfStructure];

            for (int x =0; x < lengthOfStructure; x++)
               outputBytes[x]= inputByteArray [x + offset];

            return outputBytes;
         }
         else
            offset += getLength(inputByteArray, offset);
       
      } while (offset < inputByteArray.length);
     
      return null;
   }//getASN1Structure

getContents()
清单 8 中显示的 getContents() 方法取 ASN1Structure 字节数组并返回一个包含 ASN1Structure 内容的字节数组。

getContents() 方法假定所提供的字节数组是一个有效的 ASN1 结构,所以它忽略结构中表示标签字节的第一个字节。它将第二个字节(即第一个长度字节)传递给 getNumberOfLengthBytes() 方法,这个方法返回 ASN1Structure 输入字节数组中的长度字节数。

然后它构建一个名为 contentBytes 的新字节数组,并将 ASN1Structure 的内容拷贝到 contentBytes 数组中(去掉标签和长度字节)。

清单 8. getContents() 方法

   public byte[] getContents (byte[] ASN1Structure)
   {
      int numberOfLengthBytes = getNumberOfLengthBytes(ASN1Structure [1]);
      byte[] contentBytes = new byte[ASN1Structure.length - (numberOfLengthBytes + 1)];

      for (int x =0; x < contentBytes.length; x++)
         contentBytes[x]= ASN1Structure [x + numberOfLengthBytes + 1];

      return contentBytes;
   }//getContents

一些低层加密支持方法
除了前面描述的低层处理方法,还需要一些低层加密支持方法以处理一个票据响应。这就是为什么在解释票据响应的处理之前,我要讨论以下这些为 Kerberos 客户机提供加密支持的方法:

  1. encrypt()
  2. decrypt()
  3. getMD5DigestValue()
  4. decryptAndVerifyDigest()

这些方法是 KerberosClient 类的组成部分,可以在 KerberosClient.java 文件中找到它们,本文的 源代码下载中可以找到这个文件。下面是对这几个方法的说明:

encrypt()
清单 9 中显示的 encrypt() 方法处理低层加密并加密一个输入字节数组。

这个方法取三个字节数组参数,即一个用于加密的密码( keyBytes )、要加密的纯文本数据( plainData )和一个初始向量或者 IV( ivBytes )。它用密钥和 IV 加密纯文本数据,并返回加密后的纯文本数据。

注意在 清单 9 中的 encrypt() 方法中,我使用了 DESEngineCBCBlockCipherKeyParameterParametersWithIV 类以加密这个纯文本数据。这些类属于在讨论 第二篇文章中的清单 11 中的 getFinalKey() 方法时介绍的 Bouncy Castle 加密库。回头看一下并比较 清单 9 中的 encrypt() 方法与第二篇文章中 清单 11 中的 getFinalKey() 方法。注意以下几点:

  1. getFinalKey() 方法使用一个包装了初始向量的 ParametersWithIV 类。Kerberos 规范要求在生成加密密钥时,用加密密钥作为 IV。因此,方法中的加密算法用加密密钥作为 IV。因此, getFinalKey() 方法中的算法使用这个加密密钥作为一个 IV。

    另一方面, encrypt() 方法设计为可以使用或者不使用 IV 值。更高级别的应用程序逻辑使用 encrypt() 方法时可以提供一个 IV 值或者忽略它。如果应用程序要求一个没有 IV 值的数据加密,那么它将传递 null 作为第三个参数。
    如果有 IV,那么 encrypt() 方法用一个 ParametersWithIV 实例初始化 CBCBlockCipher。注意在 清单 9if (ivBytes != null) 块中,我传递了一个 ParametersWithIV 实例作为给 cbcCipher.init() 方法调用的第二个参数。
    如果第三个参数为 null,那么 encrypt() 方法就用一个 KeyParameter 对象实始化 CBCBlockCipher 对象。注意在 清单 9 中的 else 块中,我传递了一个 KeyParameter 实例作为 cbcCipher.init() 方法调用的第二个参数。

  2. 第二篇文章的清单 11 中的 getFinalKey() 方法返回输入数据最后一块的处理结果。另一方面, encrypt() 方法将纯文本处理的每一步的结果串接在一起、并返回串接在一起的所有处理过的(加密的)字节。

清单 9. encrypt() 方法

   public byte[] encrypt(byte[] keyBytes, byte[] plainData, byte[] ivBytes)
   {
      byte[] encryptedData = new byte[plainData.length];
      CBCBlockCipher cbcCipher = new CBCBlockCipher(new DESEngine());
      KeyParameter keyParameter = new KeyParameter(keyBytes);
      
      if (ivBytes != null) {
         ParametersWithIV kpWithIV = new ParametersWithIV (keyParameter, ivBytes);
         cbcCipher.init(true, kpWithIV);
      } else
         cbcCipher.init(true, keyParameter);

      int offset = 0;                  
      int processedBytesLength = 0;

      while (offset < encryptedData.length) {
         try {
            processedBytesLength = cbcCipher.processBlock( plainData, 
                                      offset,
                                      encryptedData, 
                                      offset
                                   );
            offset += processedBytesLength;
         } catch (Exception e) {
            e.printStackTrace();
         }//catch
     }
      
     return encryptedData;
   }

decrypt()
清单 10 显示的) decrypt() 方法与 encrypt() 方法的工作方式完全相同,只不过解密时, cbcCipher.init() 方法的第一个参数是 false (加密时它是 true )。

清单 10. decrypt() 方法

   public byte[] decrypt(byte[] keyBytes, byte[] encryptedData, byte[] ivBytes)
   {
      byte[] plainData = new byte[encryptedData.length];
      CBCBlockCipher cbcCipher = new CBCBlockCipher(new DESEngine());
      KeyParameter keyParameter = new KeyParameter(keyBytes);
      
      if (ivBytes != null) {
         ParametersWithIV kpWithIV = new ParametersWithIV (keyParameter, ivBytes);
         cbcCipher.init(false, kpWithIV);
      } else
         cbcCipher.init(false, keyParameter);
   
      int offset = 0;                  
      int processedBytesLength = 0;

      while (offset < encryptedData.length) {
         try {
            processedBytesLength = cbcCipher.processBlock( encryptedData, 
                                     offset,
                                     plainData, 
                                     offset
                                  );
            offset += processedBytesLength;
         } catch (Exception e) {
            e.printStackTrace();
         }//catch
      }
      
      return plainData;
   }//decrypt()

getMD5DigestValue()
清单 11 中显示的 getMD5DigestValue() 方法取一个输入数据字节数组,并返回一个用输入数据计算的 MD5 摘要值。

Bouncy Castle 加密库在一个名为 MD5Digest 的类中包含 MD5 摘要支持。使用 MD5Digest 类进行摘要计算需要四步:

  1. 首先,实例化一个 MD5Digest 对象。
  2. 然后,调用 MD5Digest 对象的 update() 方法,在调用同时传递要摘要的数据。
  3. 然后,实例化一个用来包含 MD5 摘要值输出字节数组。
  4. 最后,调用 MD5Digest 对象的 doFinal() 方法,同时传递输出字节数组。 doFinal() 方法计算摘要值并将它放到输出字节数组中。

清单 11. getMD5DigestValue() 方法

   public byte[] getMD5DigestValue (byte[] data)
   {
      MD5Digest digest = new MD5Digest();
      digest.update (data, 0, data.length);

      byte digestValue[] = new byte[digest.getDigestSize()];
      digest.doFinal(digestValue, 0);

      return digestValue;      
   }

decryptAndVerifyDigest()
回想一下在 第一篇文章图 3 和清单 2 中,KDC 服务器的票据响应包含一个名为 enc-part 的字段,它包装了一个名为 EncryptedData 的加密的数据结构。就像在第一篇文章的 图 3 的说明中描述的那样, EncryptedData 结构由三个字段组成。

清单 12 中显示的 decryptAndVerifyDigest() 方法取一个 EncryptedData 结构(实质上就是 enc-part 字段的内容)和一个解密密钥作为参数,并返回 EncryptedData 结构的纯文本表示。加密过程步骤如下:

第 1 步:注意在 第一篇文章的清单 2 中, EncryptedData 结构实际上是 etype、kvnocipher 字段的一个 SEQUENCE 。因此,第一步是检查输入字节数组是否是一个 SEQUENCE 。为此调用 isSequence() 方法。

第 2 步:如果输入字节数组是一个 SEQUENCE ,那么需要解析这个 SEQUENCE 并提取出其内容。调用 getContents() 方法以提取出 SEQUENCE 内容。

SEQUENCE 内容中,感兴趣的是第一个字段( etype ,特定于上下文的标签号 0),它表明了加密类型。使用了 getASN1Structure() 方法调用以从 SEQUENCE 内容中提取 etype 字段。

第 3 步:调用 getContents() 方法以提取 etype 字段的内容,这是一个 ASN.1 INTEGER 。再次调用 getContents() 方法以提取 INTEGER 的内容。然后将 INTEGER 内容传递给 getIntegerValue() 方法,这个方法返回 J2ME int 格式的 INETGER 内容。将 J2ME int 值存储为一个名为 eTypeValue 的变量。 eTypeValue int 指定在生成 EncryptedData 结构时使用的加密类型。

第 4 步:回想一下 Kerberos 客户机只支持一种加密类型 ―― DES-CBC ―― 它的标识号为 3。因此,我检查 eTypeValue 是否为 3。如果它不是 3(即服务器使用了非 DES-CBC 的加密算法), 那么 Kerberos 客户机就不能处理这一过程。

第 5 步:下一步是从 EncryptedData SEQUENCE 内容中提取第三个字段( cipher ,特定于上下文的标签号 2)。调用 getASN1Structure() 方法以完成这项任务。

第 6 步:下一步,调用 getContents() 方法提取 cipher 字段的内容。cipher 字段的内容是一个 ASN.1 OCTET STRING 。还需要再调用 getContents() 方法,以提取 OCTET STRING 的内容 。

第 7 步: OCTET STRING 内容是加密的,因此需要用前面讨论的 decrypt() 方法解密。

第 8 步:解密的数据字节数组由三部分组成。第一部分由前八位组成,它包含一个称为 confounder 的随机数。confounder 字节没有意义,它们只是帮助增加黑客的攻击的难度。

解密的数据的第 9 到第 24 个字节构成了第二部分,它包含一个 16 字节的 MD5 摘要值。这个摘要值是对整个解密的数据 ―― 其中16 个摘要字节(第二部分)是用零填充的 ―― 计算的。

第三部分是要得到实际纯文本数据。

因为第八步进行完整性检查,所以必须将解密的数据的第 9 到第 24 个字节用零填充,对整个数据计算一个 MD5 摘要值,并将摘要值与第二部分(第 9 到第 24 个字节)进行匹配。如果两个摘要值匹配,那么消息的完整性就得到验证。

第 9 步:如果通过了完整性检查,那么就返回解密的数据的第三部分(第 25 个字节到结束)。

清单 12. decryptAndVerifyDigest() 方法

   public byte[] decryptAndVerifyDigest (byte[] encryptedData, byte[] decryptionKey)
   {
      /****** Step 1: ******/
      if (isSequence(encryptedData[0])) {
         /****** Step 2: ******/
         byte[] eType = getASN1Structure(getContents(encryptedData), 
                                 CONTEXT_SPECIFIC, 0);
         if (eType != null) {
            /****** Step 3: ******/
            int eTypeValue = getIntegerValue(getContents(getContents(eType)));
            /****** Step 4: ******/
            if ( eTypeValue == 3) {
               /****** Step 5: ******/
               byte[] cipher = getASN1Structure(getContents(encryptedData),
                                 CONTEXT_SPECIFIC, 2);
               /****** Step 6: ******/
               byte[] cipherText = getContents(getContents(cipher));
               if (cipherText != null) { 
                  /****** Step 7: ******/
                  byte[] plainData = decrypt(decryptionKey,
                                       cipherText, null);
                  /****** Step 8: ******/
                  int data_offset = 24;
                  byte[] cipherCksum = new byte [16];

                  for (int i=8; i < data_offset; i++)
                     cipherCksum[i-8] = plainData[i];

                  for (int j=8; j < data_offset; j++)
                     plainData[j] = (byte) 0x00;

                  byte[] digestBytes = getMD5DigestValue(plainData);

                  for (int x =0; x < cipherCksum.length; x++) {
                     if (!(cipherCksum[x] == digestBytes[x]))
                        return null;
                  }
                  byte[] decryptedAndVerifiedData = new byte[plainData.length - data_offset];

                  /****** Step 9: ******/
                  for (int i=0; i < decryptedAndVerifiedData.length; i++)
                     decryptedAndVerifiedData[i] = plainData[i+data_offset];

                  return decryptedAndVerifiedData;
               } else
                  return null;
            } else
         return null;
         } else
            return null;
      } else
         return null;

   }//decryptAndVerifyDigest

从票据响应中提取票据和密钥
我们已经讨论了低层 ASN.1 处理以及低层加密支持方法,现在可以讨论如何用这些方法处理在前面用 清单 1 中的 getTicketResponse() 方法提取的票据响应了。

看一下 清单 13 中显示的 getTicketAndKey() 方法(它属于 KerberosClient 类)。这个方法取票据响应字节数组和一个解密密钥字节数组作为参数。这个方法从票据响应中提取票据和密钥。

getTicketAndKey() 方法返回一个名为 TicketAndKey 的类的实例(这是一个要从票据响应中提取的密钥和票据的包装器)。我在 清单 14 中已经展示了 TicketAndKey 类。这个类只有四个方法:两个子 setter 方法和两个 getter 方法。 setKey()getKey() 方法分别设置和获得密钥字节。 setTicket()getTicket() 方法分别设置和获得票据字节。

现在看一看在 清单 13getTicketAndKey() 方法中所发生的过程。回想在对 第一篇文章的图 4 和清单 2的讨论中,介绍了 Kerberos 密钥和票据是如何存储在票据响应中的。从票据响应中提取密钥是一个漫长的过程,包括以下步骤:

1. 首先,检查 ticketResponse 字节数组是否真的包含了票据响应。为此,我使用了 isASN1Structure() 方法。如果 isASN1Structure() 方法返回 false,那么它表明输入 ticketResponse 字节数组不是有效的票据响应。在这种情况下,不进行任何进行一步的处理并返回 null。

注意在 清单 13 中,我调用了两次 isASN1Structure() 方法。第一调用 isASN1Structure() 方法时用“11”作为第三个参数的值,而第二次调用 isASN1Structure() 方法时,用“13”作为第三个参数的值。这是因为“11”是 TGT 响应的特定于应用程序的标签号(本系列的 第一篇文章的清单 2),而“13”是服务票据响应的特定于应用程序的标签号(本系列的 第一篇文章的清单 4)。如果 ticketResponse 字节数组是一个 TGT 响应或者服务票据响应,那么这两次方法调用之一会返回 true,就可以进行进一步的处理。如果这两个方法调用都不返回 true,那么表明 ticketResponse 字节数组不是一个票据响应,就要返回 null 并且不做任何进一步的处理。

2. 第二步是提取票据响应结构的内容。为此,我使用了 getContents() 方法调用。

3. 票据响应的内容应当是一个 ASN.1 SEQUENCE ,可以调用 isSequence() 方法对此进行检查。

4. 接下来,我调用 getContents() 方法提取 SEQUENCE 的内容。

5. SEQUENCE 的内容是票据响应的七个结构(如图 3 和 第一篇文章的清单 2所示)。在这七个结构之外,只需要两个:ticket 和 enc-part。

因此,第五步是从 SEQUENCE 内容中提取 ticket 字段(调用 getASN1Structure() 方法),提取 ticket 字段(调用 getContents() 方法)的内容,并将内容存储到在前面创建的 TicketAndKey 对象中。注意 ticket 字段是特定于上下文的标签号 5,而这个字段的内容是实际的票据,它以一个应用程序级别的标签号 1 开始,如 第一篇文章的清单 3 和图 9所示。

6. 下面,必须从在第 4 步中得到的 SEQUENCE 内容中提取密钥。这个键在在 SEQUENCE 内容的 enc-part 字段中。因此,在第 6 步,我调用 getASN1Structure() 方法从 SEQUENCE 内容中捕捉 enc-part 字段。

7. 得到了 enc-part 字段后,就要调用 getContents() 方法得到其内容。 enc-part 字段的内容构成了一个 EncryptedData 结构。

8. 可以向 decryptAndVerifyDigest() 方法传递 EncryptedData 结构,这个方法解密 EncryptedData 结构并对 EncryptedData 进行一个摘要验证检查。

9. 如果成功进行了解密和摘要验证过程,那么 decryptAndVerifyDigest() 方法就从已解密的密文数据中提取了 ASN.1 数据。ASN.1 数据应当符合我在 第一篇文章的图 4中展示的结构。注意所需要的密钥是 第一篇文章的图 4中显示的结构中的第一个字段。一个应用程序级别的标签号“25”或者“26”包装纯文本数据。这个结构称为 EncKDCRepPart (加密的 KDC 回复部分)。

这样,下一步就是检查由 decryptAndVerifyDigest() 方法返回的数据是否是一个应用程序级别的标签号 25 或者 26。

10. 下一步是提取 EncKDCRepPart 结构的内容 。调用 getContents() 方法提取所需要的内容。

EncKDCRepPart 内容是一个 SEQUENCE ,所以还必须提取 SEQUENCE 内容 。再一次调用 getContents() 方法以提取 SEQUENCE 内容。

11. SEQUENCE 内容的第一个字段(称为 key,具有上下文特定的标签号 0)包含 key 字段。可以调用 getASN1Structure() 方法以从 SEQUENCE 内容中提取第一个字段。

12. 下面,提取 key 字段的内容。调用 getConents() 方法可以返回这些内容。

key 字段的内容构成另一个名为 EncryptionKey 的 ASN.1 结构,它是一个两字段 ―― 即 keytypekeyvalue ―― 的 SEQUENCE 。再一次调用 getContents() 方法提取 SEQUENCE 的内容。

13. 所需要的会话密钥在 SEQUENCE 内容的第二个字段中( keyvalue )。因此,必须调用 getASN1Structure() 方法以从 SEQUENCE 内容中提取 keyvalue 字段(特定于上下文的标签号 1)。

14. 现在已经有了 keyvalue 字段。必须调用 getContents() 方法提取它的内容。 keyvalue 内容是一个 OCTET STRING ,所以必须再次调用 getContents() 方法以提取 OCTET STRING 的内容,它就是所要找的那个密钥。

所以只要将这个密钥字节包装在 KeyAndTicket 对象中(通过调用其 setKey() 方法)并返回 KeyAndTicket 对象。

清单 13. getTicketAndKey() 方法

   public TicketAndKey getTicketAndKey( byte[] ticketResponse, byte[] decryptionKey)
   {
     TicketAndKey ticketAndKey = new TicketAndKey();
     int offset = 0;

     /***** Step 1:*****/
     if ((isASN1Structure(ticketResponse[0], APPLICATION_TYPE, 11)) ||
        (isASN1Structure(ticketResponse[0], APPLICATION_TYPE, 13)))  {
       try {
         /***** Step 2:*****/
         byte[] kdc_rep_sequence = getContents(ticketResponse);

         /***** Step 3:*****/
         if (isSequence(kdc_rep_sequence[0])) {
            /***** Step 4:*****/
            byte[] kdc_rep_sequenceContent = getContents(kdc_rep_sequence);

            /***** Step 5:*****/
            byte[] ticket = getContents(getASN1Structure(kdc_rep_sequenceContent,
                                   CONTEXT_SPECIFIC, 5));
            ticketAndKey.setTicket(ticket);

            /***** Step 6:*****/
            byte[] enc_part = getASN1Structure(kdc_rep_sequenceContent,
                                       CONTEXT_SPECIFIC, 6);
            if (enc_part!=null) {
              /***** Step 7:*****/
              byte[] enc_data_sequence = getContents(enc_part);
                  
              /***** Step 8:*****/
              byte[] plainText = decryptAndVerifyDigest(enc_data_sequence, 
                                               decryptionKey);
                if (plainText != null){
                  /***** Step 9:*****/
                  if ((isASN1Structure(plainText[0],APPLICATION_TYPE, 25)) ||
                      (isASN1Structure(plainText[0], APPLICATION_TYPE, 26))) {
                     /***** Step 10:*****/
                     byte[] enc_rep_part_content = getContents(getContents(plainText));
                     /***** Step 11:*****/
                     byte[] enc_key_structure = getASN1Structure(enc_rep_part_content,
                                           CONTEXT_SPECIFIC, 0);
                     /***** Step 12:*****/
                     byte[] enc_key_sequence = getContents(getContents(enc_key_structure));
                     
                     /***** Step 13:*****/
                     byte[] enc_key_val = getASN1Structure(enc_key_sequence,
                                       CONTEXT_SPECIFIC, 1);
                     /***** Step 14:*****/
                     byte[] enc_key = getContents(getContents(enc_key_val));

                     ticketAndKey.setKey(enc_key);
                     return ticketAndKey;
                  } else
                     return null;
              } else 
                return null;
            } else 
              return null;
         } else 
            return null;               
       } catch (Exception e) {
         e.printStackTrace();
       }
          
       return null;

     } else
       return null;
   }//getTicketAndKey()

清单 14. TicketAndKey 类

public class TicketAndKey 
{
   private byte[] key;
   private byte[] ticket;

   public void setKey(byte[] key)
   {
      this.key = key;
   }//setKey()
	
   public byte[] getKey()
   {
      return key;
   }//getKey

   public void setTicket(byte[] ticket) 
   {
      this.ticket = ticket;
   }//setTicket
	
   public byte[] getTicket()
   {
      return ticket;
   }//getTicket
}

得到一个服务票据
已经处理了 TGT 响应并提取了 TGT 和会话密钥。现在可以使用这个 TGT 和会话密钥向 KDC 服务器请求一个服务票据。对服务票据的请求类似于对我在清单 1 中生成的对 TGT 的请求。我在 TGT 请求中省略的可选 padata 字段在服务票据请求中不再是可选的了。因此,需要在服务票据请求中加上 padata 字段。

padata 字段是包含两个字段 ―― padata-typepadata-value ―― 的 SEQUENCEpadata-value 字段带有几种类型的数据,因此相应的 padata-type 字段指定了 padata-value 字段所带的数据的类型。

本系列的第一篇文章的图 5 中我介绍了服务票据中的 padata 字段的结构。在那里说过服务票据请求中的 padata 字段包装了一个认证头(一个 KRB_AP_REQ 结构),它又包装了 TGT 以及其他数据。

所以,在可以开始生成票据请求之前,必须生成一个认证头。下面是分析了生成认证头的过程。

生成一个认证头
我在 KerberosClient 类中加入了以下方法以生成一个认证头:

  1. getMD5DigestValue()
  2. getChceksumBytes()
  3. authorDigestAndEncrypt()
  4. getAuthenticationHeader()

这四个方法都是 helper 方法。第五个方法( getAuthenticationHeader() )使用 helper 方法并生成认证头。

authorDigestAndEncrypt()
清单 15 显示的 authorDigestAndEncrypt() 方法取一个纯文本数据字节数组和一个加密密钥。这个方法对纯文本数据计算一个摘要值、加密纯文本数据、并返回一个 EncryptedData 结构,这个结构与我作为输入传递给 清单 12decryptAndVerifyDigest() 方法的结构完全匹配。

可以说 清单 15authorDigestAndEncrypt() 方法与前面讨论的 decryptAndVerifyDigest() 方法正好相反。 authorDigestAndEncrypt() 方法取 decryptAndVerifyDigest() 方法返回的纯文本数据作为输入。与此类似, authorDigestAndEncrypt() 方法返回的 EncryptedData 结构就是我作为输入传递给 decryptAndVerifyDigest() 方法的结构。

authorDigestAndEncrypt() 方法实现了以下策略:

  1. 首先,生成八个随机字节,它们构成了 confounder。
  2. 然后,声明一个名为 zeroedChecksum 的字节数组,它有十六个字节并初始化为零。这个有十六个零的数组作为一个全为零的摘要值。
  3. 第三,用其他的字节填入输入数据字节数组,以使数组中的字节数成为八的倍感数。编写了一个名为 getPaddedData() 的方法(如 清单 16所示),它取一个字节数组并在填充后返回这个数组。下面,链接(第 1 步得到的)confounder、(第 2 步得到的)全为零的摘要以及填充后的纯文本字节数组。
  4. 第四步是对第 3 步串接的字节数组计算 MD5 摘要值。
  5. 第五步是将摘要字节放到它们相应的位置上。第 5 的结果与第 3 步一样,只不过全为零的摘要现在换成了真正的摘要值。
  6. 现在调用 encrypt() 方法以加密第 5 步得到的字节数组。
  7. 然后,生成 etype 字段(特定于上下文的标签号 0)。
  8. 然后,调用 getOctetStringBytes() 方法将第 6 步得到的加密字节数组包装到 OCTET STRING 中。然后将 OCTET STRING 包装到 cipher 字段中(一个特定于上下文的标签号 2)。
  9. 最后,链接 etypecipher 字段,将这个字符串包装到一个 SEQUENCE 中,并返回这个 SEQUENCE

清单 15. authorDigestAndEncrypt() 方法

   public byte[] authorDigestAndEncrypt(byte[] key, byte[] data)
   {
     /****** Step 1: ******/
     byte[] conFounder = concatenateBytes (getRandomNumber(), getRandomNumber());  
     /****** Step 2: ******/
     byte[] zeroedChecksum = new byte[16];
     /****** Step 3: ******/
     byte[] paddedDataBytes = concatenateBytes (conFounder, 
                               concatenateBytes(zeroedChecksum, 
                                 getPaddedData(data)
                               )
                             );
     /****** Step 4: ******/
     byte[] checksumBytes = getMD5DigestValue(paddedDataBytes);
     /****** Step 5: ******/
     for (int i=8; i < 24; i++)
       paddedDataBytes[i] = checksumBytes[i-8];
     /****** Step 6: ******/
     byte[] encryptedData = encrypt(key, paddedDataBytes, null);
     /****** Step 7: ******/
     byte[] etype = getTagAndLengthBytes(
                     ASN1DataTypes.CONTEXT_SPECIFIC,
                     0, getIntegerBytes(3)
                   );
     /****** Step 8: ******/
     byte[] cipher = getTagAndLengthBytes(
                      ASN1DataTypes.CONTEXT_SPECIFIC,
                      2, getOctetStringBytes(encryptedData)
                    );
     /****** Step 9: ******/
     byte[] ASN1_encryptedData = getSequenceBytes (
                                  concatenateBytes(etype,cipher)
                                );
     return ASN1_encryptedData; 
   }//authorDigestAndEncrypt

清单 16. getPaddedData() 方法

   public byte[] getPaddedData(byte[] data) {
      int numberToPad = 8 - ( data.length % 8 );
      if (numberToPad > 0 && numberToPad != 8)
      {
         byte[] bytesPad =  new byte[numberToPad];
         for (int x = 0; x < numberToPad; x++)
            bytesPad [x] = (byte)numberToPad;

         return    concatenateBytes(data, bytesPad);
      }
      else
         return data;
   }//getPaddedData()

getChecksumBytes()
getChecksumBytes() 方法生成一个称为 Checksum 的结构,如 清单 17 所示。Checksum 结构包含两个字段: cksumtypechecksum

清单 17. Checksum 结构

   Checksum ::= SEQUENCE {
                cksumtype[0]   INTEGER,
                checksum[1]    OCTET STRING
   }+

有两个地方需要 Checksum 结构 ―― 第一个是生成服务票据响应时,然后是生成安全上下文建立请求时。Checksum 结构的作用在这两种情况下是不同的,需要在生成服务票据和上下文建立请求时说明(elaborate)。

清单 18 所示的 getChecksumBytes() 方法取两个字节数组参数。第一个参数带有 checksum 字段,而第二个参数带有 cksumtype 字段。

getChecksumBytes() 方法将 cksumtype 字段包装到一个特定于上下文的标签号 0(它表示 cksumtype 字段,如 清单 17 所示),而将 checksum 字段包装到一个特定于上下文的标签号 1(它表示 checksum 字段,同样如 清单 17 所示)。然后它链接这两个字段,将这个数组包装到一个 SEQUENCE 中,并返回这个 SEQUENCE

清单 18. getChecksumBytes() 方法

   public byte[] getChecksumBytes(byte[] cksumData, byte[] cksumType){
      byte[] cksumBytes = getTagAndLengthBytes (
                           ASN1DataTypes.CONTEXT_SPECIFIC, 3,
                           getSequenceBytes (
                               concatenateBytes (
                                getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                                                      0,
                                                      cksumType
                                ),
                                getTagAndLengthBytes(
                                  ASN1DataTypes.CONTEXT_SPECIFIC, 1,
                                    getOctetStringBytes(cksumData)
                                )
                              )
                           )
                        );
      return cksumBytes;
   }//getChecksumBytes()

getAuthenticationHeader()
本系列的第一篇文章 中的“服务票据请求”一节中,介绍过 KRB-AP-REQ 结构(也称为认证头)包装了 Kerberos 票据。此外,认证头还包装了 authenticator 字段,它表明客户机是否掌握了 会话 或者 子会话 密钥

第一篇文章的图 5 所示,认证头由五个字段组成,即 pvno、msg-type、ap-options、ticketauthenticator

清单 19getAuthenticationHeader() 方法逐一生成这五个字段,然后以正确的顺序将各个字段串接起来以形成一个完整的认证头。

清单 19. getAuthenticationHeader() 方法

   public byte[] getAuthenticationHeader( byte[] ticketContent,
                                 String clientRealm,
                                 String clientName,
                                 byte[] checksumBytes,
                                 byte[] encryptionKey,
                                 int sequenceNumber
                               )
   {
      byte[] authenticator = null;
      byte[] vno = getTagAndLengthBytes (
                      ASN1DataTypes.CONTEXT_SPECIFIC,
                         0, getIntegerBytes(5)
                   );
      byte[] ap_req_msg_type = getTagAndLengthBytes(
                                 ASN1DataTypes.CONTEXT_SPECIFIC,
                                 1, getIntegerBytes(14)
                               );
      byte[] ap_options = getTagAndLengthBytes(
                           ASN1DataTypes.CONTEXT_SPECIFIC,
                           2, getBitStringBytes(new byte[5])
                        );
      byte[] ticket = getTagAndLengthBytes(
                        ASN1DataTypes.CONTEXT_SPECIFIC,
                        3, ticketContent
                     );
      byte[] realmName = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                          1, getGeneralStringBytes(clientRealm)
                       );
      byte[] generalStringSequence = getSequenceBytes(
                                        getGeneralStringBytes (clientName)
                                     );
      byte[] name_string = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                              1, generalStringSequence
                           );
      byte[] name_type = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                       0, getIntegerBytes(ASN1DataTypes.NT_PRINCIPAL)
                    );
      byte[] clientNameSequence = getSequenceBytes(
                                     concatenateBytes (name_type, name_string)
                                  );
      byte[] cName =  getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                       2, clientNameSequence);
      byte[] cusec = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                        4, getIntegerBytes(0)
                     );
      byte[] ctime = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                       5, getGeneralizedTimeBytes (
                          getUTCTimeString(System.currentTimeMillis()).getBytes()
                       )
                );
        
      if (sequenceNumber !=0 ) {
         byte[] etype = getTagAndLengthBytes (
                         ASN1DataTypes.CONTEXT_SPECIFIC,
                      0, getIntegerBytes(3)
                      );
         byte[] eKey = getTagAndLengthBytes (
                        ASN1DataTypes.CONTEXT_SPECIFIC,
                           1, getOctetStringBytes(encryptionKey)
                     );
         byte[] subKey_sequence = getSequenceBytes (concatenateBytes(etype, eKey));
         byte[] subKey = getTagAndLengthBytes(
                           ASN1DataTypes.CONTEXT_SPECIFIC,
                           6, subKey_sequence
                        );
         byte[] sequenceNumberBytes = {
            (byte)0xff,
            (byte)0xff,
            (byte)0xff,
            (byte)0xff
         };
    
         sequenceNumberBytes[3] = (byte)sequenceNumber;
         byte[] seqNumber = getTagAndLengthBytes(
                               ASN1DataTypes.CONTEXT_SPECIFIC,
                               7, getIntegerBytes(sequenceNumberBytes)
                            );
         authenticator = getTagAndLengthBytes(ASN1DataTypes.APPLICATION_TYPE,
                          2, getSequenceBytes(
                             concatenateBytes(vno,
                                concatenateBytes(realmName,
                                   concatenateBytes(cName,
                                      concatenateBytes(checksumBytes,
                                         concatenateBytes(cusec,
                                            concatenateBytes(ctime,
                                               concatenateBytes(subKey,seqNumber)
                                            )
                                         )
                                      )
                                   )
                                )
                             )
                          )
                       );
         } else {
          authenticator = getTagAndLengthBytes(ASN1DataTypes.APPLICATION_TYPE,
                  2, getSequenceBytes(
                        concatenateBytes(vno,
                     concatenateBytes(realmName,
                              concatenateBytes(cName,
                                 concatenateBytes(checksumBytes,
                                    concatenateBytes(cusec,ctime)
                                 )
                              )
                           )
                        )
                     )
                  );
         }//if (sequenceNumber !=null)

         byte[] enc_authenticator = getTagAndLengthBytes(
                                     ASN1DataTypes.CONTEXT_SPECIFIC,
                                      4, authorDigestAndEncrypt(encryptionKey, authenticator)
                                    );
         byte[] ap_req = getTagAndLengthBytes (
                           ASN1DataTypes.APPLICATION_TYPE,
                              14, getSequenceBytes(
                                 concatenateBytes (vno,
                                    concatenateBytes(ap_req_msg_type,
                                       concatenateBytes(ap_options,
                                          concatenateBytes(ticket, enc_authenticator)
                                        )
                                     )
                                  )
                               )
                           );
       return ap_req;
   }//getAuthenticationHeader

getAuthenticationHeader() 方法有几个输入参数:

  1. 名为 ticketContent 的字节数组,它包含由 getAuthenticationHeader() 方法包装到认证头的 Kerberos 票据( TGT )。
  2. 名为 clientRealm 的字符串类型参数,它指定(生成这个请求的)Kerberos 客户机所注册的域(realm )的名字。
  3. 名为 clientName 的字符串类型参数指定生成这个请求的 Kerberos 客户机的名字。
  4. checksumBytes 字节数组携带一个 Checksum 结构以及 getChecksumBytes() 方法。
  5. encryptionKey 字节数组携带用于生成认证头的加密部分的加密密钥。
  6. 名为 sequenceNumber 的参数是一个 integer 值,它标识发送者的请求号。

第一篇文章的图 5 介绍过,认证头包含以下字段:

  • pvno
  • msg-type
  • ap-options
  • ticket
  • authenticator

现在让我们看看 清单 19 中的 getAuthenticationHeader() 方法实现是如何生成认证头的各个字段的( KRB-AP-REQ 结构):

首先要生成 pvno 字段,它有特定于上下文的标签号 0,并包装一个值为 5 的 ASN1 INTEGER 。调用 getTagAndLengthBytes() 方法执行这项任务。我将 pvno 字段存储 在一个名为 vno 的字节数组中。

类似地,两次调用 getTagAndLengthBytes() 方法生成 msg-type (特定于上下文的标签号 1)和 ap-options 字段(特定于上下文的标签号 2)。

下一行( byte[] ticket = getTagAndLengthBytes(ASN1DataTypes.Context_Specific, 3, ticketContent) )将票据结构包装到特定于上下文的标签号 3 中,这是认证头的第四个字段。

然后,必须生成认证头的第五个字段(名为 authenticator ,它有特定于上下文的标签号 4)。authenticator 字段是一个 EncryptedData 结构。authenticator 字段的纯文本格式是一个 Authenticator 结构。因此,首先生成纯文本格式的完整 Authenticator 结构,将这个纯文本 Authenticator 传递给 authorDigestAndEncrypt() 方法,这个方法返回 Authenticator 的完整 EncryptedData 表示。

注意在 第一篇文章中的清单 3 和图 5 中,纯文本格式的 Authenticator 结构由以下字段组成(省略最后一个字段,它是不需要的):

  • authenticator-vno
  • creal
  • cname
  • cksum
  • cusec
  • ctime
  • subkey
  • seq-number

在解释 第一篇文章的图 5时,我已经介绍了每一个字段的意义。

authenticator-vno 字段与 pvno 字段完全相同(本节前面讨论了 vno 字节数组,它包含特定于上下文的标签号 0 且带值为 5 的 INTEGER )。所以我重用了在 authenticator_vno 字段中使用的同一个字节数组。

现在该生成 crealm 字段了,它类似于我在 第二篇 文章“生成请求正文”一节中介绍的 realm 字段。同样,在那一节也介绍了 PrincipalName 类型的 cname 字段。在这里我就不介绍 crealmcname 字段的生成细节了。

下一项任务是生成 cksum 字段,它是 Checksum 类型。服务票据请求中的 cksum 字段的作用是加密结合 authenticator 与一些应用程序数据。注意以下三点:

  1. authenticator 结构包含 cksum 字段。
  2. cksum 字段包含一些应用程序数据的加密哈希值。
  3. 整个 authenticator 结构(包括 cksum 字段)是用一个密钥加密的。

只要在 authenticator 中的 cksum 字段与对应用程序数据的加密 checksum 相匹配,就证明生成 authenticator 和应用程序数据的客户机拥有密钥。

调用 getAuthenticationHeader() 方法的应用程序(通过调用 getChecksumBytes() 方法)生成 Checksum 结构,并将 Checksum 字节数组作为 checksumBytes 参数的值传递给 getAuthenticationHeader() 方法。

结果, checksumBytes 参数中就有了 Checksum 结构。只需要将 checksumBytes 包装到特定于上下文的标签号 3 中(这是 authenticator 结构中 cksum 字段的标签号)。

现在生成 cusec 字段,它表示客户机时间的微秒部分。这个字段的取值范围为 0 到 999999。这意味着可以在这个字段提供的最大值为 999999 微秒。不过,MIDP 不包含任何可以提供比一毫秒更精确的时间值的方法。因此,不能指定客户机的微秒部分。只是对这个字段传递一个零值。

在 Authenticator 结构中,还要生成两个字段 ―― subkeyseq-number 。在为票据请求而生成的 Authenticator 中不一定要包含这两个字段,但是后面在用同一个 getAuthenticationHeader() 方法生成上下文建立请求时需要它们。

现在,只需知道只要检查 sequenceNumber 参数是否为零。对于服务票据请求它为零,对于上下文建立请求它为非零。

如果 sequenceNumber 参数为非零,那么就生成 subkeyseq-number 字段,然后链接 authenticator-vno、 realm、cname、cksum、cusec、ctime、subkeyseq-number 字段以构成一个字节数组,将这个字节数组包装到一个 SEQUENCE 中,然后将 SEQUENCE 包装到 Authenticator 中(应用程序级别标签号 2)。

如果 sequenceNumber 参数为零,那么可以忽略 subkeyseq-number 字段,链接 authenticator-vno、crealm、cname、cksum、cusecctime 字段以构成串接的字节数组,将这个字节数组包装到一个 SEQUENCE 中,然后将这个 SEQUENCE 包装到 Authenticator 中(应用程序级别标签号 2)。

下面,需要取完整的 Authenticator 结构并将它传递 authorDigestAndEncrypt() 方法,这个方法返回纯文本 Authenticator 的完整 EncryptedData 表示。

下一个任务是串接认证头或者 KRB-AP-REQ 结构的五个字段( pvno、msg-type、ap-options、ticket、authenticator )为一个字节数组,将这个字节数组包装为一个 SEQUECNE ,最后将这个 SEQUENCE 包装到应用程序级别的标签号 14。

现在已经完成认证头,可以将它返回给给调用应用程序了。

生成服务票据请求
我讨论了生成服务票据请求需要的所有低层方法。将使用 清单 1 中请求 TGT 时所使用的同一个 getTicketResponse() 方法生成服务票据请求,只需要对 清单 1 稍加修改以使它可以同时用于 TGT 和服务票据请求。让我们看一下这个过程。

看一下 清单 20,其中可以看到修改过的清单 1 中的 getTicketRespone() 方法。与 清单 1相比,修改过的版本增加了一些代码:

清单 20. getTicketResponse() 方法

   public byte[] getTicketResponse( String userName,
                                    String serverName,
                                    String realmName,
                                    byte[] kerberosTicket,
                                    byte[] key
                                  )
   {
      byte ticketRequest[];
      byte msg_type[];

      byte pvno[] = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                      1, getIntegerBytes(5));
      msg_type = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                    2, getIntegerBytes(10));
      byte kdc_options[] = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                             0, getBitStringBytes(new byte[5]));
      byte generalStringSequence[] = getSequenceBytes (
                                        getGeneralStringBytes (userName));
      byte name_string[] = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                             1, generalStringSequence);
      byte name_type[] = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                            0, getIntegerBytes(ASN1DataTypes.NT_PRINCIPAL));
      byte principalNameSequence [] = getSequenceBytes(
                               concatenateBytes (name_type, name_string));
      byte cname[] = getTagAndLengthBytes (ASN1DataTypes.CONTEXT_SPECIFIC,
                        1, principalNameSequence);
      byte realm[] = getTagAndLengthBytes (ASN1DataTypes.CONTEXT_SPECIFIC,
                        2, getGeneralStringBytes (realmName));
      byte sgeneralStringSequence[] = concatenateBytes(getGeneralStringBytes(serverName),
                                         getGeneralStringBytes (realmName));
      byte sname_string[] = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                              1, getSequenceBytes(sgeneralStringSequence));
      byte sname_type[] = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                            0, getIntegerBytes(ASN1DataTypes.NT_UNKNOWN));
      byte sprincipalNameSequence [] = getSequenceBytes(
                                  concatenateBytes (sname_type, sname_string)
                               );
      byte sname[] = getTagAndLengthBytes (ASN1DataTypes.CONTEXT_SPECIFIC,
                       3, sprincipalNameSequence);
      byte till[] = getTagAndLengthBytes (
                      ASN1DataTypes.CONTEXT_SPECIFIC,
                      5,
                      getGeneralizedTimeBytes (
                      new String("19700101000000Z").getBytes())
                    );
      byte nonce[] = getTagAndLengthBytes(
                        ASN1DataTypes.CONTEXT_SPECIFIC,
                        7,
                        getIntegerBytes (getRandomNumber())
                     );

      byte etype[] = getTagAndLengthBytes(
                        ASN1DataTypes.CONTEXT_SPECIFIC,
                        8,
                        getSequenceBytes(getIntegerBytes(3))
                     );
      byte req_body[] = getTagAndLengthBytes(
                          ASN1DataTypes.CONTEXT_SPECIFIC,
                          4,
                          getSequenceBytes(
                             concatenateBytes(kdc_options,
                                concatenateBytes(cname,
                                   concatenateBytes(realm,
                                      concatenateBytes(sname,
                                         concatenateBytes(till,
                                            concatenateBytes(nonce, etype)
                                          )
                                      )
                                   )
                                )
                             )
                          )
                       );
       if (kerberosTicket != null) {
          msg_type = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                        2, getIntegerBytes(12));

          sname_string = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                           1, getSequenceBytes(getGeneralStringBytes(serverName)));

          sname_type = getTagAndLengthBytes(ASN1DataTypes.CONTEXT_SPECIFIC,
                          0, getIntegerBytes(ASN1DataTypes.NT_UNKNOWN));
   
          sprincipalNameSequence = getSequenceBytes(
                                      concatenateBytes (sname_type, sname_string)
                                   );

          sname = getTagAndLengthBytes (
                    ASN1DataTypes.CONTEXT_SPECIFIC,
                    3, sprincipalNameSequence
                  );
          byte[] req_body_sequence = getSequenceBytes(
                                       concatenateBytes(kdc_options,
                                          concatenateBytes(realm,
                                            concatenateBytes(sname,
                                              concatenateBytes(till,
                                                concatenateBytes(nonce, etype)
                                              )
                                           )
                                         )
                                       )
                                     );
          req_body = getTagAndLengthBytes (
                         ASN1DataTypes.CONTEXT_SPECIFIC,
                         4, req_body_sequence
                     );
          byte[] cksum = getChecksumBytes(
                            getMD5DigestValue(req_body_sequence), 
                            getIntegerBytes(7)
                        );
          byte[] authenticationHeader = getAuthenticationHeader(
                                          kerberosTicket,
                                          realmName,
                                          userName,
                                          cksum,
                                          key,
                                          0
                                       );
          byte[] padata_sequence = getSequenceBytes(concatenateBytes(
                                    getTagAndLengthBytes(
                                       ASN1DataTypes.CONTEXT_SPECIFIC,
                                    1,getIntegerBytes(1)),
                                    getTagAndLengthBytes(
                                          ASN1DataTypes.CONTEXT_SPECIFIC,
                                             2, getOctetStringBytes(authenticationHeader)
                                       )
                                    )
                                 );
          byte[] padata_sequences = getSequenceBytes(padata_sequence);
          byte[] padata = getTagAndLengthBytes(
                            ASN1DataTypes.CONTEXT_SPECIFIC,
                               3, padata_sequences
                         );
          ticketRequest = getTagAndLengthBytes(
                            ASN1DataTypes.APPLICATION_TYPE,
                            12, getSequenceBytes(
                               concatenateBytes(pvno,
                                  concatenateBytes(msg_type,
                                     concatenateBytes(padata, req_body)
                                  )
                               )
                            )
                         );
       } else {
          ticketRequest = getTagAndLengthBytes(
                            ASN1DataTypes.APPLICATION_TYPE,
                            10, getSequenceBytes(
                               concatenateBytes(pvno,
                                  concatenateBytes(msg_type, req_body)
                               )
                            )
                         );
       }

      try {
          Datagram dg = dc.newDatagram(ticketRequest, ticketRequest.length);
          dc.send(dg);
       } catch (IllegalArgumentException il) {
             il.printStackTrace();
       } 
       catch (Exception io) {
             io.printStackTrace();
       } 

       byte ticketResponse[] = null;

       try {
          Datagram dg = dc.newDatagram(700);
          dc.receive(dg);

          if (dg.getLength() > 0) {
             ticketResponse = new byte[dg.getLength()];
             System.arraycopy(dg.getData(), 0, ticketResponse, 0, dg.getLength());
          } else 
            return null;
      } catch (IOException ie){
         ie.printStackTrace();
      }

     return ticketResponse;
   }//getTicketResponse

清单 20 中显示的新的 getTicketResponse() 方法有五个参数: userName、serverName、realmName、kerberosTicketkey 。要请求一个服务票据,需要传递 kerberosTicket 字节数组的 TGT 。另一方面,在请求 TGT 时,不必传递一个票据,因此对于 kerberosTicket 字节数组传递“null”。

TGT 请求与服务票据请求的主要区别是 padata 字段。在本系列 第一篇文章 中的“请求服务票据”一节中讨论服务票据请求的 padata 字段时已经介绍过。

getTicketResponse() 的最后,我加入了一个 if (kerberosTicket!=null) 块。只有在 kerberosTicket 参数不为 null 时才进入这个块(在所有 TGT 请求中它都是 null)。

if (kerberosTicket!=null) 块中,我生成了 padata 字段。正如 第一篇文章的图 5 中描述的,这个 padata 字段包装一个可由 getAuthenticationHeader() 方法生成的认证头。

getAuthenticationHeader() 方法中还可了解到,为了生成一个认证头,需要一个可由 getChecksumBytes() 方法生成的 Checksum 结构。

现在,回想一下在讨论 getChecksumBytes() 方法时说过,为了生成一个 Checksum 结构,需要用于 cksumtypechecksum 字段的数据。

因此,生成一个认证头需要三步:

  1. 生成 cksumtypechecksum 字段的数据。如果是服务票据请求,那么 checksum 字段的数据只是对包含服务票据请求的 req-body 字段的所有子字段的 SEQUENCE 计算的 MD5 摘要值(注意在 第一篇文章的图 5, req-body 是服务票据请求的第四个字段,就在服务票据请求的第三个字段 padata 字段后面)。 cksumtype 字段的数据是 integer 7 的 ASN1 表示。这个值指定 checksum 的类型。
  2. 调用 getChecksumBytes() 方法并传递 cksumtypechecksum 字段的数据。 getChecksumBytes() 方法生成完整的 Checksum 结构。
  3. 调用 getAuthenticationHeader() 方法,同时传递 Checksum 结构。 getAuthenticationHeader() 返回认证头。

生成了认证头后,必须将它包装到一个 padata 字段中。为此,有几件事要做:

  1. 调用我在 第二篇文章的清单 5 中描述的 getOctetStringBytes() 方法将认证头包装到一个 OCTET STRING 中。
  2. 将这个 OCTET STRING 包装到 padata-value 字段中(特定于上下文的标签号 2),调用 getTagAndLengthBytes() 方法以完成这项任务。
  3. 再次调用 getTagAndLengthBytes() 方法生成对应于第 2 步生成的 padata-valuepadata-type 字段。
  4. 现在,链接 padata-typepadata-value 字段。
  5. 将第 4 步链接的字节数组放入一个 SEQUENCE 中。这个 SEQUENCE 表示一个 PADATA 结构(如 第一篇文章的图 5 和清单 3所示)。
  6. 第一篇文章的图 5 和清单 3 中显示的 padata 字段是 PADATA 结构的一个 SEQUENCE 。这意味着一个 padata 字段可以包含几个 PADATA 结构。不过,只有一个 PADATA 结构要包装到 padata 字段中,这意味着只要将第 5 步得到的 SEQUENCE 包装到另一个外部或者更高层的 SEQUENCE 中。
  7. 第 6 步的更高层 SEQUENCE 表示 PADATA 结构的 SEQUENCE ,现在可以将它包装到 padata 字段中(一个特定于上下文的标签号 3)。

清单 20 的结尾处的 if (kerberosTicket!=null) 块中可以找到 getTicketResponse() 方法中增加的所有新代码。

到此就结束了对于修改现有的 getTicketResponse() 方法以使它可同时用于 TGT 和服务票据请求的讨论。 getTicketResponse() 方法生成一个服务票据请求、将这个请求发送给 KDC 、接收服务票据响应、并将响应返回给调用应用程序。

从服务票据响应中提取服务票据和子会话密钥
服务票据响应类似于 TGT 响应。在 清单 13 中的 getTicketAndKey() 方法解析一个 TGT 响应以提取 TGT 和会话密钥。同一个方法也解析服务票据响应以从服务票据响应中提取服务票据和子会话密钥。所以,不必编写任何提取服务票据和子会话密钥的代码。

创建一个安全通信上下文
现在有了与电子银行的业务逻辑服务器建立安全通信上下文所需要的两项内容:子会话密钥和服务票据。这时 Kerberos 客户机必须生成针对电子银行的业务逻辑服务器的上下文建立请求。

参见 第一篇文章的图 7 和清单 5,它们描述了客户机为建立安全上下文而发送给电子银行服务器的消息。 清单 21 中显示的 createKerberosSession() 方法处理与电子银行的业务逻辑服务器建立安全通信上下文的所有方面(包括生成上下文建立请求、向服务器发送请求、从服务器中获得响应、解析响应以检查远程服务器是否同意上下文建立请求,并将这些工作的结果返回给调用应用程序)。

看一下 清单 21 中的 createKerberosSession() 方法,它有以下参数:

  1. ticketContent 字节数组带有准备用于建立安全上下文的服务票据。
  2. clientRealm 字符串包装了请求客户机所属的域 realm 的名字。
  3. clientName 字符串指定了请求客户机的名字。
  4. sequenceNumber 参数是一个表示这个消息序号(sequence number)的 integer
  5. encryptionKey:子会话密钥。
  6. inStreamoutStreamcreateKerberosSession() 方法用来与电子银行的服务器通信的输入输出流。

正如在第一篇文章中介绍的,要使用 Java-GSS 实现电子银行的服务器端逻辑。GSS-Kerberos 机制规定服务票据包装在一个认证头中,而这个认证头本身又包装在 第一篇文章的图 7 和清单 5 中显示的 InitialContextToken 包装器中。

可以使用 清单 19getAuthenticationHeader() 方法包装服务票据。回想一下在 清单 20getTicketResponse() 方法中我使用了 getAuthenticationHeader() 方法包装一个 TGT

为了生成认证头,需要一个 Checksum 。回想在讨论 清单 19getAuthenticationHeader() 方法时说过, Checksum 的目的是加密绑定认证头与一些应用程序数据。但是,与票据请求认证头不一样,上下文建立认证头不带有应用程序数据。

GSS-Kerberos 机制出于不同的目的使用 Checksum 结构。除了将认证头绑定到一些应用程序数据外,GSS-Kerberos 机制使用一个 Checksum 结构用物理网络地址(即客户机可以用来与服务器进行安全通信的网络地址)绑定安全上下文。如果使用这种功能,那么只能从它所绑定的网络地址上使用安全上下文。

不过,我不作准备在这个示例移动银行应用程序中使用这种功能。这就是为什么我在 Checksum 结构中指定安全上下文没有任何绑定的缘故。为此,我编写了一个名为 getNoNetworkBindings() 的方法,如 清单 22 所示。 getNoNetworkBindings() 方法非常简单。它只是生成一个硬编码的字节数组,表明不需要任何网络绑定。然后它调用 getChecksumBytes() 方法以将硬编码的数组包装到 cksum 字段中。

得到了无网络绑定的 Checksum 的字节数组后,可以将这个数组传递给 getAuthenticationHeader() 方法,这个方法返回完整的认证头。

生成了认证头后, 清单 21createKerberosSession() 方法将认证头字节数组与一个名为 gssHeaderComponents 的硬编码的字节数组相链接。 gssHeaderComponents 字节数组包含一个 GSS 头的字节表示,这个 GSS 头在上下文建立请求中将伴随一个认证头。

最后,将串接的 GSS 头和认证头包装到一个应用程序级别的标签号 0 中。GSS 要求所有上下文建立请求都包装到应用程序级别的标签号 0 中。

现在完成了上下文建立请求。下一项任务就是通过一个输出流( outStream 对象)发送这个请求。发送了请求后,监听并接收 inStream 对象上的响应。

createKerberosSession() 方法收到响应后,它就检查响应是确认创建一个新的上下文还是显示一个错误消息。要进行这种检查,必须知道消息开始标签字节后面的长度字节的字节数。 GSS 头字节(紧接着长度字节)提供了答案。

不用解析响应以进行任何进一步的处理。只是要知道电子银行的服务器是创建了一个新会话还是拒绝会话。如果电子银行的服务器确认创建新会话,那么 createKerberosSession() 方法就返回 true ,如果不是,它就返回 false

清单 21. createKerberosSession() 方法

   public boolean createKerberosSession (
                                 byte[] ticketContent, 
                                 String clientRealm, 
                                 String clientName,
                                 int sequenceNumber, 
                                 byte[] encryptionKey,
                                 DataInputStream inStream,
                                 DataOutputStream outStream
                               )
   {
      byte[] cksum = getNoNetworkBindings();
      if (sequenceNumber == 0)
         sequenceNumber++;
      byte[] authenticationHeader = getAuthenticationHeader(
                                      ticketContent,
                                      clientRealm,
                                      clientName, 
                                      cksum, 
                                      encryptionKey,
                                      sequenceNumber
                                   );
      byte[] gssHeaderComponents = {
         (byte)0x6,  
         (byte)0x9,  
         (byte)0x2a,
         (byte)0xffffff86,
         (byte)0x48,
         (byte)0xffffff86,
         (byte)0xfffffff7,
         (byte)0x12,
         (byte)0x1,
         (byte)0x2,
         (byte)0x2,
         (byte)0x1,  
         (byte)0x0
      };
      byte[] contextRequest = getTagAndLengthBytes(
                                 ASN1DataTypes.APPLICATION_TYPE,
                                 0, concatenateBytes (
                                    gssHeaderComponents, authenticationHeader
                                 )
                              );
      try { 
         outStream.writeInt(contextRequest.length);
         outStream.write(contextRequest );
         outStream.flush();
         byte[] ebankMessage = new byte[inStream.readInt()];
                                 inStream.readFully(ebankMessage);
         int respTokenNumber = getNumberOfLengthBytes (ebankMessage[1]);
         respTokenNumber += 12;

         byte KRB_AP_REP = (byte)0x02;
         if (ebankMessage[respTokenNumber] == KRB_AP_REP){
            return true;
         } else
            return false;
      } catch (Exception io) {
         io.printStackTrace();
      }
      
      return false;
   }//createKerberosSession

清单 22. getNoNetworkBindings() 方法

   public byte[] getNoNetworkBindings() {
      byte[] bindingLength = { (byte)0x10, (byte)0x0, (byte)0x0, (byte)0x0};
      byte[] bindingContent = new byte[16];
      
      byte[] contextFlags_bytes = {
         (byte)0x3e,
         (byte)0x00,
         (byte)0x00,
         (byte)0x00                           
      };
      byte[] cksumBytes = concatenateBytes (
                             concatenateBytes(bindingLength,bindingContent), 
                             contextFlags_bytes);
      byte[] cksumType = {
         (byte)0x2,
         (byte)0x3,
         (byte)0x0,
         (byte)0x80,
         (byte)0x3
      };
      byte[] cksum = getChecksumBytes(cksumBytes, cksumType);
      return cksum;
   }//getNoNetWorkBindings()

向电子银行的业务逻辑服务器发送安全消息
如果 createKerberosSession() 方法返回 true ,就知道成功地与远程 Kerberos 服务器建立了一个安全会话。就可以开始与 Kerveros 服务器交换消息了。

看一下 清单 23sendSecureMessage() 方法。这个方法取一个纯文本消息、一个加密密钥、一个序号(它惟一地标识了发送的消息)和与服务器交换数据所用的输入输出流对象作为参数。 endSecureMessage() 方法生成一个安全消息、通过输出流将这个消息发送给服务器、监听服务器的响应,并返回服务器的响应。

发送给服务器的消息是用子会话密钥保护的。这意味着只有特定的接收者(拥有子会话密钥的电子银行业务逻辑服务器)可以解密并理解这个消息。而且,安全消息包含消息完整性数据,所以电子银行的服务器可以验证来自客户机的消息的完整性。

让我们看一下 sendSecureMessage() 方法是如何用一个纯文本消息生成一个安全 GSS 消息的。

一个 GSS 安全消息采用 token(token 格式的字节数组)的形式。token 格式由以下部分组成:

  1. 一个 GSS 头,类似于我在讨论 createKerberosSession() 方法时介绍的头。
  2. 一个八字节 token 头。在 GSS-Kerveros 规范中有几个不同类型的 token,每一种 token 类型都由一个惟一的头所标识。其中只有要在 sendSecureMessage() 方法中生成的安全消息头是我们感兴趣的。一个安全消息 token 是由具有值 0x02、0x01、0x00、0x00、0x00、0x00、0xff0xff 的头所标识的。
  3. 一个加密的序号,它有助于检测回复攻击。例如,如果有恶意的黑客想要重现(即重复)一个转账指令,他是无法生成加密形式的正确序号的(当然,除非他知道 子会话 密钥)。
  4. 消息的加密摘要值。
  5. 加密后的消息。

将上面列出的五个字段以正确的顺序链接在一起,然后包装到一个 ASN.1 应用程序级别的标签号 0 中。这就构成了完整的 GSS-Kerberos 安全消息 token,如 图 1所示。

图 1.

为了生成如 图 1所示的完整安全 token,必须生成所有五个字段。

前两个字段没有动态内容,它们在所有安全消息中都是相同的,所以我在 清单 23中硬编码了它们的值。另外三个字段必须根据以下算法动态计算:
1. 在纯文本消息中添加额外的字节以使消息中的字节数是八的倍数。
2. 生成一个名为 confounder 的八字节随机数。链接 confounder 与第 1 步中填充的消息。
3. 串接 token 头( 图 1中的第二个字段)和第 2 步的结果。然后对链接的结果计算 16 字节 MD5 摘要值。
4.子会话 密钥加密第 3 步得到的 16 字节摘要值。加密算法是使用零 IV 的 DES-CBC。加密的数据的最后八个字节(放弃前八个字节)构成了 图 1第四个字段(加密的摘要值)。
5. 现在必须生成一个加密的 8 字节序号( 图 1 的第三个字段)。这个序号是用 子会话 密钥和第 4 步使用 IV 的加密摘要值的后八个字节加密的。
6. 现在取第 2 步的结果(链接在一起的 confounder 和填充的消息)并用 DES-CBC 加密它。要进行这种加密,使用一个用 0xF0子会话 密钥的所有字节执行 OR 操作生成的密钥。这种加密得到的结果构成了 图 1的第五个字段,也就是加密的消息。

生成了各个字段后,将它们链接为一个字节数组,最后,调用 getTagAndLengthBytes() 方法以在链接的字节数组前面附加一个应用程序级别的标签号 0。

可以观察 清单 23sendSecureMessage() 方法中的这些步骤。生成了安全消息后,通过输出流将消息发送给服务器、监听服务器的响应并将响应返回给接收者。

清单 23. sendSecureMessage() 方法

   public byte[] sendSecureMessage(  String message, byte[] sub_sessionKey, 
                                     int seqNumber,
                                     DataInputStream inStream,
                                     DataOutputStream outStream
                                  )
   {
      byte[] gssHeaderComponents = {
         (byte)0x6,
         (byte)0x9,
         (byte)0x2a,
         (byte)0x86,
         (byte)0x48,
         (byte)0x86,
         (byte)0xf7,
         (byte)0x12,
         (byte)0x01,
         (byte)0x02,
         (byte)0x02         
      };
      byte[] tokenHeader = {
         (byte)0x02,
         (byte)0x01,
         (byte)0x00,
         (byte)0x00,
         (byte)0x00,
         (byte)0x00,
         (byte)0xff,
         (byte)0xff
      };      
      try {
         /***** Step 1: *****/
         byte[] paddedDataBytes = getPaddedData (message.getBytes());
         /***** Step 2: *****/
         byte[] confounder = concatenateBytes (getRandomNumber(), getRandomNumber());
         /***** Step 3: *****/
         byte[] messageBytes = concatenateBytes(confounder, paddedDataBytes);
         byte[] digestBytes = getMD5DigestValue(
                                 concatenateBytes (tokenHeader,messageBytes));
         CBCBlockCipher cipher = new CBCBlockCipher(new DESEngine());
         KeyParameter kp = new KeyParameter(sub_sessionKey);
         ParametersWithIV iv = new ParametersWithIV (kp, new byte[8]);
         cipher.init(true, iv);

         byte processedBlock[] = new byte[digestBytes.length];
         byte message_cksum[] = new byte[8];

         for(int x = 0; x < digestBytes.length/8; x ++) {
            cipher.processBlock(digestBytes, x*8, processedBlock, x*8);
            System.arraycopy(processedBlock, x*8, message_cksum, 0, 8);
            iv = new ParametersWithIV (kp, message_cksum);
            cipher.init (true, iv);
         }
         /***** Step 4: *****/         
         byte[] sequenceNumber = {
            (byte)0xff,
            (byte)0xff,
            (byte)0xff,
            (byte)0xff,
            (byte)0x00,
            (byte)0x00,
            (byte)0x00,
               (byte)0x00
            };
         sequenceNumber[0] = (byte)seqNumber;
         /***** Step 5: *****/
         byte[] encryptedSeqNumber = encrypt(sub_sessionKey, sequenceNumber, message_cksum);
         /***** Step 6: *****/
         byte[] encryptedMessage = encrypt(getContextKey(sub_sessionKey), 
                                           messageBytes, new byte[8]);          
         byte[] messageToken = getTagAndLengthBytes (
                                 ASN1DataTypes.APPLICATION_TYPE,
                                 0, 
                                 concatenateBytes (
                                   gssHeaderComponents, concatenateBytes (
                                      tokenHeader, concatenateBytes (
                                         encryptedSeqNumber, concatenateBytes (
                                            message_cksum, encryptedMessage
                                         )
                                      )
                                   )
                                 )
                              );
         /***** Step 7: *****/         
         outStream.writeInt(messageToken.length);
         outStream.write(messageToken);
         outStream.flush();
         /***** Step 8: *****/
         byte[] responseToken = new byte[inStream.readInt()];
         inStream.readFully(responseToken);

         return responseToken;
      } catch(IOException ie){
         ie.printStackTrace();
      } catch(Exception e){
         e.printStackTrace();
      }
      return null;
   }//sendSecureMessage

   public byte[] getContextKey(byte keyValue[])
   {
      for (int i =0; i < keyValue.length; i++)
          keyValue[i] ^= 0xf0;
          
      return keyValue;
   }//getContextKey

解码服务器消息
就像生成并发送给服务器的消息一样, sendSecureMessage() 方法返回的服务器消息是安全的。它遵循 图 1 所示的同样的 token 格式,这意味着只有拥有 子会话 密钥的客户机才能解密这个消息。

我编写了一个名为 decodeSecureMessage() 的方法(如 清单 24 所示),它以一个安全消息和解密密钥为参数,解密这个消息并返回纯文本格式的消息。解码算法如下:

  1. 第一步是将消息的加密部分(图 24 所示的第五个字段)与 token 头分离。token 头的长度是固定的,所以只有长度字节的数目是随消息的总长度而变化的。因此,只要读取长度字节数并相应地将消息的加密部分拷贝到一个单独的字节数组中。
  2. 第二步是读取消息 checksum( 图 1的第四个字段)。
  3. 现在用解密密钥解密加密的消息。
  4. 然后,取 token 头( 图 1 的第二个字段),将它与解密的消息链接,然后取链接的字节数组的 MD5 摘要值。
  5. 现在加密 MD5 摘要值。
  6. 需要比较第 2 步的八字节消息 checksum 与第 5 步的 MD5 摘要值的后八个字节。如果它们相匹配,那么完整性检查就得到验证。
  7. 经过验证后,删除 cofounder(解密的消息的前八个字节)并返回消息的其余部分(它就是所需要的纯文本消息)。

清单 24. decodeSecureMessage() 方法

   public String decodeSecureMessage (byte[] message, byte[] decryptionKey){
      int msg_tagAndHeaderLength = 36;
      int msg_lengthBytes = getNumberOfLengthBytes (message[1]);
      int encryptedMsg_offset = msg_tagAndHeaderLength + msg_lengthBytes;

      byte[] encryptedMessage = new byte[message.length - encryptedMsg_offset];
      System.arraycopy(message, encryptedMsg_offset, 
                       encryptedMessage, 0,
                       encryptedMessage.length);
      byte[] msg_checksum = new byte[8];
      System.arraycopy(message, (encryptedMsg_offset-8), 
                       msg_checksum, 0, 
                       msg_checksum.length);
      byte[] decryptedMsg = decrypt (decryptionKey, encryptedMessage, new byte[8]);
      byte[] tokenHeader = {
         (byte)0x2,
         (byte)0x1,
         (byte)0x0,
         (byte)0x0,
         (byte)0x0,
         (byte)0x0,
         (byte)0xff,
         (byte)0xff
      };
      
      byte[] msg_digest = getMD5DigestValue (concatenateBytes(tokenHeader,decryptedMsg));
      byte[] decMsg_checksum = new byte[8];

      try {
         CBCBlockCipher cipher = new CBCBlockCipher(new DESEngine());
         KeyParameter kp = new KeyParameter(getContextKey(decryptionKey));
         ParametersWithIV iv = new ParametersWithIV (kp, decMsg_checksum);
         cipher.init(true, iv);

         byte[] processedBlock = new byte[msg_digest.length];
         
         for(int x = 0; x < msg_digest.length/8; x ++) {
            cipher.processBlock(msg_digest, x*8, processedBlock, x*8);
            System.arraycopy(processedBlock, x*8, decMsg_checksum, 0, 8);
            iv = new ParametersWithIV (kp, decMsg_checksum);
            cipher.init (true, iv);
         }
      } catch(java.lang.IllegalArgumentException il){
        il.printStackTrace();
      }
      
      for (int x = 0; x < msg_checksum.length; x++) {
         if (!(msg_checksum[x] == decMsg_checksum[x])) 
            return null;
      }
      return new String (decryptedMsg, 
                         msg_checksum.length, 
                         decryptedMsg.length - msg_checksum.length);
   }//decodeSecureMessage()


   public byte[] getContextKey(byte keyValue[])
   {
      for (int i =0; i < keyValue.length; i++)
          keyValue[i] ^= 0xf0;
          
      return keyValue;
   }//getContextKey

示例移动银行应用程序
已经完成了示例移动银行应用程序所需要的安全 Kerberos 通信的所有阶段。现在可以讨论移动银行 MIDlet 如何使用 Kerberos 客户机功能并与电子银行的服务器通信了。

清单 25显示了一个简单的 MIDlet,它模拟了示例移动银行应用程序。

清单 25. 一个示例移动银行 MIDlet

import java.io.*;
import java.util.*;
import javax.microedition.lcdui.*;
import javax.microedition.midlet.*;
import javax.microedition.io.*;

public class J2MEClientMIDlet extends MIDlet implements CommandListener, Runnable {
   private Command OKCommand = null;
   private Command exitCommand = null;
   private Command sendMoneyCommand = null;
   private Display display = null;

   private Form transForm;
   private Form transResForm;
   private Form progressForm;
   private TextField txt_userName;
   private TextField txt_password;
   private TextField txt_amount;
   private TextField txt_sendTo;
   private StringItem si_message;
   private TextField txt_label;

   private SocketConnection sc;
   private DataInputStream is;
   private DataOutputStream os;
   private DatagramConnection dc;
   
   private KerberosClient kc;
   private TicketAndKey tk;

   private String realmName = "EBANK.LOCAL";
   private String kdcServerName = "krbtgt";   
   private String kdcAddress = "localhost";
   private int kdcPort = 8080;

   private String e_bankName = "ebankserver";
   private String e_bankAddress = "localhost";
   private int e_bankPort = 8000;
   private int i =0;
   private byte[] response;
         
   public J2MEClientMIDlet()  {
      exitCommand = new Command("Exit", Command.EXIT, 0);
      sendMoneyCommand = new Command("Pay", Command.SCREEN, 1);
      OKCommand = new Command("Back", Command.EXIT, 2);   
      display = Display.getDisplay(this);
      transactionForm();
   }

   public void startApp() {
      Thread t = new Thread(this);
      t.start();
   }//startApp()
  
   public void pauseApp() { 
   }//pauseApp()

   public void destroyApp(boolean unconditional) { 
   }//destroyApp
   

   public void commandAction(Command c, Displayable s)  {
      if (c == exitCommand) {
         destroyApp(false);
         notifyDestroyed();
      } else if(c == sendMoneyCommand) {
         sendMoney();
      } else if (c == OKCommand) {
         transactionForm();
      } else if (c == exitCommand) {
         destroyApp(true);
      }
   }//commandaction
 
 
   public void sendMoney() {
      System.out.println("MIDlet... SendMoney() Starts");
      String userName = txt_userName.getString();
      String password = txt_password.getString();
      kc.setParameters(userName, password, realmName);
      System.out.println("MIDlet... Getting TGT Ticket");
      response = kc.getTicketResponse (
                                userName, 
                                kdcServerName, 
                                realmName, 
                                null, 
                                null
                              ); 

      System.out.println ("MIDLet...Getting Session Key from TGT Response");
      tk = new TicketAndKey();
      tk = kc.getTicketAndKey(response, kc.getSecretKey());
      
      System.out.println ("MIDLet...Getting Service Ticket (TGS)");
      response = kc.getTicketResponse (
                                userName,
                                e_bankName,
                                realmName,
                                tk.getTicket(),
                                tk.getKey()
                              );
                              
      System.out.println ("MIDLet...Getting Sub-Session Key from TGS Response");
      tk = kc.getTicketAndKey( response, tk.getKey());
      i++;
      System.out.println ("MIDLet...Establishing Secure context with E-Bank");
      boolean isEstablished = kc.createKerberosSession (
                               tk.getTicket(), 
                               realmName,
                               userName,
                               i,
                               tk.getKey(),
                               is,
                               os
                    );
         if (isEstablished) {
            System.out.println ("MIDLet...Sending transactoin message over secure context");
            byte[] rspMessage = kc.sendSecureMessage(
                                 "Transaction of Amount:"+txt_amount.getString()
                                 + " From: "+userName
                                 +" To: "+txt_sendTo.getString(),
                                 tk.getKey(),
                                 i,
                                 is,
                                 os
                              );
          
            String decodedMessage = kc.decodeSecureMessage(rspMessage, tk.getKey());
            if (decodedMessage!=null)
               showTransResult(" OK", decodedMessage);
            else
               showTransResult(" Error!", "Transaction failed..");
         } else
               System.out.println ("MIDlet...Context establishment failed..");
   }//sendMoney()

   public synchronized void run() {
      try {
         dc = (DatagramConnection)Connector.open("datagram://"+kdcAddress+":"+kdcPort);
         kc = new KerberosClient(dc);
         sc = (SocketConnection)Connector.open("socket://"+e_bankAddress+":"+e_bankPort);
         sc.setSocketOption(SocketConnection.KEEPALIVE, 1);
         is  = sc.openDataInputStream();
         os = sc.openDataOutputStream();
      } catch (ConnectionNotFoundException ce) {
         System.out.println("Socket connection to server not found....");
      } catch (IOException ie) {
            ie.printStackTrace();
      } catch (Exception e) {
         e.printStackTrace();
      }
   }//run
   
   public void transactionForm(){
      transForm = new Form("EBANK Transaction Form");
      txt_userName = new TextField("Username", "", 10, TextField.ANY);
      txt_password = new TextField("Password", "", 10, TextField.PASSWORD);
      txt_amount = new TextField("Amount", "", 4, TextField.NUMERIC);
      txt_sendTo = new TextField("Pay to", "", 10, TextField.ANY);
      transForm.append(txt_userName);
      transForm.append(txt_password);
      transForm.append(txt_amount);
      transForm.append(txt_sendTo);
      transForm.addCommand(sendMoneyCommand);
      transForm.addCommand(exitCommand);
      transForm.setCommandListener(this);
      display.setCurrent(transForm);   
   }

   public void showTransResult(String info, String message) {
      transResForm = new Form("Transaction Result");
      si_message = new StringItem("Status:" , info);
      txt_label = new TextField("Result:", message, 150, TextField.ANY);
      transResForm.append(si_message);
      transResForm.append(txt_label);
      transResForm.addCommand(exitCommand);      
      transResForm.addCommand(OKCommand);
      transResForm.setCommandListener(this);
      display.setCurrent(transResForm);
   }
}//J2MEClientMIDlet

运行这个 MIDlet 会得到如 图 2所示的屏幕。

图 2.

图 2显示为这个示例移动银行应用程序开发了一个非常简单的 GUI。 图 2 还显示了四个数据输入字段:

  1. Username ”字段取要使用移动银行 MIDlet 的金融服务的人的用户名。
  2. Password ”字段取用户的密码。
  3. Amount ”字段允许输入要支付给一个收款人的金额。
  4. Pay to ”字段包含收款人的用户名。

输入完数据后,按 Pay 按钮。Pay 按钮的事件处理器( 清单 25 中的 sendMoney() 方法)执行 Kerveros 通信的所有七个阶段:

  1. 生成一个 TGT 请求、将请求发送给出服务器、并接收 TGT 响应。
  2. TGT 响应中提取 TGT 和会话密钥。
  3. 生成一个服务票据请求、将请求发送给 KDC 、并接收服务票据响应。
  4. 从服务票据响应中提取服务票据和子会话密钥。
  5. 生成上下文建立请求并发送给电子银行的业务逻辑服务器、接收响应、并解析它以确定服务器同意建立一个新的安全上下文。
  6. 生成一个安全消息、将这个消息发送给服务器、并接收服务器的响应。
  7. 解码来自服务器的消息。

清单 25 的 MIDlet 代码相当简单,不需要很多解释。只要注意以下几点:

  1. 我使用了不同的线程( 清单 25 中的 run() 方法)创建 Datagram 连接 ( dc ) 和 Socket 连接上的数据输入和输出流。这是因为 MIDP 2.0 不允许在 J2ME MIDlet 的主执行线程中创建 DatagramSocket 连接。
  2. 清单 25 的 J2ME MIDlet 中,我硬编码了 KDC 服务器的域、服务器名、地址和端口号以及电子银行服务器的名字和地址。注意 MIDlet 中的硬编码只是出于展示目的。另一方面, KerberosClient 是完全可重用的。
  3. 为了试验这个应用程序,需要一个作为电子银行服务器运行的 GSS 服务器。本文的 源代码下载 包含一个服务器端应用程序和一个 readme.txt 文件,它描述了如何运行这个服务器。
  4. 最后,注意我没有设计电子银行通信框架,我只是设计了基于 Kerberos 的安全框架。可以设计自己的通信框架,并用 KerberosClient 提供安全支持。例如,可以使用 XML 格式定义不同的消息作为转账指令。

结束语
在这个三部分的系列文章中,我展示了 J2ME 应用程序中的安全 Kerberos 通信。介绍了进行一系列加密密钥交换的 Kerveros 通信。还介绍了 J2ME 应用程序是如何使用密钥与远程电子银行服务器建立通信上下文并安全地交换消息的。我还提供了展示文章中讨论的所有概念的 J2ME 代码。

参考资料

关于作者
Faheem Khan 是一个独立软件顾问,专长是企业应用集成 (EAI) 和 B2B 解决方案。读者可以通过 fkhan872@yahoo.com与 Faheem 联系。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值