Golang基于Vault实现敏感数据加解密

Golang基于Vault实现敏感数据加解密

本文是《基于Vault的敏感信息保护》的姊妹篇,文中涉及的配置管理实现方案可以参考《浅谈Golang配置管理》这篇文章。

背景

某些应用程序会处理一些敏感的数据,比如用户的证件号码、手机号等个人隐私数据。如果将这些敏感数据以明文形式存储在数据库中,一旦发生黑客入侵事件,这些数据很容易被窃取、泄露,从而引发用户信任风险和舆情危机,导致平台用户流失,甚至需要承担法律责任。

数据加密是主要的数据安全防护技术之一,敏感数据应该加密存储在数据库中,降低泄露风险。

数据加解密方案

本文采用的是 HashiCorp 公司的 Vault 工具。Vault 通过自带的 Transit 引擎提供加解密即服务(Encryption as a Service),如下图所示,加解密过程为:

  • 加密过程:
    1. App 将需要加密的明文发给 Vault
    2. Vault 将加密后的密文返给 App
    3. App 将含有密文的数据存储到数据库中
  • 解密过程:
    1. App 从数据库中读取数据(含密文字段)
    2. App 将需要解密的密文发给 Vault
    3. Vault 将解密后的明文返给 App

780A0958-A113-474F-BAE9-56487A0C196D.png

具体实现过程

1. 准备工作

使用 Vault 提供加解密服务前,需要先启用 Transit 引擎,创建专用的加解密密钥,并赋予对应的 AppRole 加解密相关权限。

# 启用 Transit 引擎
$ vault secrets enable transit

# 创建专用的加解密密钥
$ vault write -f transit/keys/mykey



# 为 AppRole 绑定的权限策略 myapp-policy 添加加解密权限
$ vault policy write myapp-policy -<<EOF
#已有的权限,见《基于Vault的敏感信息保护》这篇文章
#新增加密权限:
path "transit/encrypt/mykey" {
   capabilities = [ "update" ]
}
#新增解密权限:
path "transit/decrypt/mykey" {
   capabilities = [ "update" ]
}
EOF

# 重新生成 AppRole 的 SecretID
$ vault write -f -field=secret_id auth/approle/role/myapp/secret-id >~/.secretid

2. 初始化Vault客户端

不同于《基于Vault的敏感信息保护》这篇文章,本文采用应用程序与 Vault 直接集成的方案,使用的是 Vault 官方提供的 Go 语言库。

在应用程序与 Vault 交互前,需要初始化 Vault 客户端:登录 Vault 获取 Token,并在 Token 过期前进行续租,当无法续租时重新登录获取新的 Token。示例代码如下:

func VaultInit() {
    // 创建 Vault Client
    config := vault.DefaultConfig()
    config.Address = vaultAddress
    var err error
    VaultClient, err = vault.NewClient(config)
    if err != nil {

        log.Fatalf("Failed to create vault client, err: %v", err)
    }




    // 循环:登录认证,并续租Token
    go func() {
        for {
            vaultLoginResp, err := login(VaultClient)
            if err != nil {
                log.Printf("Unable to authenticate to Vault: %v", err)
                time.Sleep(time.Second * 10)
                continue
            }
            tokenErr := renew(VaultClient, vaultLoginResp)
            if tokenErr != nil {
                log.Printf("Unable to start managing token lifecycle: %v", tokenErr)
                time.Sleep(time.Second * 10)
            }
        }
    }()
}

本文采用的 Vault 相关配置如下:

vault:
  address: http://x.x.x.x:8200
  transit:
    key: mykey
  auth:
    roleid-file-path: /app/role/roleid
    secretid-file-path: /app/role/secretid

3. 登录认证

本文选择 AppRole 认证方法,登录 Vault 的示例代码如下:

func login(client *vault.Client) (*vault.Secret, error) {
    // 读取 RoleID
    bytes, err := ioutil.ReadFile(vaultRoleIdFilePath)
    if err != nil {
        return nil, fmt.Errorf("Error reading role ID file: %w", err)
    }
    roleID := strings.TrimSpace(string(bytes))
    if len(roleID) == 0 {
        return nil, errors.New("Error: role ID file exists but read empty value")
    }

    // 指定 SecretID
    secretID := &auth.SecretID{FromFile: vaultSecretIdFilePath}


    // 初始化 AppRole 认证方法,指定身份凭据
    appRoleAuth, err := auth.NewAppRoleAuth(roleID, secretID)
    if err != nil {
        return nil, fmt.Errorf("unable to initialize AppRole auth method: %w", err)
    }

    // 通过 AppRole 认证方法登录到 Vault
    authInfo, err := client.Auth().Login(context.Background(), appRoleAuth)
    if err != nil {
        return nil, fmt.Errorf("unable to login to AppRole auth method: %w", err)
    }
    if authInfo == nil {
        return nil, fmt.Errorf("no auth info was returned after login")
    }

    log.Printf("Successfully (re)logined, lease duration: %ds", authInfo.Auth.LeaseDuration)
    return authInfo, nil
}

4. Token续租

renew函数监听Token的生命周期,在TTL到期前进行续租操作,直到无法继续续租、续租失败为止,此时需要重新登录,获取新的 Token。renew函数的示例代码如下:

func renew(client *vault.Client, token *vault.Secret) error {
    // 为 Token 创建一个监听器
    watcher, err := client.NewLifetimeWatcher(&vault.LifetimeWatcherInput{
        Secret: token,
        //Increment: 3600,
    })
    if err != nil {

        return fmt.Errorf("unable to initialize new lifetime watcher for renewing auth token: %w", err)
    }




    // 启动后台续租协程
    go watcher.Start()
    defer watcher.Stop()


    for {
        select {
        // 续租失败,或者无法继续续租
        case err := <-watcher.DoneCh():
            //续租失败
            if err != nil {
                log.Printf("Failed to renew token: %v. Re-attempting login.", err)
                return nil
            }
            // 无法继续续租
            log.Printf("Token can no longer be renewed. Re-attempting login.")
            return nil

        // 成功完成续租
        case renewal := <-watcher.RenewCh():
            log.Printf("Successfully renewed, lease duration: %ds", renewal.Secret.Auth.LeaseDuration)
        }
    }
}

5. 加密

本文以 GORM 库为例来说明。GORM 的 Hook 机制允许在数据库 CRUD 操作前后执行预定义的 Hook 方法。对于加密而言,可以为模型类定义 BeforeSave 方法,并在其中完成敏感数据的加密操作。

func (t *Teacher) BeforeSave(*gorm.DB) error {
    return t.Encrypt()
}

Teacher 模型包含证件号码IDcard和手机号Phone两个敏感数据:

// 此处仅展示 GORM 相关标签,省略其它标签
type Teacher struct {
    gorm.Model
    Name    string
    // ... 其余字段省略
    
    //密文
    IDcard string `gorm:"unique"`
    Phone  string



    //明文
    PlainIDcard string `gorm:"-"`
    PlainPhone  string `gorm:"-"`
}

加密方法Encrypt借助 Vault 对 IDcardPhone 进行加密操作,示例代码如下:

func (t *Teacher) Encrypt() error {


    path := fmt.Sprintf("/transit/encrypt/%s", config.VaultTransitKey)
    ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)

    defer cancel()




    // 批量加密
    resp, err := Vault.Logical().WriteWithContext(ctx, path, map[string]interface{}{

        "batch_input": []map[string]interface{}{

            {

                "plaintext": base64.StdEncoding.EncodeToString([]byte(t.PlainIDcard)),
            },

            {

                "plaintext": base64.StdEncoding.EncodeToString([]byte(t.PlainPhone)),
            },

        },

    })

    if err != nil {

        log.Printf("teacher.Encrypt failed to encrypt data")
        return err

    }



    // 拿到密文
    t.IDcard = resp.Data["batch_results"].([]interface{})[0].(map[string]interface{})["ciphertext"].(string)
    t.Phone = resp.Data["batch_results"].([]interface{})[1].(map[string]interface{})["ciphertext"].(string)

    log.Printf("teacher.Encrypt called")
    return nil
}

6. 解密

解密的实现与加密类似,我们可以定义解密方法Decrypt,当需要进行解密时调用该方法:

  • 如果没有使用缓存层,可以在 AfterFind 方法中调用Decrypt,在查询数据库后完成解密操作
  • 如果使用了 Redis 等缓存服务,则需要在更新缓存或命中缓存之后调用 Decrypt

Decrypt方法的示例代码如下。

func (t *Teacher) Decrypt() error {


    path := fmt.Sprintf("/transit/decrypt/%s", config.VaultTransitKey)
    ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)

    defer cancel()




    // 批量解密
    resp, err := Vault.Logical().WriteWithContext(ctx, path, map[string]interface{}{

        "batch_input": []map[string]interface{}{

            {

                "ciphertext": t.IDcard,
            },

            {

                "ciphertext": t.Phone,
            },

        },

    })

    if err != nil {

        log.Printf("teacher.Decrypt failed to decrypt data")
        return err

    }



    // 拿到 base64 文本
    IDcard_base64 := resp.Data["batch_results"].([]interface{})[0].(map[string]interface{})["plaintext"].(string)
    Phone_base64 := resp.Data["batch_results"].([]interface{})[1].(map[string]interface{})["plaintext"].(string)
    
    // 解码拿到明文
    IDcard, err1 := base64.StdEncoding.DecodeString(IDcard_base64)
    Phone, err2 := base64.StdEncoding.DecodeString(Phone_base64)
    if err1 != nil || err2 != nil {
        log.Printf("teacher.Decrypt failed to base64 decode")
        return errors.New("base64 decode error")
    }
    t.PlainIDcard = string(IDcard)
    t.PlainPhone = string(Phone)

    log.Printf("teacher.Decrypt called")
    return nil
}

总结

数据加密是主要的数据安全防护技术之一,敏感数据应该加密存储在数据库中,降低泄露风险。本文介绍了 Golang 基于 Vault 实现敏感数据加解密的方案和具体实现过程。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYXyTUC4' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片