蒼時弦也
蒼時弦也
資深軟體工程師
發表於

Token 內容加密 - Clean Architecture in Go

這篇文章是 Clean Architecture in Go 系列的一部分,你可以透過 Leanpub 提前閱讀內容。

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

時機

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

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

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

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

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

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

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

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

實作加解密

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

 1// ...
 2
 3func encrypt(block cipher.Block, data []byte) ([]byte, error) {
 4	encrypted := make([]byte, aes.BlockSize+len(data))
 5	iv := encrypted[:aes.BlockSize]
 6
 7	if _, err := io.ReadFull(rand.Reader, iv); err != nil {
 8		return nil, err
 9	}
10
11	stream := cipher.NewCFBEncrypter(block, iv)
12	stream.XORKeyStream(encrypted[aes.BlockSize:], data)
13
14	return encrypted, nil
15}
16
17func decrypt(block cipher.Block, data []byte) ([]byte, error) {
18	iv := data[:aes.BlockSize]
19	rawData := data[aes.BlockSize:]
20
21	stream := cipher.NewCFBDecrypter(block, iv)
22	stream.XORKeyStream(rawData, rawData)
23
24	return rawData, nil
25}

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

接下來,我們只需要更新 InMemoryTokenRepositoryFind()Save() 實作,讓加解密套用進去。

 1// ...
 2var tokenEncryptionKey = "0123456789abcdef" // AES-128(16 Bytes)
 3
 4func NewInMemoryTokenRepository() (*InMemoryTokenRepository, error) {
 5	cipher, err := aes.NewCipher([]byte(tokenEncryptionKey))
 6	if err != nil {
 7		return nil, err
 8	}
 9
10	return &InMemoryTokenRepository{
11		cipher: cipher,
12		tokens: map[string]InMemoryTokenSchema{},
13	}, nil
14}
15
16func (r *InMemoryTokenRepository) Find(ctx context.Context, tokenStr string) (*tokens.Token, error) {
17	segments := strings.SplitN(tokenStr, ":", 2)
18	if len(segments) != 2 {
19		return nil, tokens.ErrTokenNotFound
20	}
21
22	id := segments[1]
23	token, ok := r.tokens[id]
24	if !ok {
25		return nil, tokens.ErrTokenNotFound
26	}
27
28	rawData, err := decrypt(r.cipher, token.Data)
29	if err != nil {
30		return nil, tokens.ErrUnableToDecrypt
31	}
32
33	return tokens.New(
34		token.Id,
35		tokens.WithVersion(token.Version),
36		tokens.WithData(rawData),
37	), nil
38}
39
40func (r *InMemoryTokenRepository) Save(ctx context.Context, token *tokens.Token) error {
41	encrypted, err := encrypt(r.cipher, token.Data())
42	if err != nil {
43		return tokens.ErrUnableToEncrypt
44	}
45
46	r.tokens[token.Id()] = InMemoryTokenSchema{
47		Id:      token.Id(),
48		Data:    encrypted,
49		Version: token.Version(),
50	}
51
52	return nil
53}

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

改進

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

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

 1func NewInMemoryTokenRepository(cfg *config.Config) (*InMemoryTokenRepository, error) {
 2	cipher, err := aes.NewCipher(cfg.EncryptionKey)
 3	if err != nil {
 4		return nil, err
 5	}
 6
 7	return &InMemoryTokenRepository{
 8		cipher: cipher,
 9		tokens: map[string]InMemoryTokenSchema{},
10	}, nil
11}

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

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

1type Cipher interface {
2	Encrypt(context.Context, []byte) ([]byte, error)
3	Decrypt(context.Context, []byte) ([]byte, error)
4}

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

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

 1// internal/cipher
 2
 3var _ repository.Cipher = &KmsCipher{}
 4
 5type KmsCipher struct {
 6  kms *kms.Client
 7  config *KmsCipherConfig
 8}
 9
10// ...
11
12func (c *KmsCipher) Encrypt(ctx context.Context, data []byte) ([]byte, error) {
13	out, err := c.kms.Encrypt(ctx, kms.EncryptInput{
14		KeyId: aws.String(c.config.Id),
15		Plaintext: data
16	})
17
18	if err != nil {
19		return nil, err
20	}
21
22	return out.CiphertextBlob, nil
23}

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