[iOS] E2EE(종단간 암호화)를 사용한 암호화 채팅

z-wook·2025년 9월 14일
1
post-thumbnail

Preview

이전 포스팅에서 종단간 암호화를 사용한 암호화 채팅의 개념에 대해서 설명했고, 오늘은 암호화 채팅의 프로세스를 코드를 보면서 설명하겠습니다.

암호화 채팅의 개념이 어렵기 때문에 이전 포스팅을 읽고 오는 것을 추천합니다.


암호화 채팅 프로세스

이전 포스팅에서 그룹 채팅에서의 키 관리 방식의 여러 가지 방법에 대해 이야기했는데, 상대적으로 구현이 간단하면서도 실용적인 그룹 키를 생성하여 공유하는 방식을 사용하여 구현해 보도록 하겠습니다.

1. 그룹 키 생성

  • 그룹 생성 시, 방 고유의 그룹 키(대칭키)를 생성합니다.
  • 그룹 키는 모든 메시지를 암호화하는 데 사용됩니다.
// 새로운 채팅방이라면 그룹 키 생성
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
}

새로운 채팅방을 생성하면 채팅방의 그룹 키를 생성하게 됩니다.
하지만 그룹 키는 메시지를 암호화/복호화하는 데 사용하고, 공격자가 그룹 키를 알게 되면 모든 메시지를 복호화 할 수 있기 때문에 ❌ 절대 평문으로 서버에 저장하면 안 됩니다.

따라서 상대방의 공개 키를 사용해서 암호화한 후 서버로 전송해야 합니다.

2. 그룹 키 배포

  • 그룹 참여자 각각의 공개 키를 사용해 그룹 키를 암호화하여 서버로 전송합니다.
 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)
}

3. 메시지 암호화

  • 메시지를 그룹 키로 암호화하여 모든 사용자에게 전송합니다.
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)
}

4. 메시지 복호화

  • 사용자는 자신의 클라이언트에서 그룹 키로 메시지를 복호화합니다.
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 노출 자체는 치명적이지 않은 이유

  • 공격자가 아는 것: salt
  • 공격자가 모르는 것: sharedSecret

    sharedSecret을 만들려면 수신자 개인키나 송신자 개인키가 필요하다.
    공개키만 가지고는 sharedSecret을 계산할 수 없다.
    즉, salt만으로는 ChaChaPoly가 요구하는 대칭키를 만들 수 없어서 복호화를 할 수 없다.

🔒 SealedBox란?
CryptoKit에서 암호화된 데이터(봉인된 데이터)를 담는 컨테이너 타입입니다.
(ChaChaPoly, AES.GCM 같은 알고리즘에서 암호화 결과를 담을 때 사용)

그룹 키 암호화 프로세스

1. sharedSecret 생성

  • 송신자의 개인키와 수신자의 공개키를 사용해 ECDH 키 교환으로 공유 비밀값(sharedSecret)을 만듭니다.
  • 이 값은 송신자와 수신자만 알고 있어, 안전하게 대칭키를 만들 수 있습니다.

2. symmetricKey 파생

  • sharedSecret과 salt를 HKDF에 넣어, 그룹키를 암호화할 대칭키(symmetricKey)를 생성합니다.
  • salt는 재사용하지 않고 랜덤으로 생성하거나, 프로토콜 상 고정값을 사용할 수 있습니다.

3. 그룹 키 암호화

  • 실제로 보호하려는 그룹키(groupKey)를 symmetricKey로 ChaChaPoly 방식으로 암호화합니다.
  • 이때 생성되는 SealedBox에는 nonce, 암호문(ciphertext), 인증 태그(tag)가 포함됩니다.

4. 전송용 데이터(combined) 생성

  • SealedBox의 모든 정보를 하나로 합친 combined 데이터를 만들어 전송합니다.
  • 수신자는 combined 데이터를 사용해 동일한 symmetricKey로 그룹키를 복호화할 수 있습니다.

그룹 키 복호화

/// 암호화된 그룹키를 복호화 하는 메서드
/// - 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 생성

  • 수신자의 개인키와 송신자의 공개키를 이용해 ECDH 키 교환을 통해 공유 비밀값(sharedSecret)을 계산합니다.
  • 이 값은 송신자와 수신자만 알고 있는 값입니다.

2. symmetricKey 파생

  • sharedSecret과 salt를 HKDF에 넣어, 암호문 복호화에 사용할 대칭키(symmetricKey)를 생성합니다.

3. SealedBox 생성 및 복호화

  • 암호화된 그룹키(encryptedGroupKey)를 SealedBox 객체로 만들고,
  • 파생한 symmetricKey를 사용해 원래의 그룹키를 복원합니다.

메시지 암호화

/// 채팅 메시지를 암호화 하는 메서드
/// - 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. 메시지 직렬화

  • ChatMessage 구조체를 JSONEncoder로 변환하여 Data 형태로 만듭니다.
    (ChaChaPoly는 Data 타입만 암호화 가능)

2. 그룹 키(대칭키)로 암호화

  • 이전 단계에서 공유한 그룹키(대칭키)를 symmetricKey로 사용합니다.
    (ChaChaPoly.seal을 통해 암호화)
  • 내부적으로 nonce, ciphertext, 인증 태그(tag) 생성
    .combined를 사용해 세 가지를 하나의 Data로 합쳐서 안전하게 전송할 수 있게 합니다.

3. SealedBox로 래핑

  • 암호화된 데이터를 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. 메시지 역직렬화

  • 복호화된 Data를 JSONDecoder로 원래의 ChatMessage 구조체로 변환합니다.

전체 코드

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
    }
}
profile
🍎 iOS Developer

0개의 댓글