在 DynamoDB 中使用 KMS 存储敏感信息

发布: (2025年12月24日 GMT+8 21:10)
11 min read
原文: Dev.to

Source: Dev.to

请提供您希望翻译的文章正文(除代码块和 URL 之外的文字),我将把它翻译成简体中文并保持原有的 Markdown 格式。

介绍

最近,我遇到了 AWS EventBridge Connections 的问题。它是一个托管的 AWS 服务,帮你处理机密——你为 API 配置身份验证(无论是为自己还是为你的客户,例如 webhook),当它与 EventBridge API 目标或 Step Functions HTTP 调用任务关联时,EventBridge Connections 会处理其余工作。

这两项服务乍看之下都很不错,但在超出简单用例后会暴露出局限性。对我而言,缺乏自定义和控制成为了阻碍。这促使我去研究替代方案:我可以在哪里安全地存储客户提供的机密或敏感数据?

显而易见的选择:AWS Secrets Manager

对于大多数人来说,首先想到的解决方案是 AWS Secrets Manager。Secrets Manager 是一项专为存储和轮换机密(如数据库凭证、API 密钥和 OAuth 令牌)而设计的全托管服务。

什么是 Secrets Manager?

AWS Secrets Manager 帮助您在无需前期投入和持续维护成本的情况下,保护对应用程序、服务和 IT 资源的访问。它使您能够在整个生命周期中轮换、管理和检索数据库凭证、API 密钥以及其他机密。

主要特性包括

  • 自动机密轮换
  • 通过 IAM 实现细粒度访问控制
  • 通过 CloudTrail 日志进行审计和合规
  • 与 RDS、DocumentDB 以及其他 AWS 服务集成

不足之处

虽然 Secrets Manager 功能强大,但并非总是必需的:

问题细节
成本每个机密每月 $0.40,外加每 10,000 次 API 调用 $0.05。对于管理大量客户机密的应用程序,这笔费用会迅速累积。
对简单用例来说功能过剩如果您不需要自动轮换或高级功能,就在为不使用的功能付费。
复杂性对于直接的加密需求,该服务会增加不必要的开销。

这时 AWS Key Management Service (KMS) 就成为一个有吸引力的替代方案。

更合适的选择:AWS KMS

什么是 KMS?

AWS Key Management Service(KMS)是一项托管服务,可轻松创建和控制用于加密数据的密码密钥。与 Secrets Manager 不同,KMS 不存储你的机密——它只存储用于自行加密和解密数据的加密密钥。

类比

  • Secrets Manager:存放机密的安全金库。
  • KMS:持有你用于锁定/解锁自己金库的钥匙的保管人。

为什么在 DynamoDB 中使用 KMS?

DynamoDB 加密 vs. 应用层加密

  • DynamoDB 已经默认使用 AWS 托管的 KMS 密钥对所有静态数据进行加密。这可以防止对物理磁盘的访问以及 AWS 基础设施层面的威胁。
  • 但是,仅靠服务器端加密(SSE)在处理 客户提供的机密 时往往不足。

应用层加密(在将数据写入 DynamoDB 之前先进行加密)提供了额外的保障:

  • 防止 IAM 策略过于宽松导致的风险
  • 在意外数据访问时限制暴露范围
  • 保证导出、备份和日志中的数据仍保持加密状态
  • 在应用边界实现细粒度的访问控制

在 DynamoDB 中存储敏感数据时,你主要有两种做法:

  1. 在 DynamoDB 中存储引用 – 将机密加密后存入 AWS Secrets Manager(或 SSM Parameter Store),然后在 DynamoDB 中仅保存该引用。
  2. 直接在 DynamoDB 中存储加密数据 – 使用 KMS 对敏感数据进行加密,并将加密后的值直接写入 DynamoDB 表中。

第二种方法对许多使用场景来说更简单且成本更低。下面我们来看看如何实现它。

使用 AWS CDK 设置 KMS

下面是一个最小化的 CDK 堆栈示例,它会创建一个 KMS 密钥和一个 DynamoDB 表,然后授予 Lambda 函数加密/解密数据的权限。

import * as kms from 'aws-cdk-lib/aws-kms';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class SecureStorageStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // Create a KMS key for encrypting sensitive data
    const encryptionKey = new kms.Key(this, 'SensitiveDataKey', {
      description: 'Key for encrypting customer secrets in DynamoDB',
      enableKeyRotation: true, // Automatically rotate key every year
      removalPolicy: RemovalPolicy.RETAIN, // Keep key even if stack is deleted
    });

    // Create DynamoDB table
    const secretsTable = new dynamodb.Table(this, 'SecretsTable', {
      partitionKey: { name: 'customerId', type: dynamodb.AttributeType.STRING },
      sortKey: { name: 'secretId', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
    });

    // Grant your Lambda function access to the key
    // (Assuming you have a Lambda function defined)
    encryptionKey.grantEncryptDecrypt(yourLambdaFunction);
    secretsTable.grantReadWriteData(yourLambdaFunction);

    // Add key ARN to Lambda environment variables
    yourLambdaFunction.addEnvironment('KMS_KEY_ID', encryptionKey.keyId);
    yourLambdaFunction.addEnvironment('SECRETS_TABLE_NAME', secretsTable.tableName);
  }
}

在 TypeScript 中加密和解密

重要限制:KMS 加密大小限制

AWS KMS Encrypt 的明文最大尺寸为 4 KB。这对于以下小型机密非常适用:

  • API 密钥
  • Webhook 密钥
  • 短期 OAuth 令牌

对以下较大的负载将 无法工作

  • PEM 证书
  • 大型 JSON 凭证
  • 多字段配置块

大负载的信封加密

(原文在此处截断;后续应继续说明信封加密的原理,包括生成数据密钥、使用数据密钥加密负载,以及将加密后的数据密钥与密文一起存储。)

Source:

机密

对于大于 4 KB 的机密,您应该使用 信封加密

  • 使用 KMS 生成数据加密密钥(DEK)
  • 使用对称算法(例如 AES‑256‑GCM)在本地加密机密
  • 将加密后的机密 以及 加密的数据密钥一起存储在 DynamoDB 中
  • 仅在需要时使用 KMS 解密数据密钥

为什么使用信封加密?

  • 能够扩展到任意大小的机密
  • 最小化 KMS API 调用次数
  • 是 AWS 推荐的最佳实践

本文聚焦于直接 Encrypt / Decrypt 的方式,以简化处理小型机密。对于处理更大负载的生产系统,应改用信封加密。

使用 AWS SDK for JavaScript v3 加密和解密敏感数据

import {
  KMSClient,
  EncryptCommand,
  DecryptCommand,
} from '@aws-sdk/client-kms';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
  DynamoDBDocumentClient,
  PutCommand,
  GetCommand,
} from '@aws-sdk/lib-dynamodb';

const kmsClient = new KMSClient({});
const dynamoClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));

const KMS_KEY_ID = process.env.KMS_KEY_ID!;
const TABLE_NAME = process.env.SECRETS_TABLE_NAME!;

interface CustomerSecret {
  customerId: string;
  secretId: string;
  encryptedValue: string;
  createdAt: string;
}

/**
 * Encrypt a sensitive value using KMS
 */
async function encryptSecret(plaintext: string): Promise<string> {
  const command = new EncryptCommand({
    KeyId: KMS_KEY_ID,
    Plaintext: Buffer.from(plaintext, 'utf-8'),
  });

  const response = await kmsClient.send(command);

  if (!response.CiphertextBlob) {
    throw new Error('Encryption failed: no ciphertext returned');
  }

  // Store as base64
  return Buffer.from(response.CiphertextBlob).toString('base64');
}

/**
 * Decrypt a KMS‑encrypted value
 */
async function decryptSecret(encryptedValue: string): Promise<string> {
  const command = new DecryptCommand({
    CiphertextBlob: Buffer.from(encryptedValue, 'base64'),
    // KeyId is optional for decrypt – KMS knows which key was used
  });

  const response = await kmsClient.send(command);

  if (!response.Plaintext) {
    throw new Error('Decryption failed: no plaintext returned');
  }

  return Buffer.from(response.Plaintext).toString('utf-8');
}

/**
 * Store an encrypted secret in DynamoDB
 */
async function storeSecret(
  customerId: string,
  secretId: string,
  plainSecret: string,
): Promise<void> {
  const encryptedValue = await encryptSecret(plainSecret);

  const item: CustomerSecret = {
    customerId,
    secretId,
    encryptedValue,
    createdAt: new Date().toISOString(),
  };

  await dynamoClient.send(
    new PutCommand({
      TableName: TABLE_NAME,
      Item: item,
    }),
  );
}

/**
 * Retrieve and decrypt a secret from DynamoDB
 */
async function getSecret(
  customerId: string,
  secretId: string,
): Promise<string | null> {
  const response = await dynamoClient.send(
    new GetCommand({
      TableName: TABLE_NAME,
      Key: { customerId, secretId },
    }),
  );

  if (!response.Item) {
    return null;
  }

  const secret = response.Item as CustomerSecret;
  return await decryptSecret(secret.encryptedValue);
}

// Example usage
async function example() {
  // Store a customer's API key
  await storeSecret('customer-123', 'api-key', 'super-secret-api-key-xyz');

  // Retrieve and decrypt it later
  const apiKey = await getSecret('customer-123', 'api-key');
  console.log('Decrypted API key:', apiKey);
}

SSM Parameter Store vs KMS:权衡

您可能会想:是使用带 KMS 加密的 SSM Parameter Store,还是直接用 KMS 加密后将数据存入 DynamoDB?

SSM Parameter Store 方案

优点

  • 集中式密钥管理
  • 内置版本控制
  • 免费层:最多 10 000 个参数
  • 可与众多 AWS 服务集成

缺点

  • 额外的 API 调用(SSM + DynamoDB)
  • 延迟增加
  • 需要管理两个服务
  • 10 000 参数的限制在大规模时可能受限

示例

// 存入 SSM,在 DynamoDB 中引用
const paramName = `/customers/${customerId}/secrets/${secretId}`;
await ssm.putParameter({
  Name: paramName,
  Value: plainSecret,
  Type: 'SecureString', // 使用 KMS 加密
  KeyId: KMS_KEY_ID,
});

// 在 DynamoDB 中存储引用
await dynamodb.putItem({
  TableName: 'Customers',
  Item: {
    customerId: { S: customerId },
    secretRef: { S: paramName }, // 仅存储引用
  },
});

在 DynamoDB 中直接使用 KMS 加密

优点

  • 单一服务(DynamoDB)
  • 延迟更低(一次 API 调用代替两次)
  • 没有参数数量限制
  • 架构更简洁

缺点

  • 没有内置版本控制(需要自行实现)
  • 在 AWS 控制台中可见性较低
  • 需要手动处理密钥轮换

何时使用哪种

  • 需要版本控制 – 如有需要,您必须实现自己的版本控制逻辑。
  • 由客户提供且由客户拥有/轮换的机密 – 直接在 DynamoDB 中使用 KMS 加密通常足够,因为轮换由外部处理。

关键要点: 为任务选择合适的工具。

  • Secrets Manager 在需要轮换的应用程序机密方面表现出色。
  • KMS 在高吞吐量、面向特定客户的数据加密方面表现卓越。

您是否在大规模管理机密时遇到类似挑战?欢迎在下方评论中分享您的做法。

Back to Blog

相关文章

阅读更多 »