在 Golang 中使用 AES 加密和解密大型数据流

举报
Rolle 发表于 2023/12/19 17:40:58 2023/12/19
【摘要】 加密是当今互联网安全的关键组成部分。Go 编程语言的“包含电池”理念意味着加密和解密数据所需的许多基元都包含在该语言中。然而,文档可能简洁且难以理解,密码学本身就是一个复杂而困难的主题。因此,要理解如何使用这些基元来实现更高级别的目标(例如加密文件)并不容易。如果你在互联网上搜索过在 Golang 中使用 AES 加密,你无疑会发现有很多很多错误使用 AES 加密的例子,或者用于非常特殊的极...

加密是当今互联网安全的关键组成部分。Go 编程语言的“包含电池”理念意味着加密和解密数据所需的许多基元都包含在该语言中。然而,文档可能简洁且难以理解,密码学本身就是一个复杂而困难的主题。因此,要理解如何使用这些基元来实现更高级别的目标(例如加密文件)并不容易。

如果你在互联网上搜索过在 Golang 中使用 AES 加密,你无疑会发现有很多很多错误使用 AES 加密的例子,或者用于非常特殊的极端情况,比如加密与 AES 块大小完全相等的纯文本,或者通过一次将整个文件读取到内存中来一举加密文件。显然,对于大多数应用程序来说,这些用例都不实用。(大多数加密整个文件的演示都包含一个明显的错误,我将在下面描述。

如果你想做一些理智的事情,比如加密比单个AES块更长的明文,而不先将整个文件读入内存,那么可以提供帮助的资源少得惊人。

这些要求对于在内存受限的系统(如嵌入式平台或云功能即服务资源)中编写代码的开发人员尤其重要。就我而言,我在 AWS Lambda 上运行代码,并且需要加密数 GB 的文件,而我的代码在只有 128MB RAM 的 VM 中运行。显然,在这种情况下,不可能将整个文件读取到内存中并在单个操作中对其进行加密。

我发现,就像你一样,这个用例没有太多的指导。AES 完全能够一次加密一个块的明文,而无需将整个明文或密文保存在内存中,但是没有手持文章演示如何在 Go 中做到这一点。所以我决定写一个。

在本文中,我将向您展示如何使用 AES 在可管理的块中加密大型数据流,就像您在 for 循环中所做的那样。此外,我们将使用 Go 的标准 io 来做到这一点。Reader 和 io.编写器接口,这将使这些组件易于集成到更大的数据管道中。

让我们从(只是一点点)理论开始,因为当我们开始编码时,背景知识是有帮助的。

高级加密标准

AES(最初名称为“Rijndael”)是一种块加密标准,于 2001 年被 NIST 采用以取代 DES 标准。AES 是一种分组密码,这意味着它只能对块中的数据进行加密或解密。AES 块为 16 字节。如果加密的明文的长度不是 AES 块大小的整数倍,则必须在明文末尾添加填充以达到偶数块大小倍数。(如果你一直在阅读关于AES的“教程”,而作者没有解决这个问题,那么作者可能不知道他或她在说什么!

AES 密钥的长度可以是 128、192 或 256 位(16、24 或 32 字节)。请注意,无论密钥有多长,块大小始终为 16 字节。

AES 是一种对称密码,这意味着使用相同的密钥进行加密和解密。

尽管年代久远,但 AES 已被证明非常安全。迄今为止,还没有已知的实际攻击允许攻击者读取使用 AES 加密的数据,前提是实施得当。

AES Modes AES 模式

AES 本身只能将 16 字节的明文块加密为 16 字节的密文(反之亦然)。AES 的实际应用要求在更大的上下文中使用 AES,称为“模式”,以便为密码系统提供附加功能。

AES 可以在以下几种模式下运行,最流行的是:

  • (电子代码簿)
  • (密码块链接)
  • (密码反馈)
  • (输出反馈)
  • (计数器)
  • (伽罗瓦/计数器)

在 ECB 模式下,明文的每个块都使用 AES 密钥独立加密。因此,重复的明文块将导致重复的密文块。这导致了明显的弱点,使欧洲央行几乎不适合任何实际应用。除非你真的、真的知道你在做什么以及为什么(在这种情况下,你可能没有阅读这篇文章),否则不要使用欧洲央行。

在 CBC 模式下,每个明文块都使用 AES 密钥和前一个块的输出的组合进行加密。由于前一个块实际上是一个随机的字节集,因此 CBC 可确保明文中的重复数据不会导致重复的密文块。每个密文块不仅取决于明文和密钥,还取决于前面的密文块。

CBC 不支持身份验证。无法确定密文是否在加密和解密之间被更改。如果攻击者可以获取密文、更改密文并要求您解密密文,则可以使用各种类型的攻击来恢复明文。如果攻击者可能有权访问加密数据(例如,您正在加密浏览器 Cookie),则 CBC 可能不是适合您的加密模式。事实上,各种权威机构都开始弃用 AES-CBC,转而使用支持身份验证的模式。也许我会做一些研究,并在以后写一篇关于这些模式之一的文章。

其他操作模式适用于某些用例,但 CBC 是 AES 最常用的模式,适用于加密文件,这是我怀疑大多数读者都感兴趣的用例。(但是,请参阅本文末尾的免责声明,了解重要注意事项。

AES 密钥和初始化向量

AES 密钥是 16、24 或 32 字节的字符串,用于加密和解密数据。密钥是执行加密的进程和执行解密的进程之间的共享密钥。“共享密钥”必须同时可用于加密过程和解密过程,但不能用于其他任何人。加密数据的安全性取决于潜在攻击者无法使用的密钥。密钥管理(将密钥分发给应该拥有密钥的各方,同时防止其他各方访问它们)是任何密码系统的核心组成部分。密钥管理可能是一个非常困难的话题,我将在本文中完全忽略这个话题,因为它与 AES 加密/解密的机制无关,但作为使用这些技术的开发人员,密钥管理绝对必须成为您更大解决方案的一部分。

CBC 模式下的每个数据块可以描述为:

Ct = f(Pt, K, C(t-1))
Ct = f(Pt, K, C(t-1))

也就是说,密文块“t”是明文块“t”、AES 密钥和前一个块的密文的函数。

这给我们带来了一个先有鸡还是先有蛋的问题:如果加密函数的输入之一需要前一个块的输出,我们如何加密第一个数据块?

CBC 通过使用“初始化向量”(IV)来解决这个问题。IV 是一个 16 字节的字符串,用于为加密过程的初始状态设定种子。IV 需要从 AES-CBC 加密密文中恢复第一个明文块。

请注意,AES 本身需要密钥,但 IV 仅与 CBC 模式相关。使用 AES 的其他模式不需要 IV。

AES 密钥和 IV 都应由加密安全的随机数生成器生成。每个加密数据流的 AES 密钥和 IV 都应该是唯一的。IV 不应从可预测的过程(例如计数器)生成。在多个加密会话中重复使用 AES 密钥或 IV(或使用可预测的密钥/IV)使攻击者能够使用重复使用的密钥或 IV 获取大量密文语料库,从而更实际地恢复明文。如果攻击者可能能够任意更改您的密文并要求您解密它,那么您需要考虑一些严重的事情,这些事情远远超出了本文的范围,但您的密码系统可能比您想象的要弱得多。(如果您想深入那个兔子洞,请参阅此 Stack Overflow 问题的第一个答案。

根据密码学理论,密钥必须在解密方和加密方之间保持机密,但 IV 不是机密,可以在不损失安全性的情况下暴露给潜在的攻击者。由于 IV 必须可用于解密过程,因此将未加密的 IV 作为加密数据流的一部分包含在内的情况并不少见。或者,由于 IV 仅与解密加密数据的初始块相关,因此一些 AES-CBC 解决方案将 16 字节的随机数预置到密文中,并使用第二个 AES 块启动“真实”数据流。

使用 AES 逐块加密数据

有了这个理论,让我们构建一些实用的东西!

在 Go 中,密码模式与底层密码本身分开管理。BlockMode 类型管理从上一个块传播信息以加密下一个块所需的状态。当下一个区块被加密/解密时,BlockMode 将执行必要的操作,调用密码对象进行加密/解密,并保留加密/解密下一个区块所需的信息。在实践中,BlockMode 是密码之上的抽象。我们的代码必须创建密码对象,但只能通过 BlockMode 对象与它交互。(是的,这确实表明可以为其他 AES 操作模式开发其他块模式类型。

Go 在 cipher.go 包中提供了 BlockMode 接口。此接口提供单个方法 CryptBlocks() 来加密或解密多个块。CBCEncrypter 类型(实现 BlockMode 接口)在调用 CryptBlocks() 之间维护内部状态,因此前一个块的输出可以用作下一个块加密的输入之一。因此,我们需要创建一个 AES 密码,但我们将通过 CBC 结构的 BlockMode 接口与它进行交互。

让我们从定义 AESCBCEncryptor 类型开始:

package aescbc

import (
 "crypto/aes"
 "crypto/cipher"
 "crypto/rand"
 "errors"
 "io"
)

type AESCBCEncryptor struct {
 IV             []byte
 AESKey         []byte
 inputoverflow  []byte
 outputoverflow []byte
 cipher         cipher.Block
 cbc            cipher.BlockMode
 isClosed       bool
}

该结构已导出字段以保存 AES 密钥和 IV。在内部,它管理字节片以存储客户端尚未读取的加密数据以及长度小于完整 AES 块大小的未加密数据。它还维护对 AES 密码和 CBC 块模式的引用,并跟踪客户端是否写入了最后一位数据。

我们首先构建一个函数来创建结构体的新实例并初始化其成员:

func NewAESCBCEncryptor() (*AESCBCEncryptor, error) {
 var err error
 var e AESCBCEncryptor

 //generate a random AES key
 e.AESKey = make([]byte, 32)
 _, err = rand.Read(e.AESKey)
 if err != nil {
  return nil, err
 }

 //generate a random initialization vector
 e.IV = make([]byte, 16)
 _, err = rand.Read(e.IV)
 if err != nil {
  return nil, err
 }

 //input overflow is used when Write() gives us a partial
 //AES block. The remaining bytes, to be used in the next
 //block, are stored here
 //input overflow should never exceed the AES block size
 e.inputoverflow = make([]byte, 0, 16)

 //The output overflow holds encrypted data that hasn't yet been
 //read by Read().
 e.outputoverflow = make([]byte, 0)

 //Generate an AES cipher in CBC mode
 e.cipher, err = aes.NewCipher(e.AESKey)
 if err != nil {
  return nil, err
 }

 e.cbc = cipher.NewCBCEncrypter(e.cipher, e.IV)
 e.isClosed = false
 return &e, nil
}

请注意,使用 crypto/rand 创建 AES 密钥和 IV.

为了加密数据,我们的客户会将明文写入我们的加密器。io.编写器接口是 Go 中执行此操作的标准方法,支持此接口将使我们的组件与许多现有模式兼容。该接口接受一个字节数组,并返回接受的字节数和发生的任何错误。

虽然 AES 坚持输入大小是 16 的整数倍,但我们不能在客户端上强制执行。因此,我们必须在内部截断 16 字节边界处的传入数据,并将多余的数据保存为“溢出”。当然,在执行此操作之前,我们需要将任何现有的溢出预置到传入数据中:

func (e *AESCBCEncryptor) Write(p []byte) (n int, err error) {
 //If the writer is closed, error out
 if e.isClosed {
  return 0, errors.New("writer has been closed")
 }

 //If there's any existing input overflow, that must be
 //prepended to the incoming data
 if len(e.inputoverflow) > 0 {
  p = append(e.inputoverflow, p...)
 }

 //We can only encrypt multiples of the block size. If
 //the input is not a multiple of the block size, save the
 //extra in the inputoverflow
 if len(p)%e.cbc.BlockSize() != 0 {
  //Take advantage of integer division here to calculate sizes
  numFullBlocks := len(p) / e.cbc.BlockSize()
  sizeFullBlocks := numFullBlocks * e.cbc.BlockSize()
  extraBytes := len(p) - sizeFullBlocks

  e.inputoverflow = make([]byte, extraBytes)
  e.inputoverflow = p[len(p)-extraBytes:]
  p = p[:sizeFullBlocks]
 } else {
  if len(e.inputoverflow) > 0 {
   e.inputoverflow = make([]byte, 0)
  }
 }

 //Encrypt the plaintext to a ciphertext slice
 cipherText := make([]byte, len(p))
 e.cbc.CryptBlocks(cipherText, p)

 //append the new ciphertext to the output waiting to be read
 e.outputoverflow = append(e.outputoverflow, cipherText...)
 return len(p), nil
}

请注意,实际执行加密是一行代码;这里的其余代码是内务管理。outputoverflow 字节片包含供客户端读取的加密数据。

上述代码中对 isClosed 的检查表明加密器可能处于关闭状态。原因如下:回想一下,AES 只能加密 16 字节的数据块。如果输入数据不是 16 个字节的偶数个字节(这很有可能),那么如何加密最后一个明文块?

答案是,最后一个块必须填充其他数据才能到达 16 字节的边界。PKCS7 填充是一种标准方法:如果需要一个额外的字节,则将单个0x01字节追加到部分块。如果需要两个字节,则附加0x02 0x02,依此类推。这样,可以很容易地确定需要从解密的明文中剥离的填充字节数。出于同样的原因,如果明文长度恰好是 16 个字节的偶数倍,则会添加额外的完整填充块。

因此,当客户端完成向加密器写入纯文本时,应调用 Close() 方法来添加所需的填充:

func (e *AESCBCEncryptor) Close() {
 //We need to pad the last block and encrypt it
 p := Pkcs7Pad(e.inputoverflow, e.cbc.BlockSize())
 e.outputoverflow = make([]byte, len(p))
 e.cbc.CryptBlocks(e.outputoverflow, p)
 e.isClosed = true
}

Pkcs7Pad 函数不是标准 Go 库的一部分;我在 https://wgallagher86.medium.com/pkcs-7-padding-in-go-6da5d1d14590 找到了 William Gallagher 的实现,我已经成功地将其与我的代码集成在一起。有关更多详细信息,请参阅他的文章。

在任何情况下,调用 Close() 都会向最后一个明文块添加填充,加密最后一个块,并设置 isClosed 标志,以便将来对 Write() 的任何调用都会引发错误。

这就是您在 CBC 模式下使用 AES 将明文转换为密文的方式。

获取加密数据

如果 Write() 方法允许客户端将明文写入加密器,则只有使用 Read() 才能将密文输出。read 方法很简单,几乎不需要注释:

func (e *AESCBCEncryptor) Read(p []byte) (n int, err error) {
 if e.isClosed && len(e.outputoverflow) == 0 {
  return 0, io.EOF
 }

 if len(p) >= len(e.outputoverflow) {
  //We can send all of our data to the caller
  n = copy(p, e.outputoverflow)
  e.outputoverflow = make([]byte, 0)
  return n, nil
 } else {
  //We can only return some of our waiting data
  n = copy(p, e.outputoverflow)
  e.outputoverflow = e.outputoverflow[n:]
  return n, nil
 }
}

这里唯一的特殊之处在于,如果调用方的读取缓冲区不够大,无法容纳所有密文,我们将返回尽可能多的数据,并通过调整 outputoverflow 字节片的大小来保留其余数据。

将密文解密为明文

AESCBCDecryptor 实际上与加密器完全相同。在创建结构时必须提供这些密钥和 IV,而不是生成密钥和 IV。解密器必须从 Read() 中“保留”至少 16 个字节的数据,直到调用 Close(),以便它可以在将 PKCS7 填充传递到客户端之前从明文中剥离它。除此之外,它是加密器的虚拟复制和粘贴:

package encryptionhelpers

import (
 "crypto/aes"
 "crypto/cipher"
 "errors"
 "io"
)

type AESCBCDecryptor struct {
 iv             []byte
 aesKey         []byte
 inputoverflow  []byte
 outputoverflow []byte
 cipher         cipher.Block
 cbc            cipher.BlockMode
}

func NewAESCBCDecryptor(aesKey []byte, iv []byte) (*AESCBCDecryptor, error) {
 var err error
 var e AESCBCDecryptor

 if len(aesKey) != 32 {
  return nil, errors.New("aes key must be 32 bytes long")
 }
 e.aesKey = aesKey

 if len(iv) != 16 {
  return nil, errors.New("IV must be 16 bytes long")
 }
 e.iv = iv

 //input overflow is used when Write() gives us a partial
 //AES block. The remaining bytes, to be used in the next
 //block, are stored here
 //input overflow should never exceed the AES block size
 e.inputoverflow = make([]byte, 0)

 //Generate an AES cipher in CBC mode
 e.cipher, err = aes.NewCipher(e.aesKey)
 if err != nil {
  return nil, err
 }

 //Contrary to the documentation, the IV for decryption does not need
 //to match the IV for encryption.
 e.cbc = cipher.NewCBCDecrypter(e.cipher, e.iv)

 e.outputoverflow = make([]byte, 0)

 return &e, nil
}

func (e *AESCBCDecryptor) BytesAvailable() int {
 return len(e.outputoverflow)
}

func (e *AESCBCDecryptor) Write(p []byte) (n int, err error) {
 //If there's any existing input overflow, that must be
 //prepended to the incoming data
 if len(e.inputoverflow) > 0 {
  p = append(e.inputoverflow, p...)
 }

 //We can only encrypt multiples of the block size. If
 //the input is not a multiple of the block size, save the
 //extra in the inputoverflow
 if len(p)%e.cbc.BlockSize() != 0 {
  //Take advantage of integer division here to calculate sizes
  numFullBlocks := len(p) / e.cbc.BlockSize()
  sizeFullBlocks := numFullBlocks * e.cbc.BlockSize()
  extraBytes := len(p) - sizeFullBlocks

  e.inputoverflow = make([]byte, extraBytes)
  e.inputoverflow = p[len(p)-extraBytes:]
  p = p[:sizeFullBlocks]
 } else {
  if len(e.inputoverflow) > 0 {
   e.inputoverflow = make([]byte, 0)
  }
 }

 plaintext := make([]byte, len(p))
 e.cbc.CryptBlocks(plaintext, p)
 if err != nil {
  return 0, err
 }

 e.outputoverflow = append(e.outputoverflow, plaintext...)
 return len(p), nil
}

func (e *AESCBCDecryptor) Read(p []byte) (n int, err error) {
 if len(e.outputoverflow) == 0 {
  return 0, io.EOF
 }

 if len(p) >= len(e.outputoverflow) {
  //We can send all of our data to the caller
  n = copy(p, e.outputoverflow)
  e.outputoverflow = make([]byte, 0)
  return n, nil
 } else {
  //We can only return some of our waiting data
  n = copy(p, e.outputoverflow)
  e.outputoverflow = e.outputoverflow[n:]
  return n, nil
 }
}

一个简单的例子

Go Playground 上提供了加密和解密数据的简单示例,网址为 https://go.dev/play/p/Pr_Xdn8bmei:

//A working example of aecsbc

package main

import (
 "fmt"

 "github.com/MatthewMucker/aescbc"
)

func main() {
 plaintext := "This is a string of plaintext. It is longer than a single AES block and can be of any arbitrary length."
 fmt.Printf("The original plaintext is: %s\n", plaintext)

 //Create an encryptor
 encryptor, _ := aescbc.NewAESCBCEncryptor()

 fmt.Printf("The AES encryption key is: %x\n", encryptor.AESKey)
 fmt.Printf("The initializion vector used is: %x\n", encryptor.IV)

 //Write the plaintext to the encryptor
 encryptor.Write([]byte(plaintext))
 encryptor.Close()

 //Read the ciphertext from the encryptor, allocating enough space for padding in the last AES block
 ciphertext := make([]byte, len(plaintext)+16)
 n, _ := encryptor.Read(ciphertext)

 fmt.Printf("The ciphertext is: %x\n", ciphertext[:n])

 //Now let's decrypt it!
 decryptor, _ := aescbc.NewAESCBCDecryptor(encryptor.AESKey, encryptor.IV)

 decryptor.Write(ciphertext[:n])
 decryptor.Close()
 recoveredPlaintext := make([]byte, len(plaintext))
 decryptor.Read(recoveredPlaintext)

 fmt.Printf("The recovered plaintext is: %s\n", string(recoveredPlaintext))

}

添加 Copy() 方法

虽然上面的代码适合作为几乎任何用例的指南,但这些类型的典型用例是加密和解密文件。由于 Go 为我们提供了 io.读者,io。Writer 和 Copy() 模式,我们可以使用它们来封装此功能。

Copy() 函数中有一些额外的内务处理;该函数本质上是两个管道:一个从源文件到加密器,第二个从加密器到目标文件,在我们从源文件获取 EOF 并在加密器上调用 Close() 之前,我们无法读取密文的最后一段。我不确定它是否是最优雅的实现,但相当多的测试使我确信这是可行的:

func (e *AESCBCEncryptor) Copy(dst io.Writer, src io.Reader) (written int64, err error) {
 shouldClose := false
 written = int64(0)
 inputBuf := make([]byte, e.CopyBufferSize)

 //Write the IV to the output stream
 dst.Write(e.IV)
 written += int64(len(e.IV))

 //Read until we get an EOF
 for {
  //Read from the source file
  n, err := src.Read(inputBuf)
  if err == io.EOF {
   shouldClose = true
  } else {
   if err != nil {
    return written, err
   }
  }

  //Write plaintext to the encryptor
  e.Write(inputBuf[:n])

  //Read ciphertext from the encryptor
  n, _ = e.Read(inputBuf)

  //Write ciphertext to the destination file
  n, err = dst.Write(inputBuf[:n])
  written += int64(n)
  if err != nil {
   return written, err
  }

  if shouldClose {
   e.Close()
   //Read any remaining ciphertext
   for {
    n, _ = e.Read(inputBuf)
    if n == 0 {
     break
    }
    n, err = dst.Write(inputBuf[:n])
    if err != nil {
     return written, err
    }
    written += int64(n)
   }
   break
  }
 }
 return written, nil
}

Code Availability and Quality
代码可用性和质量

完整的代码可在我的 github 上找到,网址为 https://github.com/MatthewMucker/aescbc,可以通过以下方式导入到您的代码中:

import "github.com/MatthewMucker/aescbc"

我对 Go 编程相当陌生,并且不相信我已经学会了该语言的细微差别和最佳实践。上面的代码比我的 github 存储库中更完整的代码略有简化。存储库中的代码会做额外的工作来最大限度地减少分配,这是朝着更高的代码质量迈出的一步,但我欢迎任何能够提高代码质量的人提出拉取请求。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。