이전 포스팅에서 종단간 암호화를 사용한 암호화 채팅의 개념에 대해서 설명했고, 오늘은 암호화 채팅의 프로세스를 코드를 보면서 설명하겠습니다.
암호화 채팅의 개념이 어렵기 때문에 꼭 이전 포스팅을 읽고 오는 것을 추천합니다.
이전 포스팅에서 그룹 채팅에서의 키 관리 방식의 여러 가지 방법에 대해 이야기했는데, 상대적으로 구현이 간단하면서도 실용적인 그룹 키를 생성하여 공유하는 방식을 사용하여 구현해 보도록 하겠습니다.
// 새로운 채팅방이라면 그룹 키 생성
if chatRoom.groupKey == nil {
chatRoom.groupKey = cryptoManager.generateGroupKey() // 그룹 키 생성
guard let groupKey = chatRoom.groupKey else {
print("❌ 그룹키 생성 실패")
return
}
// 그룹 키를 참가자들에게 공유
try await withThrowingTaskGroup(of: Void.self) { group in
for userID in chatRoom.participantsID {
group.addTask {
// 서버로 그룹 키 전송(❌ 그룹 키는 절대 평문으로 서버에 전송하면 안됨)
try await self.sendGroupKeyToServer(recipientID: userID, groupKey: groupKey, roomID: self.chatRoom.id)
}
group.addTask {
// 채팅 참가자 초대
try await self.inviteChatingRoom(invitationUserID: userID, roomID: self.chatRoom.id)
}
}
try await group.waitForAll()
}
}
guard let groupKey = chatRoom.groupKey else {
print("❌ 그룹키가 없습니다.")
return
}
새로운 채팅방을 생성하면 채팅방의 그룹 키를 생성하게 됩니다.
하지만 그룹 키는 메시지를 암호화/복호화하는 데 사용하고, 공격자가 그룹 키를 알게 되면 모든 메시지를 복호화 할 수 있기 때문에 ❌ 절대 평문으로 서버에 저장하면 안 됩니다.
따라서 상대방의 공개 키를 사용해서 암호화한 후 서버로 전송해야 합니다.
func sendGroupKeyToServer(recipientID: String, groupKey: SymmetricKey, roomID: String) async throws {
// 서버에서 상대방의 공개 키를 가져온다.(Data Type)
guard let recipientPublicKeyData = try await firestoreManager.loadPublicKeyData(userID: recipientID) else {
throw ErrorType.noPublicKeyData
}
// 나의 개인 키
guard let senderPrivateKey = keyChainManager.loadPrivateKey(keyLabel: keyChainKeyLabel) else {
throw ErrorType.noPrivateKey
}
guard let userData = authManager.loadCurrentUserData() else {
throw ErrorType.noUserData
}
// Data -> Curve25519 타입으로 변환
let recipientPublicKey = try cryptoManager.convertDataToPublicKey(data: recipientPublicKeyData)
// 그룹 키를 암호화
let encryptedGroupKeyData = try cryptoManager.encryptGroupKey(
groupKey: groupKey,
recipientPublicKey: recipientPublicKey,
senderPrivateKey: senderPrivateKey
)
let encryptedGroupKey = EncryptedGroupKey(senderID: userData.uid, key: encryptedGroupKeyData)
// 서버로 암호화된 그룹 키 전송
try firestoreManager.saveEncryptedGroupKey(roomID: roomID, recipientID: recipientID, encryptedGroupKey: encryptedGroupKey)
}
func sendChatData(roomID: String, chatMessage: ChatMessage, groupKey: SymmetricKey) throws {
// 암호화된 채팅 메시지 데이터
let sealBox = try cryptoManager.encryptChatMessage(chatMessage: chatMessage, symmetricKey: groupKey)
// 서버에 암호화된 채팅 전송
try firestoreManager.saveChatMessageData(roomID: roomID, messageID: chatMessage.id, sealBox: sealBox)
}
func decryptMessage(snapshot: QueryDocumentSnapshot, symmetricKey: SymmetricKey) -> ChatMessage? {
do {
let encryptedMessageData = try snapshot.data(as: EncryptedMessageData.self)
guard let encryptedData = Data(base64Encoded: encryptedMessageData.data),
let sealedBox = try? ChaChaPoly.SealedBox(combined: encryptedData) else {
print("❌ Base64 디코딩 또는 SealedBox 생성 실패")
return nil
}
// 서버에서 받은 채팅 데이터 복호화
return try CryptoManager.shared.decryptChatMessage(sealBox: sealedBox, symmetricKey: symmetricKey)
} catch {
print("❌ 복호화 실패: \(error)")
return nil
}
}
암호화 채팅에서 가장 중요한 암호와 관련된 코드를 보면서 설명하겠습니다.
/// 그룹키를 암호화하는 메서드
/// - Parameters:
/// - groupKey: 그룹키(실제 채팅에 쓰일 대칭키)
/// - recipientPublicKey: 수신자 공개키
/// - senderPrivateKey: 송신자 개인키
/// - Returns: 암호화된 그룹키 데이터
func encryptGroupKey(groupKey: SymmetricKey,
recipientPublicKey: Curve25519.KeyAgreement.PublicKey,
senderPrivateKey: Curve25519.KeyAgreement.PrivateKey) throws -> Data {
let sharedSecret = try senderPrivateKey.sharedSecretFromKeyAgreement(with: recipientPublicKey)
// 전송 과정에서만 쓰이는 1회성 세션 키
let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: "GroupKeySalt".data(using: .utf8)!,
sharedInfo: Data(),
outputByteCount: 32
)
let groupKeyData = groupKey.withUnsafeBytes { Data($0) }
let sealedBox = try ChaChaPoly.seal(groupKeyData, using: symmetricKey)
return sealedBox.combined
}
🧂 salt란?
임의의(난수) 바이트열로, 파생 과정의 초기화 벡터 같은 역할을 합니다.
예시 코드에서는 "GroupKeySalt"로 고정했지만 실제 사용에서는 재사용을 피하고 각 파생에 대해 랜덤하게 하는 것이 안전합니다.(반드시 비밀일 필요는 없지만 랜덤하게 만드는 것이 안전함)
주의해야 할 점은 동일한 sharedSecret+salt+info → 동일한 파생키가 나오므로, salt는 함께 저장/전송해야 수신자가 복호화 할 수 있습니다.
🎯 salt 노출 자체는 치명적이지 않은 이유
🔒 SealedBox란?
CryptoKit에서 암호화된 데이터(봉인된 데이터)를 담는 컨테이너 타입입니다.
(ChaChaPoly, AES.GCM 같은 알고리즘에서 암호화 결과를 담을 때 사용)
1. sharedSecret 생성
2. symmetricKey 파생
3. 그룹 키 암호화
4. 전송용 데이터(combined) 생성
/// 암호화된 그룹키를 복호화 하는 메서드
/// - Parameters:
/// - encryptedGroupKey: 암호화된 그룹키
/// - recipientPrivateKey: 수신자 개인키
/// - senderPublicKey: 송신자 공개키
/// - Returns: 그룹키
func decryptGroupKey(encryptedGroupKey: Data,
recipientPrivateKey: Curve25519.KeyAgreement.PrivateKey,
senderPublicKey: Curve25519.KeyAgreement.PublicKey) throws -> SymmetricKey {
let sharedSecret = try recipientPrivateKey.sharedSecretFromKeyAgreement(with: senderPublicKey)
let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: "GroupKeySalt".data(using: .utf8)!,
sharedInfo: Data(),
outputByteCount: 32
)
let sealedBox = try ChaChaPoly.SealedBox(combined: encryptedGroupKey)
let decryptedData = try ChaChaPoly.open(sealedBox, using: symmetricKey)
return SymmetricKey(data: decryptedData)
}
1. sharedSecret 생성
2. symmetricKey 파생
3. SealedBox 생성 및 복호화
/// 채팅 메시지를 암호화 하는 메서드
/// - Parameters:
/// - message: 채팅 메시지
/// - symmetricKey: 그룹키
/// - Returns: 암호화된 데이터 + 인증 태그 + Nonce(초기화 벡터)를 포함하는 구조체
func encryptChatMessage(chatMessage: ChatMessage, symmetricKey: SymmetricKey) throws -> ChaChaPoly.SealedBox {
let jsonData = try JSONEncoder().encode(chatMessage)
let encryptedData = try ChaChaPoly.seal(jsonData, using: symmetricKey).combined
return try ChaChaPoly.SealedBox(combined: encryptedData)
}
1. 메시지 직렬화
2. 그룹 키(대칭키)로 암호화
ChaChaPoly.seal을 통해 암호화).combined를 사용해 세 가지를 하나의 Data로 합쳐서 안전하게 전송할 수 있게 합니다.3. SealedBox로 래핑
/// 채팅 메시지를 복호화 하는 메서드
/// - Parameters:
/// - sealBox: 암호화된 데이터 + 인증 태그 + Nonce(초기화 벡터)를 포함하는 구조체
/// - symmetricKey: 그룹키
/// - Returns: 채팅 메시지
func decryptChatMessage(sealBox: ChaChaPoly.SealedBox, symmetricKey: SymmetricKey) throws -> ChatMessage? {
let decryptedData = try ChaChaPoly.open(sealBox, using: symmetricKey)
let chatMessage = try JSONDecoder().decode(ChatMessage.self, from: decryptedData)
return chatMessage
}
1. SealedBox 열기
ChaChaPoly.open을 사용해 암호문을 복호화합니다.2. 메시지 역직렬화
import CryptoKit
import Foundation
final class CryptoManager {
static let shared = CryptoManager()
private init() {}
}
extension CryptoManager {
/// 이메일을 SHA256 해시로 변환하는 메서드
/// - Parameter email: 해싱할 이메일 문자열
/// - Returns: SHA256 해시값 (소문자 16진수 문자열)
func hashEmail(email: String) -> String {
let data = Data(email.utf8)
let hashed = SHA256.hash(data: data)
return hashed.compactMap { String(format: "%02x", $0) }.joined()
}
/// Data -> Curve25519 타입으로 변환시키는 메서드
/// - Parameter data: 공개키 데이터
/// - Returns: 공개키
func convertDataToPublicKey(data: Data) throws -> Curve25519.KeyAgreement.PublicKey {
try Curve25519.KeyAgreement.PublicKey(rawRepresentation: data)
}
}
extension CryptoManager {
// func generateSalt(length: Int = 16) -> Data {
// var salt = Data(count: length)
// _ = salt.withUnsafeMutableBytes { buffer in
// SecRandomCopyBytes(kSecRandomDefault, length, buffer.baseAddress!)
// }
// return salt
// }
/// 그룹키를 생성하는 메서드
/// - Returns: 그룹키
func generateGroupKey() -> SymmetricKey {
return SymmetricKey(size: .bits256)
}
/// 개인키를 생성하는 메서드
/// - Returns: 개인키
/// - Warning: 반드시 키체인으로 저장, 서버에 절대 올리면 안되는 키
func generatePrivateKey() -> Curve25519.KeyAgreement.PrivateKey {
let privateKey = Curve25519.KeyAgreement.PrivateKey()
return privateKey
}
/// 공개키를 생성하는 메서드
/// - Parameter privateKey: 개인키
/// - Returns: 공개키
func generatePublicKey(privateKey: Curve25519.KeyAgreement.PrivateKey) -> Curve25519.KeyAgreement.PublicKey {
let publicKey = privateKey.publicKey
return publicKey
}
/// 대칭키를 생성하는 메서드
/// - Parameters:
/// - privateKey: 개인키
/// - publicKey: 공개키
/// - Returns: 대칭키
/// - Note: 대칭키는 그룹키로 사용
/// - Warning: 서버로 전송 시 암호화 필수
func generateSymmetricKey(privateKey: Curve25519.KeyAgreement.PrivateKey,
publicKey: Curve25519.KeyAgreement.PublicKey) throws -> SymmetricKey {
do {
let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey)
let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: "GroupKeySalt".data(using: .utf8)!,
sharedInfo: Data(),
outputByteCount: 32
)
return symmetricKey
} catch {
throw error
}
}
}
extension CryptoManager {
/// 그룹키를 암호화하는 메서드
/// - Parameters:
/// - groupKey: 그룹키(실제 채팅에 쓰일 대칭키)
/// - recipientPublicKey: 수신자 공개키
/// - senderPrivateKey: 송신자 개인키
/// - Returns: 암호화된 그룹키 데이터
func encryptGroupKey(groupKey: SymmetricKey,
recipientPublicKey: Curve25519.KeyAgreement.PublicKey,
senderPrivateKey: Curve25519.KeyAgreement.PrivateKey) throws -> Data {
let sharedSecret = try senderPrivateKey.sharedSecretFromKeyAgreement(with: recipientPublicKey)
//
let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: "GroupKeySalt".data(using: .utf8)!,
sharedInfo: Data(),
outputByteCount: 32
)
let groupKeyData = groupKey.withUnsafeBytes { Data($0) }
let sealedBox = try ChaChaPoly.seal(groupKeyData, using: symmetricKey)
return sealedBox.combined
}
/// 암호화된 그룹키를 복호화 하는 메서드
/// - Parameters:
/// - encryptedGroupKey: 암호화된 그룹키
/// - recipientPrivateKey: 수신자 개인키
/// - senderPublicKey: 송신자 공개키
/// - Returns: 그룹키
func decryptGroupKey(encryptedGroupKey: Data,
recipientPrivateKey: Curve25519.KeyAgreement.PrivateKey,
senderPublicKey: Curve25519.KeyAgreement.PublicKey) throws -> SymmetricKey {
let sharedSecret = try recipientPrivateKey.sharedSecretFromKeyAgreement(with: senderPublicKey)
let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: "GroupKeySalt".data(using: .utf8)!,
sharedInfo: Data(),
outputByteCount: 32
)
let sealedBox = try ChaChaPoly.SealedBox(combined: encryptedGroupKey)
let decryptedData = try ChaChaPoly.open(sealedBox, using: symmetricKey)
return SymmetricKey(data: decryptedData)
}
/// 채팅 메시지를 암호화 하는 메서드
/// - Parameters:
/// - message: 채팅 메시지
/// - symmetricKey: 그룹키
/// - Returns: 암호화된 데이터 + 인증 태그 + Nonce(초기화 벡터)를 포함하는 구조체
func encryptChatMessage(chatMessage: ChatMessage, symmetricKey: SymmetricKey) throws -> ChaChaPoly.SealedBox {
let jsonData = try JSONEncoder().encode(chatMessage)
let encryptedData = try ChaChaPoly.seal(jsonData, using: symmetricKey).combined
return try ChaChaPoly.SealedBox(combined: encryptedData)
}
/// 채팅 메시지를 복호화 하는 메서드
/// - Parameters:
/// - sealBox: 암호화된 데이터 + 인증 태그 + Nonce(초기화 벡터)를 포함하는 구조체
/// - symmetricKey: 그룹키
/// - Returns: 채팅 메시지
func decryptChatMessage(sealBox: ChaChaPoly.SealedBox, symmetricKey: SymmetricKey) throws -> ChatMessage? {
let decryptedData = try ChaChaPoly.open(sealBox, using: symmetricKey)
let chatMessage = try JSONDecoder().decode(ChatMessage.self, from: decryptedData)
return chatMessage
}
}