加密即服务

我们有时会编写一些与敏感信息(PII,Personally Identifiable Information)打交道的服务,例如存储用户的姓名、联系方式、住址等隐私信息。为防止由于意外数据库泄漏,或是减少内鬼违规查阅、下载数据的可能性,将 PII 数据加密后存储到数据库是一个比较好的实践。

一般我们会使用对称加密算法来加密该类数据,一个对称加密算法的关键是加密密钥。拥有加密密钥的人可以解密对应的密文数据。如果我们在应用程序代码中调用加密库加解密信息,那么不可避免地就要与密钥打交道,密钥的管理、访问密钥的权限、密钥的轮替和紧急状态下吊销密钥这些工作就都落在应用程序开发者身上了。所以 Vault 提供了名为 transit 的机密引擎,对外提供了“加密即服务”功能(Encryption as a Service)。

调用Vault加解密数据流程
图 1.10.1/1 - 调用Vault加解密数据流程

之所以叫 transit 引擎,是因为 Vault 只保存密钥,不保存明文与密文数据,这与 Vault 其他的机密引擎是不同的。

简单把玩加解密

让我们来把玩一下该功能。首先启动 Vault 测试服务:

$ vault server -dev
==> Vault server configuration:

             Api Address: http://127.0.0.1:8200
                     Cgo: disabled
         Cluster Address: https://127.0.0.1:8201
              Go Version: go1.17.5
              Listener 1: tcp (addr: "127.0.0.1:8200", cluster address: "127.0.0.1:8201", max_request_duration: "1m30s", max_request_size: "33554432", tls: "disabled")
               Log Level: info
                   Mlock: supported: false, enabled: false
           Recovery Mode: false
                 Storage: inmem
                 Version: Vault v1.9.2
             Version Sha: f4c6d873e2767c0d6853b5d9ffc77b0d297bfbdf+CHANGES

==> Vault server started! Log data will stream in below:
...
WARNING! dev mode is enabled! In this mode, Vault runs entirely in-memory
and starts unsealed with a single unseal key. The root token is already
authenticated to the CLI, so you can immediately begin using Vault.

You may need to set the following environment variable:

    $ export VAULT_ADDR='http://127.0.0.1:8200'

The unseal key and root token are displayed below in case you want to
seal/unseal the Vault or re-authenticate.

Unseal Key: 9ZWOerPfTUx+8O22+nxANhpJMDYSSD+piObTgCZGkQQ=
Root Token: s.XaRNCe4e3g5UA5z7g1gRDUaL

Development mode should NOT be used in production installations!

然后用得到的 Root 令牌登录:

$ export VAULT_ADDR='http://127.0.0.1:8200'
$ vault login s.XaRNCe4e3g5UA5z7g1gRDUaL
Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.

Key                  Value
---                  -----
token                s.XaRNCe4e3g5UA5z7g1gRDUaL
token_accessor       kdF6LVdST93xnFDeQ3M5sN46
token_duration       ∞
token_renewable      false
token_policies       ["root"]
identity_policies    []
policies             ["root"]

然后我们启用 transit 引擎:

$ vault secrets enable transit
Success! Enabled the transit secrets engine at: transit/

Vault 模拟了一个文件系统,各个机密引擎的启用是被挂载到相应文件路径下,默认挂载路径是引擎名称,所以我们启用的 transit 引擎被挂载到了默认的 transit/ 路径下。

下面,我们要创建一个密钥环,才能开始使用加密服务:

$ vault write -f transit/keys/orders
Success! Data written to: transit/keys/orders

我们成功地在 transit/keys/orders 这个路径下创建了一个密钥环,后续的加解密我们会通过这个路径来执行。

用Root用户创建一个App用户,并为其配置使用密钥加解密的权限
图 1.10.1/2 - 用Root用户创建一个App用户,并为其配置使用密钥加解密的权限

到目前为止我们都是使用 Root 用户进行的操作,下面我们要模拟一个普通的应用程序如何访问 Vault 来执行加解密操作。首先让我们为应用程序配置访问密钥环的权限:

$ vault policy write app-orders -<<EOF
path "transit/encrypt/orders" {
   capabilities = [ "update" ]
}
path "transit/decrypt/orders" {
   capabilities = [ "update" ]
}
EOF
Success! Uploaded policy: app-orders

我们新增了一个名为 app-oerders 的 Policy,赋予了对 transit 引擎下名为 orders 的密钥环加密与解密的操作权限。然后我们为应用程序创建一个 Vault Token:

$ vault token create -policy=app-orders
Key                  Value
---                  -----
token                s.Nn1C0fK0XTF8TCQlfh1LFNDJ
token_accessor       SDgg8iUVAoZVNZ1aQ5mtLlrN
token_duration       768h
token_renewable      true
token_policies       ["app-orders" "default"]
identity_policies    []
policies             ["app-orders" "default"]

可以看到,这个新 Token 拥有两个 Policy:app-ordersdefaultdefault 是默认所有用户都拥有的策略。让我们使用新 Token 登录:

$ vault login s.Nn1C0fK0XTF8TCQlfh1LFNDJ
Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.

Key                  Value
---                  -----
token                s.Nn1C0fK0XTF8TCQlfh1LFNDJ
token_accessor       SDgg8iUVAoZVNZ1aQ5mtLlrN
token_duration       767h59m27s
token_renewable      true
token_policies       ["app-orders" "default"]
identity_policies    []
policies             ["app-orders" "default"]

然后尝试加密一段数据:

$ vault write transit/encrypt/orders \
>     plaintext=$(base64 <<< "4111 1111 1111 1111")
Key            Value
---            -----
ciphertext     vault:v1:lnvn6fiOjBgpTUlYw1Oqx2uBT8dq2LfrAn2r/fFf+W8Hp12b5WFj/EDRstRBX5LO
key_version    1

可以看到,给出明文 4111 1111 1111 1111,我们得到的了密文 vault:v1:lnvn6fiOjBgpTUlYw1Oqx2uBT8dq2LfrAn2r/fFf+W8Hp12b5WFj/EDRstRBX5LO。在这里我们还可以看到,输出结果中 key_version1,并且密文头部也有 vault:v1: 的前缀。这是因为 transit 引擎支持多版本密钥管理以及密钥轮替操作。

让我们试试解密数据:

$ vault write transit/decrypt/orders \
  ciphertext="vault:v1:lnvn6fiOjBgpTUlYw1Oqx2uBT8dq2LfrAn2r/fFf+W8Hp12b5WFj/EDRstRBX5LO"
Key          Value
---          -----
plaintext    NDExMSAxMTExIDExMTEgMTExMQo=

$ base64 --decode <<< "NDExMSAxMTExIDExMTEgMTExMQo="
4111 1111 1111 1111

可以看到,解密后得到的结果,就是明文的 base64 编码形式。

密钥版本

transit 允许同时保存一个密钥环的多个版本的密钥。我们使用 Root 令牌登录,再查看一下当前密钥环的信息:

$ vault login s.XaRNCe4e3g5UA5z7g1gRDUaL
Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.

Key                  Value
---                  -----
token                s.XaRNCe4e3g5UA5z7g1gRDUaL
token_accessor       kdF6LVdST93xnFDeQ3M5sN46
token_duration       ∞
token_renewable      false
token_policies       ["root"]
identity_policies    []
policies             ["root"]

$ vault read transit/keys/orders
Key                       Value
---                       -----
allow_plaintext_backup    false
deletion_allowed          false
derived                   false
exportable                false
keys                      map[1:1641036346]
latest_version            1
min_available_version     0
min_decryption_version    1
min_encryption_version    0
name                      orders
supports_decryption       true
supports_derivation       true
supports_encryption       true
supports_signing          false
type                      aes256-gcm96

可以看到,transit/keys/orders 密钥环采用的是 aes256-gcm96 算法,当前最新密钥版本是 1,保存的最久远的密钥版本 min_decryption_version 也是 1

轮替密钥

让我们执行一次密钥轮替:

$ vault write -f transit/keys/orders/rotate
Success! Data written to: transit/keys/orders/rotate

这时再查看密钥环信息:

$ vault read transit/keys/orders
Key                       Value
---                       -----
allow_plaintext_backup    false
deletion_allowed          false
derived                   false
exportable                false
keys                      map[1:1641036346 2:1641036686]
latest_version            2
min_available_version     0
min_decryption_version    1
min_encryption_version    0
name                      orders
supports_decryption       true
supports_derivation       true
supports_encryption       true
supports_signing          false
type                      aes256-gcm96

可以看到,最新密钥版本变成了 2,而 min_decryption_version 还是 1,这代表轮替后将默认使用版本号为 2 的密钥加密数据,但使用版本号为 1 的密钥加密的数据仍然可以正常解密。让我们验证一下现在我们是否还可以解密刚才的密文:

$ vault write -format=json transit/decrypt/orders ciphertext="vault:v1:lnvn6fiOjBgpTUlYw1Oqx2uBT8dq2LfrAn2r/fFf+W8Hp12b5WFj/EDRstRBX5LO" | jq -r .data.plaintext | base64 -d
4111 1111 1111 1111

使用版本 1 的密钥加密的密文目前仍然可以解密。让我们再次加密同一段明文试试看:

$ vault write transit/encrypt/orders plaintext=$(base64 <<< "4111 1111 1111 1111")
Key            Value
---            -----
ciphertext     vault:v2:pLwQJs39PWFGHvnvX/5qrBoHBi7Ly5l4bC6ouYX9SYWgOMLlOxm+HTGhTcvpW3o8
key_version    2

可以看到,同样的明文,现在得到的密文发生了变化:

vault:v1:lnvn6fiOjBgpTUlYw1Oqx2uBT8dq2LfrAn2r/fFf+W8Hp12b5WFj/EDRstRBX5LO

vault:v2:pLwQJs39PWFGHvnvX/5qrBoHBi7Ly5l4bC6ouYX9SYWgOMLlOxm+HTGhTcvpW3o8

新的密文是使用版本 2 的密钥加密的。目前这两个版本的密文都可以被正常解密:

$ vault write -format=json transit/decrypt/orders ciphertext="vault:v2:pLwQJs39PWFGHvnvX/5qrBoHBi7Ly5l4bC6ouYX9SYWgOMLlOxm+HTGhTcvpW3o8" | jq -r .data.plaintext | base64 -d
4111 1111 1111 1111

更新密文版本

如果我们数据库中目前存储的是 v1 版本的密文,在轮替密钥后,我们希望把旧版本密文全部更新成新版本,可以使用 Vault 的 rewrap 来完成:

$ vault write transit/rewrap/orders ciphertext="vault:v1:lnvn6fiOjBgpTUlYw1Oqx2uBT8dq2LfrAn2r/fFf+W8Hp12b5WFj/EDRstRBX5LO"
Key            Value
---            -----
ciphertext     vault:v2:o8XaRyDyyj2+ai46DVW2SYssGBMdvYxPPxrC7y+UaEWA1zAf+iaIfWaIEqtU3hL5
key_version    2

通过 rewrap,我们在不知晓明文的前提下,将密文的密钥版本从 v1 更新到了 v2

假设我们在数据库中存放了大量密文数据,一种比较好的实践是定期用轮替生成新的密钥,并且编写定时任务,定时将密文全部更新到最新密钥版本,这样即使密文意外泄漏,存放在 Vault 中的密钥可能也已经被抛弃了。

抛弃旧密钥

在生产环境中我们定期轮替生成新密钥,但老密钥还是被保存在 Vault 中,长此以往会在 Vault 中留下大量的无用密钥(旧密文已被定期更新到新密钥版本)。我们可以设置密钥环的最低解密密钥版本:

$ vault write transit/keys/orders/config min_decryption_version=2
Success! Data written to: transit/keys/orders/config

$ vault read transit/keys/orders
Key                       Value
---                       -----
allow_plaintext_backup    false
deletion_allowed          false
derived                   false
exportable                false
keys                      map[2:1641036686]
latest_version            2
min_available_version     0
min_decryption_version    2
min_encryption_version    0
name                      orders
supports_decryption       true
supports_derivation       true
supports_encryption       true
supports_signing          false
type                      aes256-gcm96

如此,当前的 min_decryption_version 就被设置为 2。我们现在试试解密 v1 版本的密文:

$ vault write -format=json transit/decrypt/orders ciphertext="vault:v1:lnvn6fiOjBgpTUlYw1Oqx2uBT8dq2LfrAn2r/fFf+W8Hp12b5WFj/EDRstRBX5LO"
Error writing data to transit/decrypt/orders: Error making API request.

URL: PUT http://127.0.0.1:8200/v1/transit/decrypt/orders
Code: 400. Errors:

* ciphertext or signature version is disallowed by policy (too old)

Vault 拒绝解密 v1 版本的密文,原因是 too oldv1 版本的密钥已经被 Vault 删除。需要注意的是,抛弃密钥前必须确保所有旧版本密文都已被更新到新密钥版本,否则抛弃旧密钥等同于删除旧版本密文

生成加密密钥

在刚才的例子中,加解密操作以及密钥都位于 Vault 内部,这在处理大部分加解密场景时都已经足够;但是如果我们要加解密一个体积很大的数据,例如用户上传的视频,那么把整个视频数据传输到 Vault 服务内加密就显得不合时宜了。

我们可以用 Vault 生成一段随机的加密密钥,包含明文与密文;使用明文加密数据后,抛弃明文密钥,将密文密钥与密文数据存放在一起;解密时,先用 Vault 解密密文密钥得到明文密钥,就可以解密密文数据了。

$ vault write -f transit/datakey/plaintext/orders
Key            Value
---            -----
ciphertext     vault:v2:BD4fCUsL0xXfwWDbf+6+gwIGu3zdaFRpx0t1WQVrayiWqnSNHSCFNszKM3wxyec2e6wY2n+ABo+c2cYg
key_version    2
plaintext      vZZYeErrdmsWOl/3864lRVOTl40aQa5QRcC8o6Wgisc=

通过写入 transit/datakey/plaintext/orders 路径,我们得到了一个随机密钥,明文是 vZZYeErrdmsWOl/3864lRVOTl40aQa5QRcC8o6Wgisc=,密文是 vault:v2:BD4fCUsL0xXfwWDbf+6+gwIGu3zdaFRpx0t1WQVrayiWqnSNHSCFNszKM3wxyec2e6wY2n+ABo+c2cYg

假设我们用 vZZYeErrdmsWOl/3864lRVOTl40aQa5QRcC8o6Wgisc= 这个密钥加密了一段视频数据,并且把 vault:v2:BD4fCUsL0xXfwWDbf+6+gwIGu3zdaFRpx0t1WQVrayiWqnSNHSCFNszKM3wxyec2e6wY2n+ABo+c2cYg 和密文视频数据存放在一起;需要解密时,我们先解密密文密钥:

$ vault write -format=json transit/decrypt/orders ciphertext="vault:v2:BD4fCUsL0xXfwWDbf+6+gwIGu3zdaFRpx0t1WQVrayiWqnSNHSCFNszKM3wxyec2e6wY2n+ABo+c2cYg" | jq -r .data.plaintext
vZZYeErrdmsWOl/3864lRVOTl40aQa5QRcC8o6Wgisc=

成功地从 Vault 得到了密钥的明文以后,我们就可以进一步解密视频数据了。同样的,该密钥也收到 transit 引擎密钥环的版本管理,我们也可以定期轮替密钥、更新与视频密文一同存储的密文密钥。

results matching ""

    No results matching ""