Wynn5a 的技术博客
技术分享约 6 分钟读完

使用 Vault 保护应用的敏感数据

我们的应用程序或多或少都会涉及到敏感数据的存储和使用,比如数据库密码、服务通信密钥等。这些数据可能会以明文的方式存储在应用程序源代码和配置文件中,这样做增加了敏感数据泄漏、遗失或者错乱的风险,让外部或者内部的恶意攻击更加容易得逞,带来灾难性的后果。这些敏感数据也可能散布在不同的应用中,给统一管理带来极大的挑战,如果想修改一个密钥,可能就要重新修改、测试和部署每个应用服务,而想了解谁在使用哪些敏感数据和凭证,那简直是痴心妄想。

在云原生应用大行其道的今天,大家都在积极寻求一种可托管云上且独立于业务的敏感数据管理方案,而 Vault 就是这么一个专门解决敏感数据的存储、访问和统一管理等问题的基础设施。

本文将通过实际项目来帮大家了解并使用 Vault 来管理 Spring Boot 应用中的敏感数据

初识 Vault#

Vault 由 Hasicrop 领导开发的项目,分为开源和企业版本,基本信息如下

官方网站:www.vaultproject.io/

开源地址:https://github.com/hashicorp/vault

它致力于提供企业级敏感数据管理的全套解决方案,包括丰富的功能、易用的客户端和安全的存储方案,是该领域的不二之选,它的工作流程如下图所示

  • 客户端:Vault 提供了丰富的使用方式,有 Web UI、命令行工具 Cli、HTTP API,也有基于 API 开发的各种编程语言的 SDK,这些使用者被成为客户端
  • 身份验证:客户端提供自身的认证信息,Vault 使用这些信息来确定他们是否具有合法的访问权限,一旦通过验证,Vault 就会生成一个令牌(Token)给客户端并将其与设置好的访问策略关联起来。Vault 会将身份验证管理和决策委托给配置好的外部身份验证方法,比如 Amazon Web Services、GitHub、Google Cloud Platform、Kubernetes、Microsoft Azure、Okta 等,也支持使用内置的 Token 验证方式。不同的组织和场景,可以选用不同的验证方法。
  • 数据访问:在 Vault 中,敏感数据逻辑上是存放在不同的 Secret Engines 模型中,实际存储后端支持不同的实现方案,默认情况下使用内置的基于 Raft 协议开发的本地存储。客户端通过验证之后取得相应的令牌,则可以通过此令牌访问自己权限内的敏感数据和资源。

更多详细的资料和概念可通过官方网站了解

启用 Secret、Transit、Database 引擎#

⚠️ 本文所有的 Vault 设置请勿直接在生产环境套用,请根据实际情况设置

因为启用过程比较简单就不过多介绍了,参考官方文档即可顺利启用。

  • KV - 该引擎是一种通用的键值存储,用于将任意机密信息存储在已配置的 Vault 物理存储中,此后端可以为一个键存储单个值,也可以启用版本控制,为每个键存储可设置数量的版本值
  • Transit - 该引擎是在数据传输过程中的加密解密功能,Vault 不会存储发送到此引擎的数据,它也可以被视为“密码学即服务”或“加密即服务”,该密钥引擎还可以对数据进行签名和验证;生成数据的哈希和 HMAC;以及充当随机字节的数据源。
  • Database - 该引擎可以通过配置的角色为数据库连接动态生成凭证,支持多种数据库并提供扩展框架,这意味着需要访问数据库的服务不再需要硬编码凭证,它们可以从 Vault 请求凭证,并利用 Vault 的租赁机制更轻松地轮换密钥,保证安全。

从 Vault KV Engine 读取敏感数据#

如果我们有一些敏感数据已经存放到 Vault 中,比如一个秘密的字符串,那么可以使用如下的代码将此字符串从 KV 中读取到程序中使用

java
@RefreshScope
@RestController
@ConfigurationProperties
public class SecretController {
 
  @Value("${secret:n/a}")
  String secret;
 
  @GetMapping("secret")
  public String secret() {
    return secret;
  }
}

配置文件中需要设置读取的 KV 的应用名,也就是在 Vault 中的路径

plain
#默认情况下,Spring cloud vault 会读取路径 /secret engine/{application}/{profile 如果有的话}
#如果不设置 kv.application 则会读取 spring.application.name
#如果这个也没有,会读取默认生成的应用名称
spring.cloud.vault.kv.application-name=super-secret
spring.cloud.vault.kv.backend=kv
spring.cloud.vault.kv.enabled=true #默认值可不设置

对应的我们需要在 Vault 中添加一个这样配置的 Key 并设置好我们的秘密字符串,如图

访问我们的应用,这可以得到 Vault 中存放的秘密

bash
$ curl http://127.0.0.1:8080/secret
KEEP THIS IN VAULT

我们将 Vault 中的数据更新一下,并通知 Spring boot,然后就能得到新的值

bash
#通知 Spring 刷新
#需要依赖 org.springframework.boot:spring-boot-starter-actuator
#并在配置文件中暴漏 refresh 端口 management.endpoints.web.exposure.include=refresh
$ curl --request POST 'http://127.0.0.1:8080/actuator/refresh'
#获取最新值
$ curl http://127.0.0.1:8080/secret
Now you see the new value

使用 Vault Transit 引擎作为加密服务#

假设在应用中,我们的 User 实体中有一个字段 password 需要加密存储到数据库中

java
@Data
@Entity
public class User {
 
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
 
  private String name;
 
  @Convert(converter = PasswordConverter.class)
  private String password;
 
  private String idCode;
}

那么我们可以在 Converter 中使用 Vault 作为我们的加密服务

java
@Service
public class PasswordConverter implements AttributeConverter<String, String> {
 
  public static final String KEY_NAME = "password";
  private final VaultOperations vaultOperations;
 
  public PasswordConverter(VaultOperations vaultOperations) {
    this.vaultOperations = vaultOperations;
  }
 
  @Override
  public String convertToDatabaseColumn(String password) {
    Plaintext plaintext = Plaintext.of(password);
    return vaultOperations.opsForTransit().encrypt(KEY_NAME, plaintext).getCiphertext();
  }
 
  @Override
  public String convertToEntityAttribute(String password) {
    return vaultOperations.opsForTransit().decrypt(KEY_NAME, password);
  }
}

此时需要设置一下 Vault 的 Transit 引擎,添加我们需要的路径

使用 Vault Database 引擎来管理我们的数据库凭证并定时轮换#

现在公司要求数据库用户名和密码不能存放在配置文件中,那么我们就需要修改我们的配置文件

java
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/vault-your-data?createDatabaseIfNotExist=true
#不能再直接配置了
#spring.datasource.username=vault-data
#spring.datasource.password=123456
#需要从 Vault 中获取凭证,默认情况下会直接设置为上面注释的两个属性 spring.datasource.username & spring.datasource.password
spring.config.import=vault://
spring.cloud.vault.database.enabled=true
spring.cloud.vault.database.role=vault-root
spring.cloud.vault.database.backend=database
spring.cloud.vault.database.static-role=false

对应的,我们需要设置 Vault 的 Database engine 来满足我们的需求

创建一个数据库连接

⚠️ 我们另外创建一个 Root 权限的账号来专门给 Vault 使用

创建 Role,就是客户端可以用的凭证,需要是动态的,具体 Dynamic 和 Static 的差别,请参考官方文档

设置完成之后,我们应用启动的时候就会从 Vault 获取到数据库的凭证并顺利初始化数据库连接

那么数据库密钥轮换之后,如何才能及时拿到新的凭证,并且保证应用不会下线呢

下面的代码是一种实现思路

java
@Configuration
@ConditionalOnBean(SecretLeaseContainer.class)
public class DatabaseLeaseEventHandler {
 
  private final Logger log = LoggerFactory.getLogger(DatabaseLeaseEventHandler.class);
 
  private final ConfigurableApplicationContext applicationContext;
  private final HikariDataSource hikariDataSource;
  private final SecretLeaseContainer secretLeaseContainer;
 
  @Value("${spring.cloud.vault.database.role}")
  private String datasourceRole;
 
  public DatabaseLeaseEventHandler(ConfigurableApplicationContext applicationContext, HikariDataSource hikariDataSource,
      SecretLeaseContainer secretLeaseContainer) {
    this.applicationContext = applicationContext;
    this.hikariDataSource = hikariDataSource;
    this.secretLeaseContainer = secretLeaseContainer;
  }
 
 
  @PostConstruct
  public void afterInit() {
    var path = "database/creds/%s".formatted(datasourceRole);
    secretLeaseContainer.addLeaseListener(leaseEvent -> {
      RequestedSecret source = leaseEvent.getSource();
      if (path.equals(source.getPath())) {
        Mode mode = source.getMode();
        if (leaseEvent instanceof SecretLeaseExpiredEvent && Mode.RENEW.equals(mode)) {
          log.info("Database lease is expired, request new database credentials");
          secretLeaseContainer.requestRotatingSecret(path);
        } else if (leaseEvent instanceof SecretLeaseCreatedEvent secretLeaseCreatedEvent && Mode.ROTATE.equals(mode)) {
          log.info("Database lease is created, update to new database credentials");
          Map<String, Object> secrets = secretLeaseCreatedEvent.getSecrets();
          var username = secrets.get("username");
          var password = secrets.get("password");
          Credential credential = new Credential(username, password);
          if (!credential.valid()) {
            log.error("Cannot get updated DB credentials. Shutting down.");
            applicationContext.close();
            return;
          }
 
          refreshDatabaseCredentials(credential);
        }
      }
    });
  }
 
  private void refreshDatabaseCredentials(Credential credential) {
    updateProperties(credential.stringify());
    updateDataSource(credential.stringify());
  }
 
  private void updateProperties(StringCredential credential) {
    System.setProperty("spring.datasource.username", credential.username());
    System.setProperty("spring.datasource.password", credential.password());
  }
 
  private void updateDataSource(StringCredential credential) {
    hikariDataSource.getHikariConfigMXBean().setUsername(credential.username());
    hikariDataSource.getHikariConfigMXBean().setPassword(credential.password());
    hikariDataSource.getHikariPoolMXBean().softEvictConnections();
    log.info("Database credentials are updated");
  }
}
 
record Credential(Object username, Object password) {
 
  public boolean valid() {
    return username != null && password != null;
  }
 
 
  public StringCredential stringify() {
    return new StringCredential(username.toString(), password.toString());
  }
}
 
record StringCredential(String username, String password) {}

至此,数据库凭证管理就托管给 Vault 了

总结#

Vault 还有很多适用于不同场景的模型文中没有介绍,大家可以参考官方文档进一步了解选用。

试想如果将 Vault 和基础设施流水线结合起来,在不同的环境或者阶段,给应用提供不同的凭证和敏感数据,那么我们整个应用就是脱敏的,可以无压力分发,还具有足够的灵活性以在不同阶段具有不同的表现,保持健壮。

本文所涉及到的代码及配置已经提交至 Github,供参考

GitHub - wynn5a/vault-your-data: Use Vault in your spring-boot application to manage your secret Use Vault in your spring-boot application to manage your secret - wynn5a/vault-your-data https://github.com/wynn5a/vault-your-data/tree/main

读完了,谢谢你读到这里

有新文章时想第一时间看到?订阅 RSS