65.9K
CodeProject 正在变化。 阅读更多。
Home

JavaScript 和 RSACryptoServiceProvider 之间的 RSA 互操作 - 表单登录示例

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.72/5 (26投票s)

2005年8月29日

7分钟阅读

viewsIcon

205859

downloadIcon

4121

探索客户端 JavaScript RSA 实现与服务器端 RSACryptoServiceProvider 在典型的表单登录示例中的互操作性。

引言

本文的目的是通过描述一种可以在典型的基于表单的身份验证过程中保护浏览器和服务器之间登录密码的方法,来探索浏览器-服务器 RSA 互操作性,同时仍然可以在服务器端访问明文(清晰)密码——用于进一步处理,例如首次传输密码、更改密码、执行动态模拟或查询 Active Directory。

该机制可能为人熟知(稍后会详述)。本文提供的实现侧重于使用 JavaScript 的客户端 RSA 实现(“JavaScript 中的 RSA”,已修改以实现互操作)与服务器端(ASP.NET)使用的 Microsoft .NET Framework 的 RSA 对应项之间的互操作性。

背景与范围

有几种常见的保护基于表单的身份验证的登录密码的替代方案,例如:

  1. 通过安全套接字层 (SSL) 发送用户名和明文密码。
  2. 在浏览器端对密码进行哈希处理,并在服务器端比较哈希值。
  3. 在浏览器端加密密码,并在服务器端解密。

对于 (2) 或 (3) 的客户端哈希/加密可以在

  • Java applet/ActiveX 对象,或
  • JavaScript

本文在处理 (3) 时,将采用 JavaScript 方法,因为它看起来侵入性较小。

使用的加密和解密算法无疑将是 RSA。我们不讨论 RSA 算法的内部机制,也不对 RSA 如何以及为何工作的完整讨论进行深入探讨,这超出了本文的范围。有兴趣的读者可以在互联网上找到许多资源,例如 RSA 算法

让我们首先回顾上述(1)到(3)各种替代方案的步骤和特征。

在选项 (1) 中,用户名和密码通过网络使用 SSL 加密发送,而不是明文。SSL 可以正常工作,尽管性能可能会下降,尤其是在缓慢的拨号网络上。

选项 (2) 被广泛使用,一个突出的例子是 Yahoo! 的标准登录页面。提交表单时,密码会被哈希处理然后发送到服务器。哈希处理是单向的,这意味着无法从哈希值推导出原始字符串。服务器会将收到的值与先前存储的哈希值进行比较。如果两者匹配,则表示用户输入了正确的密码并已通过身份验证。Yahoo! 的哈希处理也采用了 JavaScript 方法,使用了 Paul Johnson 的库。他的网页 登录系统讨论了试图提供额外安全性的变体。

然而,选项 (2) 不允许在服务器端访问原始密码字符串,这就是设计选项 (3) 的动机。

工作原理

在选项 (3) 中,如本文所示,详细流程如下:

  1. 服务器创建一个 RSACryptoServiceProvider 对象,加载一个预先生成的公钥和私钥(或密钥池中的一个)。然后可以缓存它以供重用。
    public void InitCrypto(string keyFileName)
    {
      CspParameters cspParams = new CspParameters();
      cspParams.Flags = CspProviderFlags.UseMachineKeyStore;
      // To avoid repeated costly key pair generation
    
      _sp = new RSACryptoServiceProvider(cspParams);
      string path = keyFileName;
      System.IO.StreamReader reader = new StreamReader(path);
      string data = reader.ReadToEnd();
      _sp.FromXmlString(data);
    }

    请注意,每当调用 RSACryptoServiceProvider 类的默认构造函数时,它会自动创建一个新的公钥/私钥对,可供使用。为了重用先前创建的密钥,该类会使用一个已填充的 CspParameters 对象进行初始化。

  2. 当用户请求登录页面时,服务器会生成一个一次性的随机挑战字符串(Base64 编码),并将其缓存,指定 N 分钟后过期。然后将表单渲染到浏览器,其中嵌入了 JavaScript RSA 实现、加密所需的公钥信息以及挑战字符串。
    private string CreateChallengeString()
    {
      System.Random rng = new Random(DateTime.Now.Millisecond);
    
      // Create random string
    
      byte[] salt = new byte[64];
      for(int i=0; i<64;)
      {
       salt[i++] = (byte) rng.Next(65,90); // a-z
    
       salt[i++] = (byte) rng.Next(97,122); // A-Z
    
      }
    
      string challenge = ComputeHashString(salt, param.D);
    
      System.Web.HttpContext.Current.Cache.Insert(
        challenge, // as cache key
    
        salt, // as cache content, for hash verification
    
        null, 
        DateTime.Now.AddMinutes(20), // valid for N mins    
    
        System.Web.Caching.Cache.NoSlidingExpiration);
    
      return challenge;
    }
    private string ComputeHashString(byte[] salt, 
                                   byte[] uniqueKey)
    {
      // Concat before hashing
    
      byte[] target = new byte [salt.Length + uniqueKey.Length];
      System.Buffer.BlockCopy(salt, 0, target, 0, salt.Length);
      System.Buffer.BlockCopy(uniqueKey, 0, target, 
                                salt.Length, uniqueKey.Length);
                
      SHA1Managed sha = new SHA1Managed();
      return StringHelper.ToBase64(sha.ComputeHash(target));
    }
  3. 用户填写完表单后提交,JavaScript 例程首先会将用户名和密码转换为 Base64 格式,然后以 \ 作为分隔符(因为 \ 不是 Base64 字符的一部分)连接到挑战字符串的末尾。
    encryptedString(challenge + "\\" + username + "\\" + password);

    只有加密结果会被 POST 回服务器。

  4. 在服务器端,RSACryptoServiceProvider 对象将使用私钥解密 POST 请求的数据,并将结果字符串拆分为三个不同的部分(如果一切顺利),即挑战字符串、用户名和密码。服务器应首先验证挑战字符串是否存在且未被篡改;否则,身份验证请求将被取消。一旦挑战字符串匹配,它将被立即失效并从缓存中移除。然后,服务器会将解密后的用户名和密码转发给执行实际用户名-密码对验证的其他例程——一个例子是查询 Active Directory。
  5. 缓存过期将确保清理已发布但从未使用的挑战字符串。

    长话短说,为了完成上述设计,我们需要找到一个在浏览器端运行的 RSA JavaScript 实现,并确保它能够与 ASP.NET 服务器端的 Microsoft 的 .NET Framework RSA 实现进行通信。

JavaScript 与 .NET Framework 之间的 RSA 互操作性

Dave 的 JavaScript 实现“JavaScript 中的 RSA”在他的演示页面上似乎运行得相当好,但是将加密结果馈送到使用相同公/私钥对(1024 位密钥长度)的 RSACryptoServiceProvider 实例进行测试时,会生成“bad data”异常。

检查 JavaScript 源代码会发现没有采用“填充”机制。

// 0 is padded

while (a.length % key.chunkSize != 0) 
{
 a[i++] = 0;
}

核心 RSA 算法是简单的模幂运算,或者说是一个包含非常大数字的简单方程,因此大数字对于确保算法的强度至关重要。没有填充的原始 RSA 加密是不安全的,因为使用的数字可能会很小。因此,Microsoft 的 API 要求强制进行填充。来自 JavaScript 代码的不兼容填充方案将在服务器端产生“bad data”异常。

因此,JavaScript 代码需要实现 .NET RSA 实现中使用的两种填充方案之一,第一种是 PKCS#1 v1.5 填充,另一种是 OAEP(PKCS#1 v2)填充。

摘自 Microsoft 的文档

直接加密 (PKCS#1 v1.5)

Microsoft Windows 2000 或更高版本,安装了高加密包。填充:模数大小 - 11。(11 字节是可能的最小填充。)

OAEP 填充 (PKCS#1 v2)

Microsoft Windows XP 或更高版本。填充:模数大小 – 2 – 2*hLen,其中 hLen 是哈希的大小。

对更简单的 PKCS#1 v1.5 进行更多研究(请参阅 此处,关于 8.1 加密块格式化)显示了填充应如何进行。在加密过程中,会生成伪随机的非零字节,最终的填充消息(在模幂运算之前)应如下所示:

0x00 || 0x02 || PseudoRandomNonZeroBytes || 0x00 || Message

在向 JavaScript 源注入填充代码(为清晰起见省略,请参阅源代码)后,加密字符串就可以被 RSACryptoServiceProvider 成功解密。

这两个终于沟通上了!

关注点

本文详细介绍的技术,即选项 (3),使得可以使用非对称 RSA 算法将用户名和密码以加密形式从浏览器传输到服务器。互操作性由浏览器端的 JavaScript 实现和服务器端的 RSACryptoServiceProvider 实现提供。表单提交内容使用公钥加密,只有服务器知道相应的私钥才能解密内容。这避免了以明文形式发送它们(尤其是密码),并防止了中间人攻击,如窃听以及被动流量分析。

仅使用加密容易受到重放攻击。恶意用户可以截获并记录流量,然后进行延迟的 HTTP POST,冒充输入密码的用户。通过在每次身份验证请求中附加一个挑战字符串来抵消此风险。它只使用一次,之后即被丢弃。它具有有限的生命周期,已发布但未使用的挑战字符串会被清理掉。

但是,该机制不能防止网络钓鱼。它不能阻止操纵的流量,这些流量可以将恶意的 JavaScript 函数注入到浏览器收到的响应中,从而绕过加密例程。

杂项

在加密 Unicode 字符串的情况下,程序应先使用 UTF-8 将 Unicode 字符转换为字节,然后使用 Base64 将字节转换为可打印字符。这在源项目中并未实现。

除了典型的登录页面之外,RSA 互操作性还可以在许多其他场景中使用,例如 Web 表单中的字段可以有选择性地决定是否支持加密/解密。

历史

  • 2005 年 8 月 7 日 - 初稿。
© . All rights reserved.