
Token 內容加密 - Clean Architecture in Go
現階段我們已經將 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 文件的範例實作了兩個輔助函示。
接下來,我們只需要更新 InMemoryTokenRepository
的 Find()
和 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
是以安全的方式載入即可。
另一方面,我們在 encrypt
和 decrypt
的實作實際上並不符合 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}
如此一來我們甚至不用在系統中載入任何密鑰,就可以用非常安全的方式對資料進行處理。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 連載介紹 - Clean Architecture in Go
- 目標設定 - Clean Architecture in Go
- wire 的依賴注入 - Clean Architecture in Go
- 案例說明 - Clean Architecture in Go
- 操作介面設計 - Clean Architecture in Go
- Place Order 實作 Controller 部分 - Clean Architecture in Go
- Place Order 實作 Entity 部分 - Clean Architecture in Go
- Place Order 實作 Repository 部分 - Clean Architecture in Go
- Lookup Order 功能 - Clean Architecture in Go
- Tokenization 機制設計 - Clean Architecture in Go
- 在 Place Order 實作 Token 機制 - Clean Architecture in Go
- 在 Lookup Order 實作 Token 機制 - Clean Architecture in Go
- Token 內容加密 - Clean Architecture in Go