當 Swift/iOS 遇到 RSA 加密演算法

從概念到實作,讓 iOS App 安全性 Up Up

icon
每一篇文都畫個小插圖似乎是一個蠻可愛的做法!

最近在工作上遇到了 RSA 加密的需求,雖然已經有不少業界前輩寫過很棒的 RSA 文章,但我想以初次實作的角度,分享一些讓我感興趣、困惑的小知識。

因此,這篇文章會循序漸進,希望讓你順順地看下去就能略懂一二!


收到的需求

在前端呼叫後端 API 時,為了確保敏感資訊(如個人資料)能在傳遞過程中保持安全,我們可以使用 RSA 加密來保護這些資訊。

  • 前端:使用後端提供的 公鑰 (public key) 來加密資料
  • 後端:使用 私鑰 (private key) 來解開資料

在實作前,先來了解什麼是 RSA 加密吧~


RSA 是蝦米?

RSA 是一種「非對稱式加密演算法」( asymmeric cryptosystem ),指加密跟解密使用不同的金鑰。

特性是公鑰加密上鎖後,只能用私鑰解鎖。

因此,最大的好處在於「即使傳輸過程中被攔截,由於沒有私鑰,也無法解密出資料內容」。


在研究 RSA 時,也常看到 AES 這個陌生的名詞。
這兩者有什麼關係,又有什麼差異呢?

AES vs. RSA?

一般來說,常見的加密方式分為以下兩種:

AES 加密

  • 屬於對稱加密演算法,加密和解密使用相同的密鑰
  • 運算速度快、用途廣泛,適合加密大量數據
  • 但若密鑰洩漏,所有加密資料都可能被解開
  • 在 iOS 可以用 CryptoSwift 套件處理

RSA 加密

  • 屬於非對稱加密演算法,加密和解密使用不同的密鑰
  • 運算速度較慢,適合加密小型數據
  • 在 iOS 可以用 SwiftyRSA 套件處理

當然,我們也可以融合以上兩者的優勢,使用混合式加密!

混合式加密

  • 融合非對稱、對稱加密的優點
  • 加密大檔案時,可先用 AES 加密,再以 RSA 公鑰加密 AES 金鑰,這樣可以兼顧安全性與效能

查完了資料,後端突然敲了我,並給我了一個 PEM 格式的公鑰。
這又讓我好奇了,什麼是 PEM 格式呢?

PEM (Privacy Enhanced Mail)

提到公鑰私鑰,你可能會看過這個格式~

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1ymUuKnPRw99S1wMbEJt
nKv7lTOyYryYiXAlRjQznFYSaLVa8TACplEKEiLPO2A3aJG9tJf6ObMRiOhKnuGI
E9QJ+ByNOqJZM3mTHm/hAnX0N8d7WCozrJGyXlrb4I/JNkwTFx8DWggT+0rLPTCs
...
-----END PUBLIC KEY-----

這就是常用的 PEM 格式 👆🏻

PEM 格式是一種人類可讀的文字編碼,常用於憑證、公鑰與私鑰的儲存與交換,並可於純文字檔中輕鬆保存與傳輸。

副檔名 .pem.crt.cer ,裡面都有可能是 PEM 格式,因此只要打開看到 -----BEGIN ...----- ,便能得知它是 PEM!

延伸閱讀: 憑證的格式 PEM 與 DER


iOS 實戰

瞭解基本知識後,準備來實作吧!

在這次的需求中,除了要把「要傳遞的 API 路徑與參數」進行 RSA 加密外,還需要先對 API 路徑與參數做一定的處理。

這些處理的目的是為了降低 API 路徑與參數命名的混亂度,讓後端更好判斷與解密。

具體規則如下:

將 API 進行加密,並放置 header 傳遞資訊。

舉例:RSA(user/getUserInfo?param1=value&param2=value)

1. route 轉換小寫,去除 "-" 值
    a. 格式:route1/route2
    b. 舉例:user/getUserInfo
    
2. parameters 轉換小寫,去除 "-" 值,並用 & 組合
    a. 格式:?param=value
    b. 舉例:?id=bohsunhsu&password=abcde

1. 導入套件

我們會使用套件 SwiftyRSA 實作 RSA 加密,記得自行導入專案。

2. 取得公鑰

以我的例子,公鑰是由後端提供,而各位在實作前,可以用 Devglan 產出 RSA PEM 格式的公鑰與私鑰。

為求方便,這裡的示範是用參數儲存鑰匙,但現實中請不要這樣做,較佳的方式是放在 Keychain 儲存(但有點偏題故不在此贅述)。

private let testPublicKey = """
-----BEGIN PUBLIC KEY-----
XXXXXXXXXXXXXXXXXXXXXXXXXX
-----END PUBLIC KEY-----
"""

private let testPrivateKey = """
-----BEGIN RSA PRIVATE KEY-----
XXXXXXXXXXXXXXXXXXXXXXXXXX
-----END RSA PRIVATE KEY-----
"""

3. 使用公鑰加密數據

func encryptData(route: String, parameters: [String: String]) -> String {
    // 1. route 路徑轉換小寫,去除 "-" 值
    let lowercasedRoute = route
        .lowercased()
        .replacingOccurrences(of: "-", with: "")

    // 2. parameters 轉換小寫,去除 "-" 值
    let paramString = parameters
        .map { "\\($0.key)=\\($0.value)" }
        .joined(separator: "&")
        .lowercased()
        .replacingOccurrences(of: "-", with: "")
    
    // 組合起來準備加密
    let stringToEncrypt = "\\(lowercasedRoute)?\\(paramString)"
    do {
        // 拿出公鑰,將 pem 格式解成一般的 String
        let publicKey = try PublicKey(pemEncoded: testPublicKey)
        // ClearMessage 代表的是「尚未加密的明文」
        // 指定使用 UTF-8 編碼將字符串轉換為二進制數據
        let clear = try ClearMessage(string: stringToEncrypt, using: .utf8)
        // EncryptedMessage 代表的是 「加密後的數據」,與 ClearMessage 相反
        // padding 代表的是加密過程中的填充模式,解決明文長度不符合加密演算法的問題
        let encryptedMessage = try clear.encrypted(with: publicKey, padding: .PKCS1)
        return encryptedMessage.base64String
    } catch {
        return error.localizedDescription
    }
}

為什麼需要 Padding?

當使用 RSA 時,密文的長度是由公鑰的長度決定的(例如 2048 位元),但明文的長度不一定剛好符合這個長度限制。因此需要 Padding 調整明文的長度,以符合加密演算法的要求。

常見的 Padding 有 PKCS#1OAEP

PKCS#1 較普及,但較舊;OAEP 更為安全,但需確認後端是否支援。若前後端都支援,建議使用 OAEP。

4. 使用私鑰解密數據

通常我們不需要做這件事情(由後端解密),但可以解看看是不是跟加密前相同。

func decryptData(readyToDecodeString: String, environment: Environment) -> String {
    do {
        // 將 pem 格式解成一般的 String
        let privateKey = try PrivateKey(pemEncoded: testPrivateKey)
        // 「加密後的數據」被 base64 編碼過,因此需要先解開 base64 字串
        let encrypted = try EncryptedMessage(base64Encoded: readyToDecodeString)
        // 去除 padding,把「加密後的數據」變回「尚未加密的明文」
        let clear = try encrypted.decrypted(with: privateKey, padding: .PKCS1)
        // 將解密後的明文(clear)轉換為一個 UTF-8 的 String
        let string = try clear.string(encoding: .utf8)
        // 就可以看到加密前的 String 囉
        return string
    } catch {
        return error.localizedDescription
    }
}

這樣就大功告成囉~恭喜(%%%%)!


這篇文章其實就是我的筆記整理,拿到新的需求時,得到解決方法早已不是一件困難的事(AI 世代嘛),但在解決問題的過程中,陸續搞懂每一個名詞、不間斷地質疑並理解、最後內化成自己的知識,還是重要且有趣的!

我可真囉嗦!總之~有任何想法都可以聯繫我,下次見囉~~

參考資料:  
https://ithelp.ithome.com.tw/articles/10250721
https://ithelp.ithome.com.tw/articles/10251744
https://blog.csdn.net/weixin_44259720/article/details/110947742
https://www.ssldragon.com/zh/blog/rsa-aes-encryption/