---
title: "Token 內容加密 - Clean Architecture in Go"
date: 2025-03-28T00:00:00+08:00
publishDate: 2025-03-28T00:00:00+08:00
lastmod: 2024-10-07T20:05:43+08:00
tags: ["Golang","Clean Architecture","架構","經驗"]
series: "clean-architecture-in-go"
toc: true
permalink: "https://blog.aotoki.me/posts/2025/03/28/clean-architecture-in-go-token-encryption/"
language: "zh-tw"
---


現階段我們已經將 Tokenization（代號化）的機制整合到原本訂單的使用者資訊中，然而在 `Token` 的紀錄中仍會存在明碼的資料，因此我們還需要加入加密機制來處理，要如何處理也是一個值得思考的問題。

<!--more-->

## 時機{#timing}

加密跟解密的處理，該由誰來負責是個不常被注意到的議題。我們應該在 Entity（實體）、UseCase（使用案例）、Repository（倉庫） 的哪一個階段進行處理更為適合呢？

假設在 Entity 處理，那麼很可能這個 Entity 本身就是負責加解密的行為，否則每次我們要使用這個 Entity 進行任何動作時，都需要呼叫一次加解密的操作，是相當費力的。

除此之外，如果 Entity 需要知道加解密的方式，那麼也必須知道使用哪種加密演算法，一定程度上也跟這個演算法耦合，從期待是高度抽象的 Entity 來說也會失去了彈性。

在 UseCase 中使用相對 Entity 一定程度合理許多，假設是需要高度保護的資料，只在這個 UseCase 使用時解密再進行使用，似乎也合理許多。

然而，這樣做可能讓 UseCase 在封裝上有著過多細節的問題，因為每次需要使用這些加密的資料，我們是必要將它還原成 `[]byte` 或者 `string` 的狀態才能使用，就很容易有過多的細節存在。

因此，要負責加解密的職責，透過 Repository 來負責可能是更合理的。對 Repository 的定位來說，本身就具備跟細節耦合的方式，因此我們可以根據 Repository 使用的儲存類型設定不同的加密方式和處理，在使用上也不像 Entity 和 UseCase 那麼受限。

綜合以上的評估，在 Repository 做加密處理是最合理的方案，因此我們可以開始著手來改寫 `InMemoryTokenRepository` 的實作。

> 上述的判斷並不是絕對的，假設對於安全性有極高的要求，確實可能會有著需要每次都解密後馬上加密再繼續使用的情境。以「減少明碼處理」的角度來看，這樣確實更安全，但是在大多數情況下，一個 API 請求的處理可能不到一秒，在這個前提下以 Repository 層級的加解密已經比明碼處理安全非常多。

## 實作加解密{#implement-encrypt-and-decrypt}

Golang 本身就有提供相當容易使用的加密標準函式庫，我們可以在 `internal/repository/in_memory_tokens.go` 加入兩個輔助函示來協助我們實作。

```go
// ...

func encrypt(block cipher.Block, data []byte) ([]byte, error) {
	encrypted := make([]byte, aes.BlockSize+len(data))
	iv := encrypted[:aes.BlockSize]

	if _, err := io.ReadFull(rand.Reader, iv); err != nil {
		return nil, err
	}

	stream := cipher.NewCFBEncrypter(block, iv)
	stream.XORKeyStream(encrypted[aes.BlockSize:], data)

	return encrypted, nil
}

func decrypt(block cipher.Block, data []byte) ([]byte, error) {
	iv := data[:aes.BlockSize]
	rawData := data[aes.BlockSize:]

	stream := cipher.NewCFBDecrypter(block, iv)
	stream.XORKeyStream(rawData, rawData)

	return rawData, nil
}
```

這裡選用了 AES 演算法和 CFB 這個加密方式來搭配使用，參考 Golang 文件的範例實作了兩個輔助函示。

接下來，我們只需要更新 `InMemoryTokenRepository` 的 `Find()` 和 `Save()` 實作，讓加解密套用進去。

```go
// ...
var tokenEncryptionKey = "0123456789abcdef" // AES-128（16 Bytes）

func NewInMemoryTokenRepository() (*InMemoryTokenRepository, error) {
	cipher, err := aes.NewCipher([]byte(tokenEncryptionKey))
	if err != nil {
		return nil, err
	}

	return &InMemoryTokenRepository{
		cipher: cipher,
		tokens: map[string]InMemoryTokenSchema{},
	}, nil
}

func (r *InMemoryTokenRepository) Find(ctx context.Context, tokenStr string) (*tokens.Token, error) {
	segments := strings.SplitN(tokenStr, ":", 2)
	if len(segments) != 2 {
		return nil, tokens.ErrTokenNotFound
	}

	id := segments[1]
	token, ok := r.tokens[id]
	if !ok {
		return nil, tokens.ErrTokenNotFound
	}

	rawData, err := decrypt(r.cipher, token.Data)
	if err != nil {
		return nil, tokens.ErrUnableToDecrypt
	}

	return tokens.New(
		token.Id,
		tokens.WithVersion(token.Version),
		tokens.WithData(rawData),
	), nil
}

func (r *InMemoryTokenRepository) Save(ctx context.Context, token *tokens.Token) error {
	encrypted, err := encrypt(r.cipher, token.Data())
	if err != nil {
		return tokens.ErrUnableToEncrypt
	}

	r.tokens[token.Id()] = InMemoryTokenSchema{
		Id:      token.Id(),
		Data:    encrypted,
		Version: token.Version(),
	}

	return nil
}
```

到此為止，如果有撰寫相關的 Feature Test 可以運行看看，我們的實作並不會改變原本的功能，然而現在在資料的儲存上已經具備加解密的機制。

## 改進{#improvement}

作為展示在 Clean Architecture 設計下能夠輕鬆的插入不同處理的設計，上述的案例仍有些簡單，以下是一些可以更近一步改進的思考方向。

首先，我們的 Key（密鑰）是直接寫死在 `InMemoryTokenRepository` 上的，一看就知道這個做法並不安全，因此我們應該將密鑰在其他地方載入，做為參數傳入。

```go
func NewInMemoryTokenRepository(cfg *config.Config) (*InMemoryTokenRepository, error) {
	cipher, err := aes.NewCipher(cfg.EncryptionKey)
	if err != nil {
		return nil, err
	}

	return &InMemoryTokenRepository{
		cipher: cipher,
		tokens: map[string]InMemoryTokenSchema{},
	}, nil
}
```

透過依賴注入的方式，將密鑰注入進來。此時我們只需要確保 `*config.Config` 是以安全的方式載入即可。

另一方面，我們在 `encrypt` 和 `decrypt` 的實作實際上並不符合 Repository 的定位。基於設計上的考量，我們應該進一步讓 Repository 描述需求，定義出加密的介面來處理。

```go
type Cipher interface {
	Encrypt(context.Context, []byte) ([]byte, error)
	Decrypt(context.Context, []byte) ([]byte, error)
}
```

此時，我們也不需要使用 `*config.Config` 來提供密鑰，因為在我們實作 `Cipher` 介面後，這個密鑰會由 `Chiper` 的實作者處理，並且在我們進行依賴注入（如：`wire`）的階段時處理完畢。

假設我們運行在雲端環境，並且希望這個加解密的過程能有更高的安全性，甚至可以利用像是 AWS SDK 的方式，直接取用 KMS（Key Management Service）這類服務來協助我們處理。

```go
// internal/cipher

var _ repository.Cipher = &KmsCipher{}

type KmsCipher struct {
  kms *kms.Client
  config *KmsCipherConfig
}

// ...

func (c *KmsCipher) Encrypt(ctx context.Context, data []byte) ([]byte, error) {
	out, err := c.kms.Encrypt(ctx, kms.EncryptInput{
		KeyId: aws.String(c.config.Id),
		Plaintext: data
	})

	if err != nil {
		return nil, err
	}

	return out.CiphertextBlob, nil
}
```

如此一來我們甚至不用在系統中載入任何密鑰，就可以用非常安全的方式對資料進行處理。

