三、密码系统

既然我们已经讨论了一般的安全性,在进入特定于 web 的概念之前,我们还需要了解一个安全性概念,那就是密码术。密码学是研究创造保护信息的代码。历史上使用过许多加密算法,从最早的 Caesar 密码(涉及将字母 X 的字符移位(例如,将“abcdef”移位两个字符将得到“cdefgh”)到用于非对称加密的 RSA 算法。与其试图在这里对密码学进行全面的论述,至少有一本书会以此为主题,不如让我们探索一下作为 ASP.NET Core 程序员需要了解的最常见的算法。让我们从对称加密开始,因为它是大多数人在想到“密码术”时想到的密码术类型

对称加密

回到我们的中情局三部曲,如果你正在寻找保护信息的机密性,你应该强烈考虑对称加密。对称加密是指使用一个密钥将信息加密为密文,然后使用该密钥将信息解密回明文的方法和算法集。让我们定义其中的一些术语:

  • 明文:这是以不变格式存储的信息。

  • 密文:这是已经被转换成(希望是)不可读格式的信息。

  • 加密:把明文变成密文的过程。

  • 解密:把密文变成明文的过程。

  • Key :在加密和解密过程中使用的一组字节,有助于确保当密文看起来毫无意义时,任何生成的密文都可以可靠地变回明文。

  • 初始化向量(IV) :一组字节,用于帮助确保如果多次加密文本,每次都会得到唯一的密文。

在一个非代码的例子中,你可以把加密的过程想象成当你离开时锁上你的房子。你家的钥匙锁上你的门,然后你用同一把钥匙打开你的门。对称加密的工作方式与此类似,您可以使用一个密钥来加密您的信息,并将其转换为不可读的密文,然后使用同一个密钥来“解锁”该数据,并将其转换回可用的明文。继续这个类比,想象一下静脉注射就像一个神奇的物品,让你每次锁门的时候看起来都不一样。是的,同一把钥匙可以打开门,但改变门的外观会让错误的人更难知道哪把钥匙打开了哪扇门。

对称加密在网站中最常用于保护静态数据,例如,当您希望将敏感数据存储在系统中,但不希望黑客窃取您的数据存储时读取这些数据。例如,想想你如何存储电子邮件地址。您不希望黑客能够读取电子邮件地址,因为这样他们就可以轻松地对您的客户进行鱼叉式网络钓鱼攻击。但是你也需要知道那个电子邮件地址是什么,因为你需要不时地给他们发电子邮件。对称加密允许你做到这一点——如果你将密文存储在数据库中,黑客将很难读取这些信息,但你可以在你的电子邮件发送逻辑中解密它,以将你的电子邮件发送到正确的地方。

关于对称加密需要强调的另一点是:任何好的对称加密算法对于单个明文都会有多个有效的密文。换句话说,如果你把你的名字加密十次,只要你用十个不同的 iv,你就应该得到十个不同的密文。

对称加密类型

有两种对称加密算法:流密码和块密码。流密码的工作原理是按顺序逐个加密位,一次加密一位文本,而不管文本的大小。另一方面,分组密码是通过将比特组一起加密来工作的。例如,如果您使用 64 位算法加密 240 位文本,而不是一次加密一位,您将加密原始文本的四个独立块。密文看起来会像这样:

  1. 块 1 将包含位 1–64。

  2. 块 2 将包含 65–128 位。

  3. 块 3 将包含 129–192 位。

  4. 块 4 将包含 193–240 位,加上标记密文结尾的两位,然后是一些填充位,达到 256 位。

过去,流密码用于保护传输中的数据,而块密码用于保护静态数据。从那以后,流密码就不再受到安全社区的青睐,因为它们比块密码更容易被破解。因此,我们在这里只讨论分组密码。

对称加密算法

有许多对称加密算法,有些可以安全使用,有些则不那么安全。我不会讨论很多不同的算法,但是让我们来看看两个最常见的算法,这两个算法都是. NET 支持的。

和三重 DES

DES,或数据加密标准,实际上并不是一种算法。相反,它是 20 世纪 70 年代创建的分组密码的标准,并且选择数据加密算法(DEA)来实现该标准。作为程序员,这并不重要,只要知道 DES 和 DEA 或多或少可以互换。创建该标准是为了概述算法应该满足什么规范,而算法满足这些标准并定义如何完成处理。但是对于我们的目的来说,每个都做同样的事情。虽然 DES 不再被普遍使用,但有必要回顾一下它的历史,以便了解三重 DES 及其替代品高级加密标准(AES)的来源。

当 DES 第一次被提出时,美国国家安全局决定将该算法的密钥长度减少一半至 56 位,使其安全性大大降低。据推测,这是为了让 NSA 可以根据需要解密流量,但随着计算机变得更快,这可以预见地引发了问题。到了 20 世纪 90 年代,用 DES 加密的数据可以在几个小时内被破解,这使得数据不安全到了不能再安全使用的程度。 1 当替代品被开发出来的时候,人们开始用两三个密钥对数据进行三次加密和解密,而不是用 DES 对数据进行一次加密。这种方法被称为三重 DES。

三重 DES,有一些密钥组合,仍然被认为是安全使用的,但它不是很快。对于安全但更快的加密算法,大多数人转向上述 AES。

AES 和 Rijndael

高级加密标准是由国家标准和技术研究所开发的,选择用来实现该标准的算法是 Rijndael。就像 DES 和 DEA 可以互换一样,AES 和 Rijndael 也可以互换。和 DES 一样,AES 也是分组密码。但是,虽然 AES 技术上只有一个块大小-128 位-因为 Rijndael 有多个块大小(128、160、192、224 或 256 位),大多数人认为 AES 有多个块大小。密钥越大,安全性越好,但是处理时间越长。

在撰写本文时,AES 是生产系统中最推荐使用的标准。除非你有一个非常好的理由,否则你应该使用 AES 来满足你的大部分加密需求。

块加密的问题

由于块加密算法一次加密大量数据,如果不小心,您可能会意外泄露正在加密的项目的信息。要了解原因,让我们用 128 位版本的 AES 加密清单 3-1 中的文本。

good afternoon! this is truly a good afternoon! have a good day!

Listing 3-1Text we will encrypt

文本“下午好!”(包括空格)长度为 16 个字符。如果您使用的是 Unicode,则每个字符 8 位,因此这是一个 128 位的文本块(16 个字符,每个字符 8 位)。让我们使用 AES 和一个 128 位的密钥来加密这个文本。

017D36D9D4091CCD9380C5E20F5B0DB31BEF1379BA4D9DF52003CEAF3942C022017D36D9D4091CCD9380C5E20F5B0DB364AA8F9AB2A22117769763F6CF95411D4923331C01B6FE7D220360DF6A7F6FB2

Listing 3-2Encrypted text with repeated code blocks

您可以在清单 3-2 中看到,两个完全相同的文本块,它们的大小和位置恰好与一个加密文本块一致,具有完全相同的加密值。如果您总是用一个密钥加密少量数据,并且每次都使用新的 IVs(我们稍后将讨论这意味着什么),那么这可能并不重要。如果你需要加密大量的信息,这是一个非常大的问题。要了解原因,让我们看看安全社区中一个常见的例子:加密 Linux 企鹅(图 3-1 )。

img/494065_1_En_3_Fig1_HTML.jpg

图 3-1

Linux 企鹅图片 2

现在让我们加密这张图片,不要对重复信息做任何保护。

img/494065_1_En_3_Fig2_HTML.jpg

图 3-2

Linux 企鹅3T3】的加密版图片

如图 3-2 所示,加密版本看起来与原始版本非常相似,如果你知道这是某个图像的加密版本,你就可以合理地猜测原始图像是什么。任何大型数据集都会出现这种模式,比如图像或大型文本,因此在处理大型数据集时,我们需要更好的保护。

为了解决这个问题,您可以使用许多不同的技术,在. NET 中称为密码模式。我不会深入了解这些模式如何工作的细节,但是您应该知道一些高级别的差异,以及一些更常见的模式的优缺点。有许多不同的模式,尽管不是所有的模式在. NET 中都可用。下面是一些值得了解的模式:

  • 电子码本【EBC】:数据一次加密一块,如前所述。在. NET 中可用。

  • 密码块链接 (CBC) :块 1 的数据用于隐藏块 2 的信息,块 2 的数据用于隐藏块 3 的信息,以此类推。在. NET 中可用。

  • 密文窃取 (CTS) :这类似于 CBC,除了明文的最后两个块,它在末尾处理填充(当明文不完全适合分组密码的块大小时)。在. NET 中可用。

  • 密码反馈【CFB】:CBC/CTS 的一个缺点是你需要整个消息没有错误才能正确解密其中的任何部分。CFB 模式通过使用一个块的一小部分密文来随机化下一个块,使得在部分密文丢失的情况下更容易恢复,从而解决了这个问题。在. NET 中不可用

  • 输出反馈【OFB】:这就像 CFB 一样,它从前一个块中提取信息来随机化下一个块,但这样做是从算法的不同部分进行的,以便更容易从丢失的块中恢复。在. NET 中不可用

  • 计数器模式 (CTR) :这类似于 OFB 模式,除了使用计数器而不是从先前的块中获取信息进行随机化。这种模式可以并行加密数据块,因此比 CFB 或 OFB 要快得多。在. NET 中不可用

  • 基于 XEX 的带密文窃取的微调码本模式【XTS】:这种模式是为加密非常大量的信息而构建的,比如加密硬盘。在. NET 中不可用

  • 伽罗瓦/计数器模式 (GCM) :这种模式类似于 CTR,因为它包括一个计数器来帮助它解决 ECB 重复块问题,但仍然并行处理块,但与 CTR 不同,GCM 包括可以检测对密文的有意或无意篡改的认证。有售。NET 通过 AesGcm 类。

在这些模式中,EBC 是最不安全的,应该完全避免。除此之外,您可能会发现每种模式的特定需求。我个人倾向于 CTR,因为它能够并行加密和解密,但你的里程可能会有所不同。

既然我们已经讨论了什么是对称加密以及它是如何工作的,我们可以跳到一些关于如何在. NET 中实现它的代码示例。

对称加密。网

在我们开始使用加密的例子之前,我们需要谈一谈创建 IVs。如前所述,iv 帮助您确保每个密文都是唯一的。但是要拥有独一无二的密文,你需要独一无二的随机的 iv。虽然System.Random是一种生成看似随机的值的好方法,但它生成的值对于安全加密来说不够随机。输入RNGCryptoServiceProvider

protected byte[] CreateRandomByteArray(int length)
{
  var rngService = new RNGCryptoServiceProvider();
  byte[] buffer = new byte[length];

  rngService.GetBytes(buffer);
  return buffer;
}

Listing 3-3Sample code that creates a random array of bytes

Caution

不要忽略这一部分。网上有许多代码示例向您展示如何实现加密算法,还有太多的硬代码密钥和/或 iv。我无法告诉你这是一个多么糟糕的 T2 想法。为了使加密有效,密钥和 iv 都需要随机生成。

现在我们已经有了清单 3-3 并且可以创建一个 IV,我们可以创建一个可以加密明文的基本方法。

public byte[] EncryptAES(byte[] plaintext, byte[] key)
{
  byte[] encrypted;
  //Hard-code the key length for now, we’ll fix this later
  var iv = CreateRandomByteArray(16);

  using (var rijndael = Rijndael.Create())
  {
    rijndael.Key = key;
    rijndael.Padding = PaddingMode.PKCS7;
    rijndael.Mode = CipherMode.CBC;
    rijndael.IV = iv;

    var encryptor = ↲
          rijndael.CreateEncryptor(rijndael.Key, rijndael.IV);

    using (var memStream = new MemoryStream())
    {
      using (var cryptStream = new CryptoStream(↲
        memStream, encryptor, CryptoStreamMode.Write))
      {
        using (StreamWriter writer = new ↲
          StreamWriter(cryptStream))
        {
        writer.Write(plainText);
      }

      encrypted = memStream.ToArray();
    }
  }

  return encrypted;
}

Listing 3-4Simple version of AES symmetric encryption in .NET

这里有很多东西需要解开,所以我将为您重点介绍几样东西:

  1. 此方法采用一个字节数组并返回一个字节数组。这样做是因为这就是。NET 是可行的,但这并不是我所开发的大多数应用的运行方式。让我们在下一轮解决这个问题。

  2. 我们将该方法硬编码为使用 16 字节的 IVs。Rijndael 应该使用从 128 到 256 位的几个密钥大小,但是微软在。NET 核心。这不是我希望他们实现的方式,但我们稍后会回来讨论这个问题。

  3. 方法名为 AES,但我们使用的是 Rijndael 算法。如果你还记得,AES 是标准,但是 Rijndael 是用于实现该标准的算法。除了支持不同长度的键之外,这两者应该是等价的。这个我以后再讲。

  4. 稍后将全面讨论密钥来自哪里以及存储在哪里。现在,要知道它应该和我们生成静脉注射液的方式一样。

  5. PaddingMode 指定如何将位应用于结尾,以标记加密部分的结尾。(请记住,我们是在以 128 位的倍数加密文本,我们的文本不会恰好符合这一点。)这个跟我们的安全级别没多大关系,用 PKCS7 就行了。

  6. 我们使用 CBC 作为我们的密码模式,因为 EBC 是不安全的,. NET 不支持 CTR

  7. 大概通过传入密钥,我们将知道以后如何让它解密。但我们也需要知道静脉注射的情况,但那不会被退回来。我们需要解决这个问题。

Note

中的大多数或所有加密算法实现。NET 有一个名为GenerateIV()的方法,它大概可以用来创建 IVs,而不需要我们的自定义方法。坦率地说,我从来没有让它正常工作过,所以我在这里展示如何安全地创建自己的 IV。

让我们再试一次对称加密,但这一次我们要解决前面提到的问题。首先,让我们在清单 3-5 中定义几个方法,我们需要允许我们的方法使用字符串,而不是算法期望的字节数组。

private static byte[] HexStringToByteArray(
  string stringInHexFormat)
{
  return Enumerable.Range(0, stringInHexFormat.Length)
                   .Where(x => x % 2 == 0)
                   .Select(x =>
                     Convert.ToByte(stringInHexFormat. ↲
                       Substring(x, 2), 16))
                   .ToArray();
}

private static string ByteArrayToString(byte[] bytes)
{
  var sb = new StringBuilder();
  foreach (var b in bytes)
    sb.Append(b.ToString("X2"));

  return sb.ToString();
}

Listing 3-5String to byte array methods

我们还需要一种从密钥存储位置获取密钥的方法。密钥存储的讨论超出了本章的范围,但是让我们定义清单 3-6 中的接口,我们的秘密存储服务现在必须实现这个接口。

public interface ISecretStore
{
  string GetKey(string keyName, int keyIndex);
  string GetSalt(string saltName);
}

Listing 3-6ISecretStore service

我们将在后面讨论散列时讨论GetSalt()。正如你可能猜到的,GetKey()被设计成从密钥存储位置获取加密密钥,通常被称为密钥库。密钥存储是一个重要的主题,我们将在本书的后面讨论。但是现在,让我们假设GetKey()安全地从我们的存储位置获取密钥。

有了keyIndex,你就可以定期升级你的密钥(这是一个叫做密钥轮换的过程),而不需要进行数据迁移,在数据迁移中,你所有的加密值都用旧密钥解密,用新密钥加密。我稍后将向您展示这是如何工作的,但是现在,只需知道每个键可以有多个值,这些值可以通过索引找到。

Tip

要特别注意每个密文的算法和关键索引指示符的逻辑。加密升级和密钥轮换一直在发生,但是如果您的代码不够智能,无法处理这些升级,您可能需要花费数百或数千个小时来更新代码,以便在最短的停机时间内完成数据迁移。这段代码相当容易地处理这些迁移,为您节省了许多不必要的工作时间。

现在,让我们深入研究一个更好的加密类的实现,一个处理字符串、理解密钥轮换并安全存储 iv 的加密类。

using System;
using System.IO;
using System.Security.Cryptography;

public class SymmetricEncryptor
{
  public enum EncryptionAlgorithm
  {
    AES128 = 1
  }

  private EncryptionAlgorithm _defaultAlgorithm =
    EncryptionAlgorithm.AES128;
  private readonly int _defaultKeyIndex;
  private readonly ISecretStore _secretStore;

  public SymmetricEncryptor(IConfiguration config,
    ISecretStore secretStore)
  {
    _defaultKeyIndex =
      config.GetValue<int>("AppSettings:KeyIndex");
    _secretStore = secretStore;
  }

  private int GetBlockSizeInBytes(
    EncryptionAlgorithm algorithm)
  {
    switch (algorithm)
    {
      case EncryptionAlgorithm.AES128:
        return 16;
      default:
        throw new NotImplementedException(
          $"Cannot find block size for
          { algorithm.ToString() } algorithm");
    }
  }

  public string EncryptString(string plainText,
    string keyName)
  {
    //Removed code to check if parameters are null

    var keyString = _secretStore.GetKey(keyName,
      _defaultKeyIndex);

    switch (algorithm)
    {
      case EncryptionAlgorithm.AES128:
        return EncryptAES(plainText, keyString, algorithm,
          _defaultKeyIndex);
      default:
        throw new ↲
          NotImplementedException(algorithm.ToString());
    }
  }

  private string EncryptAES(string plainText, ↲
    string keyString, EncryptionAlgorithm algorithm,
    int keyIndex)
  {
    byte[] encrypted;
    var keyBytes = HexStringToByteArray(keyString);
    var iv = ↲
      CreateRandomByteArray(GetBlockSizeInBytes(algorithm));

    using (var rijndael = Rijndael.Create())
    {
      rijndael.Key = keyBytes;
      rijndael.Padding = PaddingMode.PKCS7;
      rijndael.Mode = CipherMode.CBC;
      rijndael.IV = iv;

      var encryptor =
        rijndael.CreateEncryptor(rijndael.Key, rijndael.IV);

      using (var memStream = new MemoryStream())
      {
        using (var cryptoStream = new CryptoStream(↲
          memStream, encryptor, CryptoStreamMode.Write))
        {
          using (var writer = ↲
            new StreamWriter(cryptoStream))
          {
            writer.Write(plainText);
          }

          encrypted = memStream.ToArray();
        }
      }
    }

    var asString = ByteArrayToString(encrypted);
    var ivAsString = ByteArrayToString(iv);

    return $"[{(int)algorithm},{keyIndex}]↲
      {ivAsString}{asString}";
  }
}

Listing 3-7A more robust implementation of AES encryption

清单 3-7 中的版本有几处变化。以下是亮点:

  1. EncryptString()现在接受字符串格式的文本进行加密,而不是 byte[]。

  2. EncryptString()现在取一个键,而不是一个键。实际的密钥值是从我们的ISecretStoreGetKey()方法中的密钥库中提取的。

  3. 如前所述,GetKey()还采用了密钥索引,这将允许我们相对容易地升级密钥。

  4. EncryptString()现在把算法当成了一个enum,所以升级到一个新的算法应该是相对容易的(只要我们有一个足够智能的解密方法来处理所有的可能性)。

  5. 现在,我们不是只返回加密文本,而是返回所用算法的指示器、密钥索引、IV 和加密文本。

  6. 我们不是存储字节数组,而是返回十六进制格式的字符串,使用HexStringToByteArrayByteArrayToString方法将字符串转换成字节,反之亦然。

  7. _defaultKeyIndex在这里没有起到明显的作用,但是升级它将改变使用哪个键索引,允许我们容易地旋转键。

Note

您可能会感到惊讶,IV 与加密文本一起存储,而不是保密。IV 的目的是确保两次加密文本会产生两个不同的密文,而不是为了它的保密性。你应该非常小心地保守的秘密,这就是为什么我把对这些键的访问分成它们自己的服务。另一方面,IV 可以相对公开。如果你不明白为什么,在下一节看看 salts 如何为散列工作可能会有帮助。

这段代码几乎完成了我们加密字符串所需的所有工作,除了跟踪哪个密钥用于加密字符串,您可以使用我用来跟踪哪个算法的类似技术自己实现。现在我们需要解密字符串。首先,我们需要从存储的密文字符串中提取算法和密钥索引。

protected void GetAlgorithm(string cipherText,
  out int? algorithm, out int? keyIndex,
  out string trimmedCipherText)
{
  //For now, fail open and let the calling method
  //handle issues, but in greenfield systems, consider
  //failing closed and throw an exception if
  //either the algorithm or key index are missing here

  algorithm = null;
  keyIndex = null;
  trimmedCipherText = cipherText;

  if (cipherText.Length <= 5 || cipherText[0] != '[')
    return;

  var foundAlgorithm = 0;
  var foundKeyIndex = 0;

  var cipherInfo = cipherText.Substring(1,
    cipherText.IndexOf(']') - 1).Split(",");

  if (int.TryParse(cipherInfo[0], out foundAlgorithm))
    algorithm = foundAlgorithm;

  if (cipherInfo.Length == 2 && int.TryParse(cipherInfo[1], out foundKeyIndex))
    keyIndex = foundKeyIndex;

  trimmedCipherText = cipherText.Substring(↲
    cipherText.IndexOf(']') + 1);
}

Listing 3-8Pulling algorithm and key index information from our stored string

从安全角度来看,清单 3-8 中没有太多内容;我们只是解析存储算法枚举值和键索引的字符串,然后将它们作为out参数返回给调用方法。所以,让我们直接进入解密代码。

public class SymmetricEncryptor
{
  //Same properties and constructors as Listing 3-5

  public string DecryptString( ↲
    string cipherText, string keyName)
  {
    //Removed code to check if parameters are null

    int? algorithmAsInt = null;
    int? keyIndex = null;
    string plainCipherText = null;
    GetAlgorithm(cipherText, out algorithmAsInt, out keyIndex,
      out plainCipherText);

    if (!algorithmAsInt.HasValue)
      throw new InvalidOperationException(↲
        "Cannot find an algorithm for encrypted string");

    var algorithm = ↲
      (EncryptionAlgorithm)algorithmAsInt.Value;

    var key = _secretStore.GetKey(keyName, keyIndex.Value);

    switch (algorithm)
    {
      case EncryptionAlgorithm.AES128:
        return DecryptStringAES(↲
          plainCipherText, key, algorithm);
      default:
        throw new InvalidOperationException(↲
          $"Cannot decrypt cipher text with ↲
          algorithm {algorithm}");
    }
  }

  private string DecryptStringAES(↲
    string cipherText, ↲
    string key, ↲
    EncryptionAlgorithm algorithm)
  {
    string plaintext = null;
    var keyBytes = HexStringToByteArray(key);

    //Our IV is one block, but we have two characters per byte
    var blockStringSize = GetBlockSizeInBytes(algorithm) * 2;
    var ivString = cipherText.Substring(0, blockStringSize);
    var ivBytes = HexStringToByteArray(ivString);

    var cipherNoIV = cipherText.Substring(blockStringSize,
      cipherText.Length - blockStringSize);
    var cipherBytes = HexStringToByteArray(cipherNoIV);

    using (var rijndael = Rijndael.Create())
    {
      rijndael.Key = keyBytes;
      rijndael.Padding = PaddingMode.PKCS7;
      rijndael.Mode = CipherMode.CBC;
      rijndael.IV = ivBytes;

      var decryptor = rijndael.CreateDecryptor(
        rijndael.Key, rijndael.IV);

      using (var memStream = new MemoryStream(cipherBytes))
      {
        using (var cryptStream = new CryptoStream(↲
          memStream, decryptor, CryptoStreamMode.Read))
        {
          using (var reader = new StreamReader(cryptStream))
          {
            plaintext = reader.ReadToEnd();
          }
        }
      }
    }

    return plaintext;
  }
}

Listing 3-9AES symmetric decryption in .NET

我希望此时清单 3-9 中的大部分代码已经对你有意义了。解密逻辑的实际实现与加密逻辑几乎相同,因此这里没有必要深入讨论细节。不过,我希望存储算法和密钥索引的原因已经很清楚了。这种解密方法足够智能,可以处理密文使用的任何算法和密钥,为了升级,我们需要做的就是生成新的密钥,并告诉我们的EncryptString方法使用它们。

现在我们可以在。NET,就好像这是框架的早期版本,但是将这些功能像框架中的大多数其他功能一样转移到服务中会更方便。

创建加密服务

幸运的是,创建加密服务非常简单。首先,我们需要一个接口,如清单 3-10 所示。

public interface ISymmetricEncryptor
{
  string EncryptString(string plainText, string keyName);
  string DecryptString(string cipherText, string keyName);
}

Listing 3-10Symmetric encryption interface

接下来,我们需要一个实现这个接口的类。我不会在书中这么做,因为到目前为止,你应该已经掌握了这么做所需的大部分知识。不过,我还没有谈到密钥存储。我稍后会谈到这一点,但是现在,让我们假设我们已经有了一个从密钥存储中获取加密密钥的服务,它是作为一个使用IKeyStore接口的服务添加的。

现在,清单 3-11 有了实现ISymmetricEncryptor接口的类的开端。

public class SymmetricEncryptor : ISymmetricEncryptor
{
  private IKeyStore _keyStore;

  public SymmetricEncryptor(IKeyStore keyStore)
  {
    _keyStore = keyStore;
  }
}

Listing 3-11Empty class for symmetric encryption service

正如你在清单 3-11 中看到的,这个类在其构造函数中实现了我们假设的IKeyStore。不过,实现实际的加密和解密已经被讨论过了,所以您应该能够自己完成这项工作。如果您有问题,当然欢迎您参考本书的 GitHub 资源库,位于 https://github.com/Apress/adv-asp.net-core-3-security ,其中有该服务的完整实现版本,稍微进行了重构,以便更容易添加更多算法。

最后,为了确保您可以在应用中使用该服务,您需要告诉框架该服务是可用的。您可以通过将清单 3-12 中的代码添加到 Startup.cs 文件中来实现。

public class Startup
{
  //Constructor and other code removed for brevity

  public void ConfigureServices(IServiceCollection services)
  {
    //Add other services
    services.AddScoped<ISymmetricEncryptor,
      SymmetricEncryptor>();
  }

  //Configure and other methods removed for brevity
}

Listing 3-12Adding our encryption class as a service within the framework

不要忘记以这种方式包含您的IKeyStore实现!

Note

如果您想编写遵循微软与 ASP.NET 建立的模式的代码,您更有可能围绕加密功能创建一个包装器服务,该服务调用密钥存储库和加密服务以遵守单一责任原则。我的观点是,过多的解耦导致代码难以跟踪和调试,而我的方法更容易理解。这两种方法都没有错,所以使用你最好的判断。

在 Rijndael 中使用 256 位密钥怎么样?奇怪的是,微软在他们的Aes类上实现了 256 位密钥(技术上不合适),而在他们的Rijndael类上跳过了它(完全合适)。所以,如果你想要 AES 256,使用Aes类,而不是Rijndael。不过,代码的所有其他方面都是一样的。

使用不支持的密码模式(如 CTR 或 XTS)进行加密怎么样?NET 框架?我不知道为什么微软没有添加比他们更多的加密选项支持,但既然他们丢了球,我们需要一个第三方库来填补空白。一个受欢迎的图书馆是充气城堡。

使用弹力城堡的对称加密

Bouncy Castle 是 Java 和 C# 加密库的第三方提供商。它是免费的,以 NuGet 包的形式提供。在这些例子中,我将删除两者共有的逻辑。NET 和 Bouncy Castle,只显示 Bouncy Castle 特有的代码。需要注意的一点是,Bouncy Castle 的人使用了术语 SIC(分段整数计数器)而不是 CTR,但它们是一回事。 5

var aes = new RijndaelEngine();
var blockCipher = new SicBlockCipher(aes);
var cipher = new PaddedBufferedBlockCipher(↲
  blockCipher, new Pkcs7Padding());

var iv = CreateRandomByteArray(GetIVSizeInBytes(algorithm));

var key = HexStringToByteArray(GetKey(algorithm));
var keyParam = new KeyParameter(key);

cipher.Init(true, new ParametersWithIV(keyParam, iv));
var textAsBytes = Encoding.ASCII.GetBytes(text);

var encryptedBytes = ↲
  new byte[cipher.GetOutputSize(textAsBytes.Length)];
var length = cipher.ProcessBytes(↲
  textAsBytes, encryptedBytes, 0);

cipher.DoFinal(encryptedBytes, length);

Listing 3-13Symmetric encryption using Bouncy Castle

一旦清单 3-13 中的代码完成,它就是存储最终密文的 encryptedBytes 数组,如果您愿意,您可以将它转换成一个字符串。

您可以看到,与。NET 库,这使得在没有文档的情况下更难工作(因为没有 intellisense ),并且更难使代码可重用。但无论是充气城堡还是土著。NET,我建议您编写自己的包装器代码(欢迎您使用 GitHub 库中的示例作为起点),它可以自动创建 IV 和密钥存储管理,这将抽象出两个库的混乱状态,并使未来的开发更加容易。

为了完整起见,清单 3-14 有相应的解密代码。

var aes = new RijndaelEngine();
var blockCipher = new SicBlockCipher(aes);
var cipher = new PaddedBufferedBlockCipher(↲
  blockCipher, new Pkcs7Padding());

var ivAsStringSize = GetIVSizeInBytes(algorithm) * 2;
var ivString = cipherText.Substring(↲
  0, ivAsStringSize);
var ivBytes = HexStringToByteArray(ivString);

var cipherNoIV = ↲
  cipherText.Substring(ivAsStringSize, ↲
  cipherText.Length - ivAsStringSize);
var cipherBytes = HexStringToByteArray(cipherNoIV);

var key = HexStringToByteArray(GetKey(algorithm));
var keyParam = new KeyParameter(key);

cipher.Init(false, new ParametersWithIV(keyParam, ivBytes));

var decryptedBytes = ↲
  new byte[cipher.GetOutputSize(cipherBytes.Length)];
var length = cipher.ProcessBytes(cipherBytes, 0, ↲
  cipherBytes.Length, decryptedBytes, 0);

cipher.DoFinal(decryptedBytes, length);

return Encoding.ASCII.GetString(decryptedBytes);

Listing 3-14Symmetric decryption using Bouncy Castle

在这个代码片段中,是decryptedBytes存储解密的明文,如果您愿意,可以将它转换成字符串。

现在,您应该知道如何加密文本,让我们继续学习一种不允许解密的加密方法:散列法。

散列法

对于大多数程序员来说,哈希可能有点难以理解。对我来说,当我第一次接触编程的时候,确实是这样。像加密一样,哈希将明文转换为密文。然而,与加密不同的是,不可能将密文还原成明文。此外,与加密不同,如果你散列你的名字十次,你应该得到十个相同的密文。

在我进入为什么你会做这样的事情之前,让我们谈谈如何。举一个非常简单(也非常糟糕)的散列算法的例子,可以将字符串中的每个字符转换成 ASCII 值,然后将每个值相加得到散列。对于“have ASCII 值为 104、111、117、115 和 101,加起来是 548。在这种情况下,548 是我们的“散列”值。哈希“房子”总是导致“548”,但一个人永远不能走相反的方向;“548”再也回不到“房子”了。

有了这样一个简单的散列,也很容易看出多个值可以变成同一个散列。例如,单词“狗”(100 + 111 + 103 + 115)和单词“牛奶”(109 + 105 + 108 + 107)的散列值都是 429。这被称为哈希冲突,一旦在现实世界的哈希算法中发现哈希冲突,它通常会被丢弃,除了微不足道的用途。但是除了使用当前的算法之外,你没有什么可做的,所以把这些知识收起来,让我们继续。

哈希的用途

我们现在将讨论散列的两种用途。首先,如果您需要隐藏原始数据,您应该考虑使用散列,但是您不需要知道原始数据是什么。密码就是一个很好的例子。作为一名程序员,您永远不需要知道原始密码是什么,您只需要知道提供的密码是否与原始密码匹配。(记住,不应该使用已知哈希冲突的哈希。)要知道新密码是否与存储的密码相匹配,您可以按照以下步骤操作:

  1. 以哈希格式存储原始密码。

  2. 当用户试图登录时,散列他们输入到系统中的密码。

  3. 如果新密码的哈希与原始密码的哈希匹配,您就知道密码匹配,可以让用户进入。

散列信息的第二个原因是验证数据的完整性。还记得之前的中情局三合会吗?确保您的数据没有被篡改也是一项安全责任。对原始数据进行哈希运算可以检查数据是否在系统的正常流程之外被更改过。如果您存储数据的散列版本,然后定期检查以确保存储数据的散列仍然与存储的散列相匹配,那么您就可以确定数据没有被更改。例如,如果我们想在我们的数据库中存储这本书的书名的值,高级 ASP.NET Core 3 Security,但是想验证没有人更改过书名,我们可以存储哈希值(“2723”使用我们前面提到的坏哈希算法),然后在请求时重新哈希书名。如果新的哈希与我们存储的哈希不匹配,我们可以假设有人在我们不知道的情况下更改了标题。

不幸的是,在现有的框架中,没有任何使用散列来保护数据完整性的好例子,所以现在让我们把它放在后面的口袋里,我们以后再回来讨论它。

哈希盐

散列的一个问题是,创建所谓的彩虹表相当容易,彩虹表只是一个值列表以及使用特定算法的散列值。举个例子,假设你是一名黑客,你知道你攻击的大量网站使用一种特定的算法存储密码。不要试图猜测每个用户的所有密码,您可以使用该算法预先计算几十亿个最常见密码的哈希,而不会有太大的困难,然后将您窃取的密码与预先计算的哈希列表进行匹配。现在,您可以访问彩虹表中每个密码的明文版本。

还记得我之前说过散列冲突不是你要担心的事情吗?这是因为作为一名开发人员,让哈希表抵抗彩虹表应该是你更优先考虑的事情。为了让黑客更难解决这个问题,聪明的软件开发者在他们的散列中加入了所谓的。salt 只是一个术语,表示添加到明文中的额外文本,使其哈希更难映射到明文。这里有一个真实的例子。

如前所述,在典型的网站数据库中,存储用户密码的地方很有可能是使用哈希的地方。正如我们在前一章提到的,用户在选择只有他们自己知道的随机密码方面做得不好,相反,他们经常选择显而易见的密码。如果你是一名黑客,成功窃取了一个带有密码的数据库,那么在寻找凭证时,你首先要寻找的就是普通的散列密码。在这个假设的密码库中,您可能会多次遇到“5b a61e 4c 9 b 93 F3 f 0682250 b 6 cf 8331 b 7 ee 68 FD 8”。在您方便的彩虹表中,您可以看到这只是单词“password”的散列,所以现在您已经有了相当多用户的用户名和密码组合。

但是如果您使用 salt,而不是单独散列它们的密码,您现在将散列附加信息和密码。一种可能是使用用户 ID 作为 salt。结果是相同的密码在数据库中产生了非常不同的散列,如表 3-1 所示(使用 SHA-1 只是为了举例)。

表 3-1

咸版本的同一文本和他们的 SHA-1 哈希

|

用户标识

|

要散列的值

|

结果

| | --- | --- | --- | | Seventeen | 17 密码 | f 926 a 81 e 873108197 a 91801d 44 db 5 BCA 455 b 567 | | Thirty-five | 35 密码 | 3789 B4 BD 37 b 160 a 45 db3 F6 cf 6003d 47 b 289 aa 1 de | | Ninety-nine | 99 密码 | d 75b 32 a 689 c 044 f 85 ee0f 26278 de C5 d 4 CB 71 c 666 | | One hundred and two | 102 密码 | 2864 afcf 9 f 911 EC 81 D8 a6f 62 BD E0 ba 78685 a989 | | One hundred and sixty-four | 164 密码 | e 829 c 16322d 6a 4 e 94473 Fe 632027716566965 f9a |

既然每个密码散列都是唯一的,尽管用户有相同的密码,彩虹表就没那么有效了。如果黑客想要任何特定用户的密码,他们现在必须在彩虹表中拥有一个更大的预先哈希密码数据库(并了解明文的哪一部分是盐,哪一部分是密码),或者需要创建一个全新的彩虹表,为数据库中您想要破解其密码的每个用户创建一个彩虹表。要在您的网站中实现这一点,您只需确保无论何时散列用户的密码以进行存储或比较时,都包含 ID。

更安全的方案是使用更长的盐。需要为使用整数作为 salt 的值生成哈希值会显著增加任何有效的彩虹表的大小。为了进一步增加大小,您应该考虑使用更长的 salt——32 字节或更多——以使创建彩虹表变得不切实际。

你应该把盐存放在哪里?如果您使用类似行 ID 的东西,那么您的 salt 已经为您存储了。一般来说,把盐和你的杂碎一起保存被认为是安全的,所以你不必在这方面花太多心思。对于密码,我更喜欢将任何基于行的 salt 与散列一起存储,但是您的收获可能会有所不同,这取决于我们的需求和预算。默认的 ASP.NET 用户存储也以这种方式存储盐。

在我们继续之前,最后一个注意事项:还记得我们之前讨论过 IVs 如何安全地与您的密文存储在一起吗?虽然 IVs 和 salts 在数学上非常不同,但它们的功能有些类似——随机化您的密文,使获取您的明文更加困难。在这两种情况下,特别是如果你正在考虑基于行的盐,你真正的安全是在除了保守你的随机数发生器的秘密之外的地方。

Note

您可能想知道,如果您比我在这里概述的更努力地隐藏您的静脉注射和盐,您的系统是否会更安全。简短的回答是“是的”,但是这样做的努力通常比额外的安全性更值得。

哈希算法

与加密算法一样,有几种哈希算法,其中一些您不应该再使用了。我们将在这里讨论最常见的算法。

讯息摘要 5

MD5 是一种 128 位哈希算法,在 20 世纪 90 年代和 21 世纪初非常流行。MD5 的几个问题是在 20 世纪 90 年代末和 21 世纪初发现的,包括哈希冲突和哈希本身的安全性弱点,使它成为大多数用途的无用算法。安全问题非常严重,用 MD5 散列的密码可以在几分钟内被破解。一般情况下,应该避免使用这种算法。这里提到它有两个原因:

  1. 尽管 MD5 的第一个问题是在 20 多年前发现的,但 MD5 的使用仍然出现在现实世界中。

  2. MD5 仍然可以用于一些完整性检查。

MD5 现在唯一真正的用途是比较哈希值以检查意外修改,即使这样,我也建议使用更新的算法。

沙(或)

安全哈希算法(SHA)是一种 160 位算法,于 1995 年首次发布,十多年来一直是哈希算法的标准。它的实现有点类似于 MD5,尽管它具有更大的块大小,并且纠正了许多安全缺陷。然而,自 2000 年代中期以来,安全社区中越来越多的人建议不要使用 SHA,而是建议使用一种 SHA-2,部分原因是 SHA 有大量彩虹表,部分原因是发现了 SHA 哈希冲突。 6

注意,你可能会看到沙被称为。这两者(通常)没有区别。很简单,当开发 SHA 时,没有 SHA-2,因此不需要区分不同的版本。

SHA-2

SHA-2 标准于 2001 年首次发布,在撰写本文时,它还是。NET 核心,取代 SHA 和 MD5。在内部,SHA-2 类似于 SHA-1 和 MD5。令人困惑的是,你几乎不会经常看到提到“SHA-2”,而是你会看到 SHA-512,SHA-256,沙-224,SHA-384 等。这些都属于“SHA-2”的范畴,分为以下两类:

  1. SHA-512 和 SHA-256,它们是 SHA-2 算法的 512 位和 256 位实现。

  2. 其他的都是 512 的删节版。例如,SHA-384 是阿沙-512 哈希结果的前 384 位。

虽然 SHA-2 通常被认为是安全的,但它只是 SHA 的一个更长版本,并且已知存在一些攻击。不幸的是。NET 开发人员,。NET Core 还不支持 SHA-2 的替代品 SHA-3。

沙-3

SHA-3 从根本上不同于 SHA-2、SHA 和 MD5,原因超出了本书的范围。尽管在功能上,它的行为是一样的。如前所述。NET 不支持 SHA-3,但是可以用 Bouncy Castle 实现。

如果你需要一个散列算法,在大多数情况下,暂时使用 SHA-2 应该是安全的。不过,直接迁移到 SHA-3 并不是一个坏主意,这样可以避免以后的迁移问题。

PBKDF2、bcrypt 和 scrypt

SHA 家族的散列都被设计得很快。如果您使用散列来验证数据的完整性,这是非常好的。如果我们存储密码,并且我们想让黑客很难创建彩虹表来计算明文值,这就不太好了。因此,这个问题的一个解决方案是与大多数程序员的本能相反,创建一个低效的函数来散列密码。

PBKDF2、bcrypt 和 scrypt 都是不同类型的哈希,专门设计为比 SHA 运行效率更低,作为防止通过 rainbow 表窃取数据的额外保护层。这三个都是可调的,所以你可以调整散列速度,以适应你的特定环境。PBKDF2 和 bcrypt 通过允许程序员配置获得结果所必须经历的迭代次数来实现这一点,而 scrypt 试图低效地使用 RAM。

你应该使用哪一个?美国国家技术标准协会(NIST)发布了一项标准,推荐使用 PBKDF2 对密码进行哈希处理。然而,一个问题是,自本文发表以来,GPU(图形处理单元,为显卡而生,但越来越多地用于可以通过并行处理改善的工作负载,如加密和机器学习)的使用呈指数增长,似乎 PBKDF2 比 bcrypt 更容易受到使用 GPU 的暴力攻击。虽然我找不到哪一个是最好的行业共识,所以在这一点上,你可能觉得有理由使用这三个中的任何一个。

默认的 ASP.NET Core 密码散列器使用 PBKDF2,但是使用少量的迭代,所以我将在本章的后面向您展示它是如何工作的,这样您就可以增加迭代的次数以达到更安全的目的。

哈希和搜索

你们中的许多人现在会有一个问题:如果我以加密格式存储我的 PII、PAI 和 PHI,如何搜索这些数据?例如,不应该用明文存储电子邮件地址,因为这是 PII,但是通过电子邮件地址搜索用户是一种非常常见(也是必要的)的做法。我们如何解决这个问题?

一种解决方案是以散列格式存储所有加密信息,最好是在单独的数据存储中。这样,如果您需要通过电子邮件地址搜索用户,您可以散列您希望查找的电子邮件地址,将该散列与您数据库中已有的电子邮件地址的散列进行比较,然后返回电子邮件散列匹配的行。

你可能会问的下一个问题是:盐是如何影响搜索的?如果每个数据点都有一个单独的 salt,那么您的数据会更加安全,但是为了做到这一点,您需要使用每行的 salt 重新散列您的搜索值。使用前面的密码示例,如果您想在密码以 SHA-1 格式存储的数据库中搜索密码等于“password”的所有用户,您不能只搜索“password”的哈希值“5b a61e 4c 9 b 93 F3 f 0682250 b 6 cf 8331 b 7 ee 68 FD 8”。对于 ID 为 17 的用户,您需要重新散列“17password”并将密码散列与“f 926 a81e 8731018197 a 91801d 44 db 5 BCA 455 b 567”进行比较。然后,要查看用户 ID 35 是否有该密码,您需要散列“35password”并将“3789 B4 BD 37 b 160 a 45 db 3 f 6 cf 6003d 47 b 289 aa 1 de”与数据库中的值进行比较。这是可笑的低效。

如果您需要搜索散列数据,唯一可行的解决方案(除了完全跳过一个 salt 之外)是用相同的 salt 散列每一段数据。例如,你的电子邮件可能有一个盐,名字是另一个,姓氏是另一个,等等。然后,要在数据库中查找每个名字为“John”的人,只需用 salt 散列搜索文本(即“John”),然后在数据库中查找该散列。在这些情况下,使用长盐是至关重要的。重复使用短盐不会让你很安全。

Caution

仅当您想要搜索该数据时,才需要重用盐。对于没有人会搜索的数据,比如密码(即应该禁止搜索所有密码为“P@ssw0rd”的用户),使用基于行的 salts 是一个好主意。

你应该在哪里储存柱基盐?我通常把它们和我的加密密钥放在一起。虽然这可能比所需要的更安全,但是它是一种已经存在并且安全的存储机制,所以为什么不重用它呢?

在我们继续之前,最后一点意见是:如果您需要存储加密的(这样您可以恢复原始值)和散列的(这样您可以在搜索中包含它)数据,您应该将这些列存储在不同的位置。如果您还记得,我们之前说过,好的加密要求每次加密的密文都不同。另一方面,好的散列要求密文与每个散列保持一致。如果散列值与加密值一起存储,那么黑客不仅能够将普通值组合在一起,而且他们现在已经掌握了进入您的加密算法的线索,可以帮助他们窃取您的加密密钥和数据。

加入进来。网

如果您要查找如何使用 SHA-512 在线散列数据,您可能会看到类似清单 3-15 的内容。

public byte[] HashSHA512(byte[] toHash)
{
  using (SHA512 sha = new SHA512Managed())
  {
    return sha.ComputeHash(toHash);
  }
}

Listing 3-15Code showing basics of hashing in .NET

像加密算法一样,我们传递字节数组,而不是字符串。这段代码创建一个SHA512Managed对象的新实例,然后使用它的ComputeHash()方法(大概)创建一个名为“toHash”的byte[]的散列。让我们解决这个问题,这样我们就可以散列字符串,只要我们做了更改,我们就自动追加一个 salt。

public string HashSHA512(string plainText, string salt,
  bool saveSaltInResult)
{
  var fullText = string.Concat(plainText, salt);
  var data = Encoding.UTF8.GetBytes(fullText);

  using (SHA512 sha = new SHA512Managed())
  {
    var hashBytes = sha.ComputeHash(data);
    var asString = ByteArrayToString(hashBytes);

    if (saveSaltInResult)
      return string.Format("[{0}]{1}{2}",
        (int)HashAlgorithm.SHA512, salt, asString);
    else
      return string.Format("[{0}]{1}",
        (int)HashAlgorithm.SHA512, asString);
  }
}

Listing 3-16Code showing hashing that uses strings instead of bytes

清单 3-16 中的代码稍微复杂一点。这个方法处理 salt 的添加(只要保持一致,salt 是第一个还是最后一个并不重要),将字符串转换成ComputeHash()方法所期望的字节数组,然后获取结果散列并将其转换回字符串以便于存储。对于基于行的 salt,您还可以选择自动将 salt 保存在结果中。我不会在这里展示,但是如果你想看一个工作版本,你可以看看这本书的 GitHub 库中的源代码。

但是,如果您自动包含 salts,那么如何轻松地比较明文和它的散列版本呢?如果您没有提前计划,升级哈希算法会很困难。但是你也可以为此创建一个方法。

public bool MatchesHash(string plainText, string hash)
{
  string trimmedHash = "";
  string salt = "";
  int? algorithmAsInt = 0;
  int? hashIndex = 0;

  GetAlgorithm(hash, out algorithmAsInt,
 out hashIndex, out trimmedHash);

  if (algorithmAsInt.HasValue && trimmedHash.Length > SaltLength)
  {

  salt = trimmedHash.Substring(0, SaltLength);
  trimmedHash = trimmedHash.Substring(SaltLength);
  }

  else
  {
  salt = null;
  }
  if (!algorithmAsInt.HasValue) return false;
  var hashAlgorithm = (HashAlgorithm)algorithmAsInt.Value;
  var hashed = CreateHash(plainText, salt, hashAlgorithm, true);
  return hashed == hash;
}

Listing 3-17Hash match method

在清单 3-17 中,CreateHash只不过是一个方法,它是一个switch / case语句,根据选择的算法调用正确的散列算法方法。只要你假设盐的长度不变,其他的就没什么神奇的了。我们找到存储值的算法,用该算法散列我们的新值,然后比较两个值。更新这个方法来处理不包含 salt 的散列也很容易,只需将 salt 作为参数传入,当参数不为空时,跳过从散列值中提取 salt。

现在,您已经拥有了创建一个可工作且易于使用的散列类的所有部件,我们可以用它在 ASP.NET 创建一个服务。如果你想看完整的工作版本,你可以访问这本书的 GitHub 库。然而,在我们到达那里之前,我们需要解决一个问题:“如果我想使用最好的算法,比如 SHA-3,我该怎么做?”不幸的是,像对称加密一样,微软没有给我们所有我们应该有的选择。所以,我们还是坚持使用第三方库来实现最好的加密技术。幸运的是,充气城堡又来救我们了。

SHA-3 散列与充气城堡

幸运的是,使用 Bouncy Castle 散列的代码比加密的代码要短得多。清单 3-18 中列出了这个类,不过我会让您来加入盐、匹配和其他必要的逻辑,使这个类成为一个真正健壮的类。

public static string Hash(string valueToHash)
{
  var sha3 = new Sha3Digest(512);
  var hashedBytes = new byte[sha3.GetDigestSize()];
  var toHashAsBytes = Encoding.ASCII.GetBytes(valueToHash);

  sha3.BlockUpdate(toHashAsBytes, 0, toHashAsBytes.Length);
  sha3.DoFinal(hashedBytes, 0);

  return ByteArrayToString(hashedBytes);
}

Listing 3-18Hashing SHA-3 with Bouncy Castle

与加密和解密方法一样,DoFinal()将密文写入字节数组,在本例中为hashedBytes。如前所述,我将剩下的逻辑(如算法跟踪和 salt 管理)留给您来实现。

PBKDF2 哈希在。网

如前所述,ASP.NET Core 默认密码哈希使用 PBKDF2,但迭代次数相对较少。我将在这里向您展示如何纠正这种情况。

byte[] hashBytes = KeyDerivation.Pbkdf2(
                     password: plainText,
                     salt: saltAsBytes,

                     //.NET 3.1 uses HMACSHA256
                     prf: KeyDerivationPrf.HMACSHA512,

                     //.NET 3.1 uses 10,000 iterations
                     iterationCount: 100000,

                     //.NET 3.1 uses 32 bytes
                     numBytesRequested: 64)
                   );

Listing 3-19PBKDF2 in .NET

这里有几点需要注意:

  • 不像大多数本地人。NET 哈希,PBKDF2 接受一个字符串密码。它接受一个显式的 salt,该 salt 应该存储为一个字节数组。

  • 默认实现使用 SHA-256,此处已升级到 SHA-512。

  • 迭代次数增加了十倍,达到 100,000 次。除非你有一个特别弱的托管服务,这应该不会给你带来任何性能问题。

  • 因为我们将散列算法输出的大小增加了一倍,所以从函数返回的字节数也增加了一倍。

您已经看到了如何将这个结果变回字符串,如何管理算法选择,等等。,所以我将把它留给您来实现。

创建散列。网络服务

在进入下一个主题之前,我们需要做的最后一件事是在 ASP.NET 框架内创建一个新的散列服务,以便我们在进行认证时可以使用它。首先,让我们在清单 3-20 中为我们自己的散列服务定义接口。

public interface IHasher
{
  string CreateHash(string plainText,
    HashAlgorithm algorithm);
  string CreateHash(string plainText, string salt,
    HashAlgorithm algorithm);
  bool MatchesHash(string plainText, string hash);
}

Listing 3-20Interface for custom hashing service

这些应该看起来很熟悉,所以我不会在这里深入探讨。我们还需要实现IPasswordHasher接口,它有两个方法,每个方法都很容易实现,可以在清单 3-21 中看到。

public string HashPassword(IdentityUser user, string password)
{
  return CreateHash(password, HashAlgorithm.PBKDF2_SHA512);
}

public PasswordVerificationResult VerifyHashedPassword(
  IdentityUser user, string hashedPassword,
  string providedPassword)
{
  var isMatch = MatchesHash(providedPassword, hashedPassword);

  if (isMatch)
    return PasswordVerificationResult.Success;
  else
    return PasswordVerificationResult.Failed;
}

Listing 3-21Implemented methods from the IPasswordHasher interface

这本书的 GitHub 帐户中提供了整个服务的源代码。

我们还有最后一步。由于队列中已经有一个IPasswordHasher服务,您需要替换而不是添加这个服务,如清单 3-22 所示。

public class Startup
{
  //Constructor and properties removed

  public void ConfigureServices(IServiceCollection services)
  {
    //Other services removed for brevity

    services.Replace(new ServiceDescriptor(
      serviceType: typeof(IPasswordHasher<IdentityUser>),
      implementationType: typeof(Hasher),
      ServiceLifetime.Scoped));
  }
}

Listing 3-22Replacing the existing IPasswordHasher implementation in Startup.cs

Note

如果您正在升级现有的应用,请确保您的IPasswordHasher实现足够智能,能够理解您现有的密码。如果您有以默认格式存储的密码,您可以通过从 ASP.NET 源代码复制源代码或者创建一个现有类的实例并调用它,然后在您的MatchesHash方法中处理这个场景。

不对称加密

由于我们已经尽力指定我们目前为止谈论的加密类型是对称加密,人们可以有把握地假设有一种加密类型叫做非对称加密。但是什么是非对称加密呢?简而言之,对称加密只有一个密钥来加密和解密数据,而非对称加密有两个密钥,一个可以与他人共享的公钥和一个必须始终保密的私钥。是的,哪个是哪个很重要。私钥包含的信息比公钥多得多,所以不要混淆这两者。

非对称加密之所以有用,是因为如果你用公钥加密某个东西,唯一能解密该信息的是私钥。但是反过来也一样。如果你用私钥加密,唯一能解密信息的是相应的公钥。

这为什么有用?有两种不同的用法,这取决于您是使用私钥还是公钥进行加密。

如果你用公钥加密数据,那么私钥持有者是唯一能解密信息的人。为什么不用对称加密呢?对于对称加密,发送方和接收方需要拥有相同的密钥。但是如果你有一个安全的方法来交换密钥,那么理论上你也有一个安全的方法来交换消息。但是如果你使用某人的公钥,那么你就可以发送一条私人消息,而不需要担心密钥交换。

用私钥加密数据起初听起来并不十分有用;毕竟,任何用私钥加密的消息都可以用现成的公钥解密,这使得消息本身不太私密。但是,如果您总能用公钥解密消息,那么您就知道是哪个私钥加密了它,这就意味着您知道是哪个用户(那个私钥的所有者)发送了它。

数字签名

在我们开始讨论如何在?NET,要说一下数字签名。数字签名的目的是提供某种保证,确保消息是由特定的人发送的,并且在传输过程中没有被修改。换句话说,我们可以使用数字签名来帮助我们提供不可否认性

怎么会这样?首先,如果我们散列一条消息,我们可以通过散列该消息并将其与我们得到的散列进行比较来确保该消息没有被改变。但是作为一个额外的安全层,我们可以用我们的私钥加密哈希结果,确保哈希本身没有被修改,并且我们可以追踪消息到私钥的所有者。

中的不对称加密。网

非对称加密中有一个我们没有谈到的限制,即使大多数非对称算法都是像 Rijndael 一样的块密码,据我所知,没有任何密码模式可以对大于块大小的数据进行加密。因此,我们只能安全地加密小块数据。这没问题,原因有二:

  • 非对称加密比对称加密慢,因此您通常同意对称加密算法,然后仅使用非对称加密来交换对称算法密钥。

  • 哈希足够小,可以用非对称算法加密,所以我们仍然可以对数字签名使用非对称加密。

没有必要使用非对称加密来构建自定义的密钥交换机制,因为创建一个证书并使用 HTTPS/SSL 将为我们做所有的事情,甚至更多。因此,我在这里只演示如何创建数字签名。

像对称加密和散列一样,如果您不知道自己在做什么和为什么做,那么您将在网上找到的非对称加密的示例代码并不容易使用。因此,这里有一个用 RSA(一种常见的非对称算法)创建和验证数字签名的更有用的实现。首先,我将向您展示如何在清单 3-23 中生成密钥。

public static class AsymmetricKeyGenerator
{
  public static KeyPair GenerateKeys()
  {
    using (var rsa = new RSACryptoServiceProvider(2048))
    {
      rsa.PersistKeyInCsp = false;

      var keyPair = new KeyPair();

      keyPair.PrivateKey = ↲
        rsa.SendParametersToXmlString(true);
      keyPair.PublicKey = ↲
        rsa.SendParametersToXmlString(false);

      return keyPair;
    }
  }

  public struct KeyPair
  {
    public string PublicKey { get; set; }
    public string PrivateKey { get; set; }
  }
}

Listing 3-23Generating a public/private key pair in .NET

RSA 是基于很难找到非常大的数的质因数这一事实。在using (var rsa = new RSACryptoServiceProvider(2048))中,我们告诉代码使用一个 2048 位的质数,这个质数应该可以安全使用,直到 RSA 本身使用起来不安全为止(稍后会详细介绍)。

我们还没有解决的一个问题是密钥存储。对称加密和非对称加密的问题是一样的:你把你的密钥存储在哪里,以便它们尽可能远离黑客?那个。默认情况下,NET 实现将密钥存储在窗口中,但这几乎不是我们想要的功能,所以我们用rsa.PersistKeyInCsp = false;将其关闭。稍后,我们将进一步讨论密钥存储。

根据我的经验,很难正确生成密钥,所以虽然显式地创建 XML 文档是一件痛苦的事情,但也更容易实现。

public static void ImportParametersFromXmlString(↲
  this RSA rsa, string xmlString)
{
  var parameters = new RSAParameters();

  var xmlDoc = new XmlDocument();
  xmlDoc.LoadXml(xmlString);

  if (!xmlDoc.DocumentElement.Name.Equals("key"))
    throw new NotSupportedException("Format unknown");

  foreach (var node in xmlDoc.DocumentElement.ChildNodes)
  {
    switch (node.Name)
    {
      case "modulus":
        parameters.Modulus = ↲
          (string.IsNullOrEmpty(node.InnerText) ? null : ↲
            Convert.FromBase64String(node.InnerText));
        break;
      case "exponent":
        parameters.Exponent = ↲
          (string.IsNullOrEmpty(node.InnerText) ? null : ↲
            Convert.FromBase64String(node.InnerText));
        break;
      case "p":
        parameters.P = ↲
          (string.IsNullOrEmpty(node.InnerText) ? null : ↲
            Convert.FromBase64String(node.InnerText));
        break;
      case "q":
        parameters.Q = ↲
          (string.IsNullOrEmpty(node.InnerText) ? null : ↲
            Convert.FromBase64String(node.InnerText));
        break;
      case "dp":
        parameters.DP = ↲
          (string.IsNullOrEmpty(node.InnerText) ? null : ↲
            Convert.FromBase64String(node.InnerText));
        break;
        case "dq": ↲
          parameters.DQ = ↲
            (string.IsNullOrEmpty(node.InnerText) ? null : ↲
              Convert.FromBase64String(node.InnerText));
          break;
        case "inverseq":
          parameters.InverseQ = ↲
           (string.IsNullOrEmpty(node.InnerText) ? null : ↲
             Convert.FromBase64String(node.InnerText));
          break;
        case "d":
          parameters.D = ↲
            (string.IsNullOrEmpty(node.InnerText) ? null : ↲
              Convert.FromBase64String(node.InnerText));
          break;
    }
  }
  else
  {
    throw new Exception("Invalid XML RSA key.");
  }

  rsa.ImportParameters(parameters);
}

public static string SendParametersToXmlString(↲
  this RSA rsa, bool includePrivateParameters)
{
  RSAParameters parameters = ↲
    rsa.ExportParameters(includePrivateParameters);

    return string.Format("<key>↲
                          <modulus>{0}</modulus>↲
                          <exponent>{1}</exponent>↲
                          <p>{2}</p>↲
                          <q>{3}</q>↲
                          <dp>{4}</dp>↲
                          <dq>{5}</dq>↲
                          <inverseq>{6}</inverseq>↲
                          <d>{7}</d>↲
                          </key>",↲
    parameters.Modulus != null ? ↲
      Convert.ToBase64String(parameters.Modulus) : null,
    parameters.Exponent != null ? ↲
      Convert.ToBase64String(parameters.Exponent) : null,
    parameters.P != null ? ↲
      Convert.ToBase64String(parameters.P) : null,
    parameters.Q != null ? ↲
      Convert.ToBase64String(parameters.Q) : null,
    parameters.DP != null ? ↲
      Convert.ToBase64String(parameters.DP) : null,
    parameters.DQ != null ? ↲
      Convert.ToBase64String(parameters.DQ) : null,
    parameters.InverseQ != null ? ↲
      Convert.ToBase64String(parameters.InverseQ) : null,
    parameters.D != null ? ↲
      Convert.ToBase64String(parameters.D) : null);
}

Listing 3-24Encryption key to and from XML

我们不会讨论清单 3-24 中的每一个值是什么意思,只要知道你现在可以没有太多麻烦地保存和加载密钥。与对称加密和散列一样,整个方法的源代码可以在本书的 GitHub 库中找到。

Caution

当谈到一般的加密时,每个算法都有一个有限的生命周期。黑客和计算机总是在进步,所以某个算法需要被取代只是时间问题。对于非对称加密来说尤其如此。不幸的是,RSA 是最常见的非对称加密算法,它依赖于将大数分解为素数因子的困难,这对于量子计算机来说比传统计算机容易得多。您将需要替换任何 RSA 用法(并且最终替换所有其他算法),所以努力使您的代码足够健壮以支持以后轻松交换算法。

密钥存储

正如我前面提到的,密钥存储是一个重要的话题。我将在这里回顾一下基础知识,但是如果您负责决定如何在您的团队中存储密钥,请继续阅读这个主题。无论哪种方式,您的系统管理团队都应该帮助您确定在您的环境中存储密钥的最佳方式。

如前所述,存储密钥的目标是让它们尽可能远离黑客,尽可能远离您的加密数据。将密钥存储在数据库中不是一个好主意!如果您的整个数据库被盗,将加密数据与密钥存储在一起只比将数据存储在明文中稍微安全一点。你有什么选择?

  • 硬件安全模块【HSM】:这是为了存储密钥而构建的硬件。这些都很贵,但是你可以通过云以更低的价格获得。

  • Windows DPAPI :这是 Windows 内置的一个密钥存储机制。如果您运行的是 Windows,这是一个相对容易实现的解决方案,但请注意,由于密钥存储在计算机本身,这使得移动服务器或运行负载平衡变得更加困难。

  • 文件系统中的文件:这应该是不言自明的。它也不是非常安全,因为如果你的网站可以找到这些文件,那么黑客也可以找到它们。

  • 单独的加密服务:你也可以创建一个加密和解密数据的 web 服务,让密钥本身尽可能远离你的应用。如果实现,这项服务应该受到防火墙的保护,以防止除了网站本身以外的任何东西或任何人的访问。

如果必须将密钥存储在数据库中,则使用存储在配置文件中的密钥对密钥进行加密。这导致更多的处理,并且在存储大量需要保密的数据的大型关键任务系统中被认为是不安全的。但是,如果您的系统很小,并且没有存储太多敏感数据,那么这种方法可能是实现某种程度上安全的密钥存储的一种快速而又肮脏的方法。

不要创建自己的算法

你可能早些时候已经注意到了,我没有深入探究每种算法是如何工作的。这有两个原因。

即使是拥有博士学位的人发明的算法有时也能被相当容易地破解。今天推荐使用的加密算法在面向大众之前已经经历了数年,有时是数十年的研究、测试、尝试破解和同行评审。除非你是世界上最优秀的加密专家之一,否则你不应该尝试在生产系统中使用你自己开发的加密算法。相反,你应该像我们在本章中所做的那样,专注于使用已经过安全社区审查并由可信来源实现的算法。

第二,即使是安全的算法也可以用不安全的方式实现。我们之前谈到了旁路攻击,并给出了一个黑客根据 CPU 的辐射破解加密算法的例子。一些加密实现试图混淆处理以使这种破解更加困难。你的实现(很可能)没有。这里的教训是不要试图创建你自己的算法,甚至是你自己的这些算法的实现

出于这些原因,本书强烈建议您使用内置的加密算法。NET 或可信库中的实现。没有必要知道更多,除非你想满足你的好奇心。

加密的常见错误

在我们进入下一章之前,当谈到一般安全性时,有必要指出关于加密的最后两件事。首先,现在应该很明显,编码不是加密。是的,如果你有 Base64 编码的文本,它看起来很难阅读。问题是,许多黑客和大多数黑客软件会立即识别以 Base64 编码的文本,并立即解码。其他编码机制也是如此。编码有它的用途,但隐私不在其中。

第二,当我与开发人员交谈时,他们经常会问是否应该对他们不安全地存储或处理的值进行加密,例如将敏感信息放在黑客容易访问的地方。我的回答几乎总是“不”。如果您处理数据不安全,您应该几乎总是修复处理。在这些情况下,正确地加密您的信息通常比仅仅使用后面描述的技术来保护您的信息要多得多。是的,加密可以保护您的数据,但它不是帮助您避免其他安全最佳实践的万灵药。

摘要

本章介绍了网站中常用的不同类型的加密技术:对称加密、哈希和非对称加密。我们讨论了何时使用每种方法,以及何时组合使用这些方法来创建新功能(如数字签名)或解决问题(如散列数据)以使搜索加密数据更容易。

我们现在已经完成了关于一般安全概念的章节。现在是时候深入研究 web 安全性了。

Footnotes [1](#Fn1_source) [T2`www.schneier.com/blog/archives/2004/10/the_legacy_of_d.html`](http://www.schneier.com/blog/archives/2004/10/the_legacy_of_d.html)   [2](#Fn2_source) 图像由 lewing@isc.tamu.edu 创建,用 GIMP ( [`https://en.wikipedia.org/wiki/GIMP`](https://en.wikipedia.org/wiki/GIMP) )创建   [3](#Fn3_source) [T2`https://en.wikipedia.org/wiki/File:Tux_ecb.jpg`](https://en.wikipedia.org/wiki/File:Tux_ecb.jpg)   [4](#Fn4_source) [T2`https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.rijndaelmanaged?view=netframework-4.8`](https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.rijndaelmanaged%253Fview%253Dnetframework-4.8)   [5](#Fn5_source) [T2`https://people.eecs.berkeley.edu/~jonah/bc/org/bouncycastle/crypto/modes/SICBlockCipher.html`](https://people.eecs.berkeley.edu/%257Ejonah/bc/org/bouncycastle/crypto/modes/SICBlockCipher.html)   [6](#Fn6_source) [T2`www.theregister.co.uk/2017/02/23/google_first_sha1_collision/`](http://www.theregister.co.uk/2017/02/23/google_first_sha1_collision/)   [7](#Fn7_source) [T2`https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-132.pdf`](https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-132.pdf)   [8](#Fn8_source) [T2`https://medium.com/@mpreziuso/password-hashing-pbkdf2-scrypt-bcrypt-1ef4bb9c19b3`](https://medium.com/%2540mpreziuso/password-hashing-pbkdf2-scrypt-bcrypt-1ef4bb9c19b3)