Amazon S3 Encryption Client を使ってCSE-KMSをしてみた

クライアント側でもさらに暗号化しておきたい場合に
2024.03.13

S3バケットにPutする前にクライアント側で暗号化したい

こんにちは、のんピ(@non____97)です。

皆さんはS3バケットにPutする前にクライアント側で暗号化したいなと思ったことはありますか? 私はあります。

組織のコンプライアンスによっては転送中はHTTPSだけでなく、別途クライアント側でAESなどで暗号化することが求められます。

では、S3の場合はどのような選択肢があるでしょうか。S3の暗号化の方式には以下があります。

  • SSE-S3 : S3が管理している暗号化キーで暗号化する
  • SSE-KMS : S3上でKMSを使って暗号化する
  • SSE-C : 自身が作成及び管理している暗号化キーでS3上で暗号化する
  • CSE-KMS : クライアント側でKMSを使って暗号化する
  • CSE-C : 自身が作成及び管理している暗号化キーでクライアント側で暗号化する

それぞれの概要は以下記事がまとまっています。

クライアント側で暗号化をするとなると、CSE-KMSかCSE-Cです。CSE-Cの場合、自身で暗号化キーを管理する必要があります。これは非常に手間です。そのため、要件を満たすことができるのであればCSE-KMSを使いたいとこです。CSE-KMSについてはAmazon S3 Encryption Clientを使うことで簡単に実装することが可能です。実際にAmazon S3 Encryption Clientを使ってみました。

なお、CSE-Cについては以下記事があります。

いきなりまとめ

  • Amazon S3 Encryption Client を使うことで簡単にCSE-KMSの実装を行える
    • for Java ではCSE-Cも可能
  • マネジメントコンソールから透過的に平文のオブジェクトをダウンロードすることを防ぐことが可能
    • どのKMSキーを使ったのかは分からないので、全てのKMSキーのアクセスが可能で、総当たりできないと復号することは難しい
    • S3バケットとKMSキーのAWSアカウント自体を分けてしまえば、S3バケットのAWSアカウントの全ての権限が奪われたとしても、KMSキーを特定することが難しいため復号はできない想定
  • SSE-KMSやSSE-S3との併用も可能

Amazon S3 Encryption Clientとは

Amazon S3 Encryption ClientとはS3にPutする前にクライアント側で暗号化するライブラリです。S3側ではクライアント側で暗号化されたオブジェクトをそのまま受け取ります。クライアント側で暗号化されたものを復号して。保存することはありません。(SSE-S3やSSE-KMSなど別途サーバーサイド暗号化を行うことは可能です)

情報は以下AWS公式ドキュメントにまとまっています。

Amazon S3 Encryption Clientは以下プログラミング言語とプラットフォームでサポートされています。

  • C++
  • Go
  • Java
  • .NET
  • Ruby
  • PHP

暗号化するオブジェクトごとに一意のデータキーを生成します。

では、暗号化する際に使用するデータキーはどのように管理するでしょうか。これはS3バケットにオブジェクトをPutする際にメタデータとして一緒に管理されています。この際、データキーはラッピングキーというデータキーを暗号化する暗号化キーで暗号化されています。そしてラッピングキーで暗号化されたデータキーをS3のオブジェクトとしてメタデータに保存します。ラッピングキーはCSE-KMSの場合はKMSキーです。

s3-envelope-encrypt-3

抜粋 : Amazon S3 暗号化クライアントの概念 - Amazon S3 暗号化クライアント

KMSの鍵管理は以下記事が参考になります。

サポートしている暗号化アルゴリズムは以下にまとまっています。オブジェクトの暗号化はAES-GCMで、データキーの暗号化はAES-GCMまたはKMS+encryption contextRSA-OAEP-MGF1 and SHA-1を使うと良さそうです。

やってみた

検証環境

実際に試してみましょう。

SSE-KMSを有効化したS3バケットとSSE-KMS用、CSE-KMS用の2つのKMSキーを用意しました。

以下のAWS CDKのコードを使ってデプロイしました。

./lib/sse-kms-stack.ts

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";

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

    const sseKey = new cdk.aws_kms.Key(this, "KeySse", {
      enableKeyRotation: true,
      alias: "sse-key",
      keySpec: cdk.aws_kms.KeySpec.SYMMETRIC_DEFAULT,
      keyUsage: cdk.aws_kms.KeyUsage.ENCRYPT_DECRYPT,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    new cdk.aws_kms.Key(this, "KeyCse", {
      enableKeyRotation: true,
      alias: "cse-key",
      keySpec: cdk.aws_kms.KeySpec.SYMMETRIC_DEFAULT,
      keyUsage: cdk.aws_kms.KeyUsage.ENCRYPT_DECRYPT,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    new cdk.aws_s3.Bucket(this, "Bucket", {
      encryption: cdk.aws_s3.BucketEncryption.KMS,
      blockPublicAccess: new cdk.aws_s3.BlockPublicAccess({
        blockPublicAcls: true,
        blockPublicPolicy: true,
        ignorePublicAcls: true,
        restrictPublicBuckets: true,
      }),
      encryptionKey: sseKey,
      bucketKeyEnabled: true,
      enforceSSL: true,
      versioned: false,
    });
  }
}

ファイルをS3バケットにPut or Getするためにいくつかのファイルが必要です。ファイルは以下スクリプトで用意しました。

$ for i in {1..2}; do
    mkdir "./dir_${i}"
    for j in {1..2}; do
        echo "./dir_${i}/file_${j}" > "./dir_${i}/file_${j}"
        mkdir "./dir_${i}/dir_${j}"
        for k in {1..2}; do
            echo "./dir_${i}/dir_${j}/file_${k}" > "./dir_${i}/dir_${j}/file_${k}"
            mkdir "./dir_${i}/dir_${j}/dir_${k}"
            for l in {1..2}; do
              echo "./dir_${i}/dir_${j}/dir_${k}/file_${l}" > "./dir_${i}/dir_${j}/dir_${k}/file_${l}"
          done
        done
    done
done

実行後、以下のように階層構造になっていることを確認します。

$ tree dir_1
dir_1
├── dir_1
│   ├── dir_1
│   │   ├── file_1
│   │   └── file_2
│   ├── dir_2
│   │   ├── file_1
│   │   └── file_2
│   ├── file_1
│   └── file_2
├── dir_2
│   ├── dir_1
│   │   ├── file_1
│   │   └── file_2
│   ├── dir_2
│   │   ├── file_1
│   │   └── file_2
│   ├── file_1
│   └── file_2
├── file_1
└── file_2

7 directories, 14 files

用意したコード

Golangを触ってみたかったので、Amazon S3 Encryption Client for Go V3を使います。ライブラリの詳細は以下をご覧ください。

なお、Amazon S3 Encryption Client for Go V3はRaw AES-CGMキーやRaw RSAキーを使用することはできません。つまり、CSE-Cはできません。この場合はAmazon S3 Encryption Client for Javaを使用しましょう。

以下サンプルコードを参考に実装してみます。

引数に応じて、複数オブジェクトのPut or Getをできるようにしています。難しいことは特にしていません。通常に Put or Get するのと比べてAmazon S3 Encryption Client for GoのクライアントとCMM作成している程度です。

実際のコードは以下の通りです。

main.go

package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"strings"

	"github.com/aws/amazon-s3-encryption-client-go/v3/client"
	"github.com/aws/amazon-s3-encryption-client-go/v3/materials"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
	"github.com/aws/aws-sdk-go-v2/service/kms"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

func main() {
	var (
		download   = flag.Bool("download", false, "Download S3 objects to local with CSE-KMS")
		upload     = flag.Bool("upload", false, "Upload local file to S3 bucket with CSE-KMS")
		bucketName = flag.String("bucket", "", "S3 bucket name")
		objectKey  = flag.String("object-key", "", "S3 object key")
		localPath  = flag.String("path", "", "Local path")
		kmsKeyArn  = flag.String("kms-key-arn", "", "KMS key ARN")
	)
	flag.Parse()

	// download と upload の両方またはどちらも設定されていない場合は終了
	if (*download && *upload) || (!*download && !*upload) {
		log.Fatalf("Error : download か upload のいずれか一方を指定してください")
	}

	// AWSの認証情報を読み込み
	// MFAのコード入力を標準入力で受け付ける
	ctx := context.Background()
	cfg, err := config.LoadDefaultConfig(ctx, config.WithAssumeRoleCredentialOptions(func(options *stscreds.AssumeRoleOptions) {
		options.TokenProvider = func() (string, error) {
			return stscreds.StdinTokenProvider()
		}
	}))
	if err != nil {
		log.Fatalf("unable to load AWS credential:, %v", err)
	}

	// AWS SDK client
	s3Client := s3.NewFromConfig(cfg)
	kmsClient := kms.NewFromConfig(cfg)

	// keyring と CMM の作成
	cmm, err := materials.NewCryptographicMaterialsManager(materials.NewKmsKeyring(kmsClient, *kmsKeyArn, func(options *materials.KeyringOptions) {
		options.EnableLegacyWrappingAlgorithms = false
	}))
	if err != nil {
		log.Fatalf("error while creating new CMM")
	}

	// Amazon S3 Encryption Client
	s3EncryptionClient, err := client.New(s3Client, cmm, func(clientOptions *client.EncryptionClientOptions) {
		clientOptions.EnableLegacyUnauthenticatedModes = false
	})
	if err != nil {
		log.Fatalf("unable to load SDK config, %v", err)
	}

	// オブジェクトをGetする場合
	if *download {
		if err := GetObjectsWithCseKms(ctx, s3EncryptionClient, s3Client, *localPath, *bucketName, *objectKey, cmm); err != nil {
			log.Fatalf("Failed to download objects: %v", err)
		}
	}

	// オブジェクトをPutする場合
	if *upload {
		if err := PutObjectsWithCseKms(ctx, s3EncryptionClient, s3Client, *localPath, *bucketName, *objectKey, cmm); err != nil {
			log.Fatalf("Failed to upload objects: %v", err)
		}
	}
}

func GetObjectsWithCseKms(ctx context.Context, s3EncryptionClient *client.S3EncryptionClientV3, s3Client *s3.Client, path, bucketName, objectPrefix string, cmm materials.CryptographicMaterialsManager) error {
	var objectKeys []string

	// 指定されたオブジェクトキーがフォルダか単一のオブジェクトを指しているのか判断
	// フォルダの場合、フォルダ配下の全オブジェクトを取得
	// ファイルの場合、オブジェクトキーを直接利用
	if strings.HasSuffix(objectPrefix, "/") {
		listInput := &s3.ListObjectsV2Input{
			Bucket: aws.String(bucketName),
			Prefix: aws.String(objectPrefix),
		}
		res, err := s3Client.ListObjectsV2(ctx, listInput)
		if err != nil {
			return fmt.Errorf("error ListObjectsV2: %w", err)
		}
		for _, item := range res.Contents {
			objectKeys = append(objectKeys, *item.Key)
		}
	} else {
		objectKeys = append(objectKeys, objectPrefix)
	}

	for _, objectKey := range objectKeys {
		var localFilePath string = ""
		if strings.HasSuffix(objectPrefix, "/") || len(objectKeys) > 1 {
			// 複数オブジェクトの場合はプレフィックスを結合し、ディレクトリ構造を維持する
			localFilePath = filepath.Join(path, strings.TrimPrefix(objectKey, objectPrefix))
		} else {
			// 単一オブジェクトの場合
			localFilePath = filepath.Join(path, filepath.Base(objectKey))
		}

		// Get先のディレクトリの作成
		if err := os.MkdirAll(filepath.Dir(localFilePath), os.ModePerm); err != nil {
			return fmt.Errorf("error create directory: %w", err)
		}

		log.Printf("Downloading: %s/%s → %s", bucketName, objectKey, localFilePath)

		// オブジェクトのGet
		getRes, err := s3EncryptionClient.GetObject(ctx, &s3.GetObjectInput{
			Bucket: aws.String(bucketName),
			Key:    aws.String(objectKey),
		})
		if err != nil {
			return fmt.Errorf("error while decrypting: %v", err)
		}
		defer getRes.Body.Close()

		file, err := os.Create(localFilePath)
		if err != nil {
			return fmt.Errorf("error create file: %w", err)
		}
		defer file.Close()

		if _, err = file.ReadFrom(getRes.Body); err != nil {
			return fmt.Errorf("error write file: %w", err)
		}
	}

	return nil
}

func PutObjectsWithCseKms(ctx context.Context, s3EncryptionClient *client.S3EncryptionClientV3, s3Client *s3.Client, basePath, bucketName, objectPrefix string, cmm materials.CryptographicMaterialsManager) error {

	return filepath.Walk(basePath, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if !info.IsDir() {
			file, err := os.Open(path)
			if err != nil {
				return err
			}
			defer file.Close()

			var objectKey string
			if strings.HasSuffix(objectPrefix, "/") {
				// ディレクトリの階層構造を維持するようにオブジェクトキーを生成
				relPath, err := filepath.Rel(basePath, path)
				if err != nil {
					return err
				}

				// relPathが"."の場合 = basePath が単一ファイルの場合はファイル名をそのままオブジェクキーとして使用
				if relPath == "." {
					relPath = filepath.Base(path)
				}

				objectKey = filepath.Join(objectPrefix, relPath)
			} else {
				// objectPrefixをそのままオブジェクトキーとして使用
				objectKey = objectPrefix
			}

			// Windowsのパス区切り文字を正しい形式に変換
			objectKey = filepath.ToSlash(objectKey)

			log.Printf("Uploading: %s → %s/%s", path, bucketName, objectKey)

			_, err = s3EncryptionClient.PutObject(ctx, &s3.PutObjectInput{
				Bucket: &bucketName,
				Key:    &objectKey,
				Body:   file,
			})
			if err != nil {
				return fmt.Errorf("failed to upload %s → %s/%s: %w", path, bucketName, objectKey, err)
			}
		}
		return nil
	})
}

コードは以下GitHubリポジトリにも保存しています。

S3バケットにアップロードしたオブジェクトが暗号化されていることを確認

ビルドして実行してみます。

まず、ローカルの複数のファイルをS3バケットにPutします。その際、CSE-KMSで使用するKMSキーのARNを一緒に指定してあげます。

$ go build

$ bucket_name=s3kmsstack-bucket83908e77-nslpx5joc5zo
$ kms_key_arn=arn:aws:kms:us-east-1:<AWSアカウントID>:key/776d89f5-8db2-4b5a-bf2d-92d67c2499ec

$ ./cse-kms -upload \
  --bucket="${bucket_name}" \
  -object-key=test1/ \
  -path=dir_1 \
  -kms-key-arn="${kms_key_arn}"
2024/03/12 18:35:14 Uploading: dir_1/dir_1/dir_1/file_1 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_1/dir_1/file_1
Assume Role MFA token code: 232551
2024/03/12 18:35:25 Uploading: dir_1/dir_1/dir_1/file_2 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_1/dir_1/file_2
2024/03/12 18:35:25 Uploading: dir_1/dir_1/dir_2/file_1 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_1/dir_2/file_1
2024/03/12 18:35:26 Uploading: dir_1/dir_1/dir_2/file_2 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_1/dir_2/file_2
2024/03/12 18:35:26 Uploading: dir_1/dir_1/file_1 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_1/file_1
2024/03/12 18:35:27 Uploading: dir_1/dir_1/file_2 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_1/file_2
2024/03/12 18:35:28 Uploading: dir_1/dir_2/dir_1/file_1 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_2/dir_1/file_1
2024/03/12 18:35:28 Uploading: dir_1/dir_2/dir_1/file_2 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_2/dir_1/file_2
2024/03/12 18:35:29 Uploading: dir_1/dir_2/dir_2/file_1 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_2/dir_2/file_1
2024/03/12 18:35:29 Uploading: dir_1/dir_2/dir_2/file_2 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_2/dir_2/file_2
2024/03/12 18:35:30 Uploading: dir_1/dir_2/file_1 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_2/file_1
2024/03/12 18:35:30 Uploading: dir_1/dir_2/file_2 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_2/file_2
2024/03/12 18:35:30 Uploading: dir_1/file_1 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/file_1
2024/03/12 18:35:31 Uploading: dir_1/file_2 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/file_2

Put後、S3バケット内のオブジェクト一覧を確認します。

$ s3-tree s3kmsstack-bucket83908e77-nslpx5joc5zo
Enter MFA code for <MFAデバイスのARN>:
s3kmsstack-bucket83908e77-nslpx5joc5zo
└── test1
    ├── dir_1
    │   ├── dir_1
    │   │   ├── file_1
    │   │   └── file_2
    │   ├── dir_2
    │   │   ├── file_1
    │   │   └── file_2
    │   ├── file_1
    │   └── file_2
    ├── dir_2
    │   ├── dir_1
    │   │   ├── file_1
    │   │   └── file_2
    │   ├── dir_2
    │   │   ├── file_1
    │   │   └── file_2
    │   ├── file_1
    │   └── file_2
    ├── file_1
    └── file_2

8 directories, 14 files

ディレクトリ構造を維持したままPutできていますね。

もちろん、以下のとおり単一オブジェクトのPutも可能です。

$ ./cse-kms -upload \
  --bucket="${bucket_name}" \
  -object-key=test2/ \
  -path=dir_1/dir_1/dir_1/file_1 \
  -kms-key-arn="${kms_key_arn}"
2024/03/12 18:48:57 Uploading: dir_1/dir_1/dir_1/file_1 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test2/file_1
Assume Role MFA token code: 075083

$ ./cse-kms -upload \
  --bucket="${bucket_name}" \
  -object-key=test3 \
  -path=dir_1/dir_1/dir_1/file_2 \
  -kms-key-arn="${kms_key_arn}"
2024/03/12 18:49:58 Uploading: dir_1/dir_1/dir_1/file_2 → s3kmsstack-bucket83908e77-nslpx5joc5zo/test3
Assume Role MFA token code: 496548

Putしたオブジェクトを確認すると、SSE-KMSで暗号化されているようでした。

SSE-KMSで暗号化されていることを確認

また、オブジェクトにはメタデータが多数設定されていました。

メタデータ

Amazon S3 Encryption Client v3のドキュメント上でこれらメタデータの詳細を説明しているものはありませんでしたが、v1 for Javaのドキュメントには以下のような記載がありました。

Metadata used the same way by V1 and V2 clients

key description
x-amz-key-v2 CEK in key wrapped form. This is necessary so that the S3 encryption client that doesn't recognize the v2 format will not mistakenly decrypt S3 object encrypted in v2 format.
x-amz-iv Randomly generated IV (per S3 object), base64 encoded. (Same as v1.)
x-amz-unencrypted-content-length Unencrypted content length. (optional but should be specified whenever possible. Same as v1.)
x-amz-tag-len Tag length (in bits) when AEAD is in use.Only applicable if AEAD is in use. This meta information is absent otherwise, or if KMS is in use.
Supported value: "128"

Metadata using V2 client

key description
x-amz-matdesc Customer provided material description in JSON format. (Same as v1). For KMS client side encryption, the cek algorithm is stored as part of the material description under the key-name aws:x-amz-cek-alg.
x-amz-wrap-alg Key wrapping algorithm used.Supported values: "AES/GCM/NoPadding" (symmetric default), "RSA-OAEP-SHA1", "RSA-OAEP-SHA1" (asymmetric default), "kms"
No standard key wrapping is used if this meta information is absent
Always set to "kms" if KMS is used for client-side encryption
x-amz-cek-alg Content encryption algorithm used. Supported values: "AES/GCM/NoPadding"

抜粋 : com.amazonaws.services.s3 (AWS SDK for Java - 1.12.677)

データキーはオブジェクトごとに設定されていると記載がありますね。実際にx-amz-key-v2を確認してみましょう。

$ aws s3api head-object --bucket s3kmsstack-bucket83908e77-nslpx5joc5zo --key test1/file_1
{
    "AcceptRanges": "bytes",
    "LastModified": "2024-03-12T09:35:32+00:00",
    "ContentLength": 31,
    "ETag": "\"479eaf9f260a764ec1448e0e7d63f3c2\"",
    "ContentType": "application/octet-stream",
    "ServerSideEncryption": "aws:kms",
    "Metadata": {
        "x-amz-tag-len": "128",
        "x-amz-unencrypted-content-length": "15",
        "x-amz-wrap-alg": "kms+context",
        "x-amz-matdesc": "{\"aws:x-amz-cek-alg\":\"AES/GCM/NoPadding\"}",
        "x-amz-key-v2": "AQIDAHg+YH4U1RAkozWyMUWL5BicUbfrz32sM54xbbaVA2nkmAHJIoazcOqzARB/6YQnE1S5AAAAfjB8BgkqhkiG9w0BBwagbzBtAgEAMGgGCSqGSIb3DQEHATAeBglghkgBZQMEAS4wEQQMkWRXhEH7j0PPS9TVAgEQgDuPq+HNpUv9shzB384yAk6GTwK+ZBf2butKxHjHpcmKnctVJ2WnjGJigc3IAfhconOMIl3SvELwIuWa6A==",
        "x-amz-cek-alg": "AES/GCM/NoPadding",
        "x-amz-iv": "Y/CAq2DVgdI/TENS"
    },
    "SSEKMSKeyId": "arn:aws:kms:us-east-1:<AWSアカウントID>:key/994ab06b-bf6a-4386-b61c-bdbf49bc4418",
    "BucketKeyEnabled": true
}
$ aws s3api head-object --bucket s3kmsstack-bucket83908e77-nslpx5joc5zo --key test1/dir_1/dir_1/file_1
{
    "AcceptRanges": "bytes",
    "LastModified": "2024-03-12T09:35:26+00:00",
    "ContentLength": 43,
    "ETag": "\"fc787b87e0f91210c8a6d61d77b20eee\"",
    "ContentType": "application/octet-stream",
    "ServerSideEncryption": "aws:kms",
    "Metadata": {
        "x-amz-tag-len": "128",
        "x-amz-unencrypted-content-length": "27",
        "x-amz-wrap-alg": "kms+context",
        "x-amz-matdesc": "{\"aws:x-amz-cek-alg\":\"AES/GCM/NoPadding\"}",
        "x-amz-key-v2": "AQIDAHg+YH4U1RAkozWyMUWL5BicUbfrz32sM54xbbaVA2nkmAEyAIpL5ZYFivanr593VUReAAAAfjB8BgkqhkiG9w0BBwagbzBtAgEAMGgGCSqGSIb3DQEHATAeBglghkgBZQMEAS4wEQQMw7KI5thjhGJnNhSHAgEQgDtR/WTAkKaKBPcrR7Vd8H7Y5mpO6GqBddnb2coI3u+cbT2wl9n987/6hs0H+e1VPRzYKqyE6hQ8JYHFvw==",
        "x-amz-cek-alg": "AES/GCM/NoPadding",
        "x-amz-iv": "etp+udbyZBVTI2vD"
    },
    "SSEKMSKeyId": "arn:aws:kms:us-east-1:<AWSアカウントID>:key/994ab06b-bf6a-4386-b61c-bdbf49bc4418",
    "BucketKeyEnabled": true
}

確かにx-amz-key-v2はオブジェクト毎に異なるようです。初期化ベクトル(IV)もオブジェクト毎に異なるので当然といえば当然です。

Putしたオブジェクトを単純にGetしてみましょう。

$ aws s3 cp s3://s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_1/dir_1/file_1 ./
download: s3://s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_1/dir_1/file_1 to ./file_1

$ cat ./file_1
D}4�\��m�:ސ�q���
                1)�ƍ�*'�lk�{`�<�h

はい、Getしたオブジェクトの中身を確認すると文字化けしていました。確かに暗号化されていそうです。

それではAmazon S3 Encryption Clientを使ってオブジェクトをGetします。

$ ./cse-kms -download \
  --bucket="${bucket_name}" \
  -object-key=test1/ \
  -path=dst \
  -kms-key-arn="${kms_key_arn}"
Assume Role MFA token code: 066416
2024/03/12 18:53:38 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_1/dir_1/file_1 → dst/dir_1/dir_1/file_1
2024/03/12 18:53:39 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_1/dir_1/file_2 → dst/dir_1/dir_1/file_2
2024/03/12 18:53:39 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_1/dir_2/file_1 → dst/dir_1/dir_2/file_1
2024/03/12 18:53:40 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_1/dir_2/file_2 → dst/dir_1/dir_2/file_2
2024/03/12 18:53:40 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_1/file_1 → dst/dir_1/file_1
2024/03/12 18:53:41 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_1/file_2 → dst/dir_1/file_2
2024/03/12 18:53:41 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_2/dir_1/file_1 → dst/dir_2/dir_1/file_1
2024/03/12 18:53:42 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_2/dir_1/file_2 → dst/dir_2/dir_1/file_2
2024/03/12 18:53:42 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_2/dir_2/file_1 → dst/dir_2/dir_2/file_1
2024/03/12 18:53:43 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_2/dir_2/file_2 → dst/dir_2/dir_2/file_2
2024/03/12 18:53:43 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_2/file_1 → dst/dir_2/file_1
2024/03/12 18:53:44 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/dir_2/file_2 → dst/dir_2/file_2
2024/03/12 18:53:44 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/file_1 → dst/file_1
2024/03/12 18:53:45 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test1/file_2 → dst/file_2

特にエラーなくGetできました。ディレクトリ構造も維持できていそうです。

Getしたファイルを確認してみます。

$ cat dst/dir_1/dir_1/file_1
./dir_1/dir_1/dir_1/file_1

$ cat dst/dir_1/file_2
./dir_1/dir_1/file_2

どちらも平文で表示できました。Amazon S3 Encryption Clientを使うことによって簡単に復号できました。

CSE-KMSで使用しているKMSキーの権限が足りない場合に復号できないことを確認

続いて、CSE-KMSで使用しているKMSキーの権限が足りない場合に復号できないことを確認します。

以下re:Postの内容を参考に、rootユーザーと特定のIAMロールのみしか操作できないようにキーポリシーを変更します。

実際のキーポリシーは以下のとおりです。

変更前

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::<AWSアカウントID>:root"
            },
            "Action": "kms:*",
            "Resource": "*"
        }
    ]
}

変更後

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::<AWSアカウントID>:root"
            },
            "Action": "kms:*",
            "Resource": "*",
            "Condition": {
                "StringEquals": {
                    "aws:PrincipalType": "Account"
                }
            }
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::<AWSアカウントID>:role/<管理用IAMロール名>"
            },
            "Action": "kms:*",
            "Resource": "*"
        }
    ]
}

キーポリシーで"AWS": "arn:aws:iam::<AWSアカウントID>:root"がプリンシパルのステートメントを削除すると、rootユーザーであっても操作できなくなるので注意しましょう。キーポリシーの評価は以下が参考になります。

この状態でキーポリシーで明示的な許可をしていないIAMロールにAssumeRoleして、オブジェクトをGetします。このIAMロールにはAdministratorAccessを付与しています。

$ ./cse-kms -download \
  --bucket="${bucket_name}" \
  -object-key=test2/file_1 \
  -path=dst2/ \
  -kms-key-arn="${kms_key_arn}"
2024/03/12 21:57:01 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test2/file_1 → dst2/file_1
Assume Role MFA token code: 145469
2024/03/12 21:57:10 Failed to download objects: error while decrypting: operation error S3: GetObject, error while decrypting materials: operation error KMS: Decrypt, https response error StatusCode: 400, RequestID: b153dcae-3e7e-482e-83f7-d037b98bc119, api error AccessDeniedException: User: arn:aws:sts::<AWSアカウントID>:assumed-role/<IAMロール名>/aws-go-sdk-1710248221763425000 is not authorized to perform: kms:Decrypt on resource: arn:aws:kms:us-east-1:<AWSアカウントID>:key/776d89f5-8db2-4b5a-bf2d-92d67c2499ec because no resource-based policy allows the kms:Decrypt action

AdministratorAccessを付与しているにも関わらず、権限が足りずエラーになりました。キーポリシーがしっかり効いていそうです。

次に、以下のようにキーポリシーを編集して、使用している作業用IAMロールも許可してあげます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::<AWSアカウントID>:root"
            },
            "Action": "kms:*",
            "Resource": "*",
            "Condition": {
                "StringEquals": {
                    "aws:PrincipalType": "Account"
                }
            }
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": [
                    "arn:aws:iam::<AWSアカウントID>:role/<作業用IAMロール名>",
                    "arn:aws:iam::<AWSアカウントID>:role/<管理用IAMロール名>"
                ]
            },
            "Action": "kms:*",
            "Resource": "*"
        }
    ]
}

この状態で再度チャレンジします。

./cse-kms -download \
  --bucket="${bucket_name}" \
  -object-key=test2/file_1 \
  -path=dst2/ \
  -kms-key-arn="${kms_key_arn}"
2024/03/12 22:05:45 Downloading: s3kmsstack-bucket83908e77-nslpx5joc5zo/test2/file_1 → dst2/file_1
Assume Role MFA token code: 900353

$ cat dst2/file_1
./dir_1/dir_1/dir_1/file_1

正常に平文でオブジェクトをGetできました。

クライアント側でもさらに暗号化しておきたい場合に

Amazon S3 Encryption Client を使ってCSE-KMSを試してみました。

クライアント側でもさらに暗号化しておきたい場合に役立ちそうですね。仮に、オブジェクトをGetされたとしても、暗号されたままです。また、どのKMSキーを使ったのかは分かりません。そのため、全てのKMSキーのアクセスが可能で、総当たりできないと復号することは難しいかと思います。

KMSキーへのアクセス権限が漏洩することが気になるのであれば、S3バケットとKMSキーのAWSアカウント自体を分けてしまうのも一つの案でしょう。先述の通り、どのKMSキーを使ったのかはオブジェクトからは分かりません。そのため、仮にS3バケットのAWSアカウントの全ての権限が奪われたとしても、KMSキーを特定することができず、復号はできないと考えます。

また、送信先がS3ではない場合はAWS Encryption SDKを使うと良いでしょう。

この記事が誰かの助けになれば幸いです。

以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!