클라우드 Mac에서 iOS 암호화 기록의 변조와 절단 검증하기
오프라인 메모를 저장할 때 암호화하고 읽을 때 복호화하는 정상 흐름은 쉽게 검증할 수 있습니다. 정말 위험한 상황은 파일 일부가 잘렸거나 잘못된 키를 사용했는데도 앱이 오류를 무시한 채 빈 기록을 다시 저장하는 경우입니다. 클라우드 Mac에서 이 로직을 독립적인 Swift Package로 분리하면 UI에 연결하기 전에 실패 시 동작을 검증할 수 있습니다. 이 글에서는 암호화된 기록의 무결성만 확인하며, 키의 영구 저장이나 계정 체계는 다루지 않습니다.
먼저 기록 형식과 실패 처리 계약 정의하기
CryptoKit의 AES-GCM을 사용하며, 기록은 SealedBox.combined가 반환하는 바이트로 저장합니다. 기본 동작으로 생성된 nonce, 암호문, 인증 태그가 함께 저장되므로 읽을 때도 이를 온전히 복원해야 합니다. 태그를 임의로 잘라내지 말고, nonce를 비밀 정보로 취급하지도 마세요.
같은 키로 nonce를 재사용해서는 안 됩니다. 일반적인 호출에서는 AES.GCM.seal이 자동으로 생성하도록 두면 됩니다. 파일명, 현재 시각의 초 단위 값, 기록 순번으로 nonce를 만들지 마세요. 테스트에 사용하는 키는 테스트 안에서 임시로 생성하며, 저장소나 테스트 보고서에 남기지 않습니다.
복호화에 성공한 경우에만 내용을 비즈니스 로직 계층으로 전달해야 합니다. 인증 실패는 빈 내용과 같지 않으며, 원본 파일을 자동으로 덮어쓰는 동작으로 이어져서도 안 됩니다.
기록 ID 같은 외부 필드도 필요하다면 정규화된 방식으로 인코딩한 뒤 추가 인증 데이터로 사용할 수 있습니다. 암호화와 복호화에 동일한 바이트를 제공해야 합니다. 이 글에서는 먼저 외부 필드가 없는 최소한의 기록을 검증합니다.
실행 가능한 테스트 패키지 만들기
Xcode 도구 체인이 설치된 전용 물리 노드에서 일반 개발 사용자 계정으로 다음 명령을 실행합니다. 새 작업 공간을 사용하고 기존 프로젝트를 덮어쓰지 마세요. 먼저 swift --version이 정상적으로 결과를 반환하는지 확인한 다음 초기화합니다.
mkdir SecureRecord
cd SecureRecord
swift --version
swift package init --type library
생성된 Tests/SecureRecordTests/SecureRecordTests.swift를 다음 절의 내용으로 교체합니다. 소스 파일은 기본 구현을 그대로 두어도 됩니다. 이 테스트는 시스템의 CryptoKit을 직접 호출하므로 서드파티 의존성을 다운로드할 필요가 없습니다.
여기서는 새 테스트 프레임워크의 특정 문법에 예제가 의존하지 않도록 XCTest를 사용합니다. 실제 프로젝트에서는 보조 함수를 프로덕션 모듈로 옮기고 테스트에서 해당 모듈을 가져와야 합니다. 그래야 “테스트 구현은 올바르지만 앱 구현은 다른” 상황을 막을 수 있습니다.
정상 기록과 네 가지 예외 상황 검증하기
아래는 전체 테스트 파일입니다. 정상 경로에서 암호화와 복호화 후 바이트가 동일한지 먼저 확인한 다음, 태그 변조, 잘못된 키, 잘린 입력, 유효한 빈 내용을 각각 검사합니다. 각 테스트는 자체 키를 사용하며 변경 가능한 상태를 공유하지 않습니다.
import Foundation
import CryptoKit
import XCTest
final class SecureRecordTests: XCTestCase {
private func encode(
_ data: Data,
key: SymmetricKey
) throws -> Data {
let box = try AES.GCM.seal(data, using: key)
return try XCTUnwrap(box.combined)
}
private func decode(
_ record: Data,
key: SymmetricKey
) throws -> Data {
let box = try AES.GCM.SealedBox(combined: record)
return try AES.GCM.open(box, using: key)
}
func testRoundTripAndFreshNonce() throws {
let key = SymmetricKey(size: .bits256)
let input = Data("draft-v1".utf8)
let first = try encode(input, key: key)
let second = try encode(input, key: key)
XCTAssertEqual(try decode(first, key: key), input)
XCTAssertEqual(try decode(second, key: key), input)
XCTAssertNotEqual(first, second)
}
func testChangedTagIsRejected() throws {
let key = SymmetricKey(size: .bits256)
var record = try encode(Data("draft-v1".utf8), key: key)
let last = record.index(before: record.endIndex)
record[last] ^= 0x01
XCTAssertThrowsError(try decode(record, key: key))
}
func testWrongKeyIsRejected() throws {
let key = SymmetricKey(size: .bits256)
let otherKey = SymmetricKey(size: .bits256)
let record = try encode(Data("draft-v1".utf8), key: key)
XCTAssertThrowsError(try decode(record, key: otherKey))
}
func testTruncatedRecordsAreRejected() throws {
let key = SymmetricKey(size: .bits256)
let record = try encode(Data("draft-v1".utf8), key: key)
for length in [0, 1, record.count - 1] {
let truncated = Data(record.prefix(length))
XCTAssertThrowsError(try decode(truncated, key: key))
}
}
func testEmptyPlaintextIsValid() throws {
let key = SymmetricKey(size: .bits256)
let record = try encode(Data(), key: key)
XCTAssertFalse(record.isEmpty)
XCTAssertEqual(try decode(record, key: key), Data())
}
}
swift test를 실행해 다섯 개 테스트가 모두 통과하는지 확인합니다. 빈 평문도 비어 있지 않은 기록을 생성합니다. 저장 형식에 nonce와 태그도 포함되기 때문입니다. “파일 길이가 0보다 크다”는 검사로 인증된 복호화를 대신하지 마세요.
두 암호문이 서로 다르다는 단언은 실수로 nonce를 고정하는 구현상의 회귀를 발견하기 위한 것이지, 난수의 품질을 증명하는 것은 아닙니다. 전체 암호문의 스냅샷도 비교하지 않습니다. 그렇게 하면 정상적인 무작위성 때문에 오히려 테스트가 실패하게 됩니다.
알고리즘의 실패를 앱 동작에 연결하기
인증 실패 테스트가 통과했다는 것은 하위 계층이 비정상적인 바이트를 거부했다는 뜻일 뿐입니다. 호출하는 쪽에도 검증 기준을 추가해야 합니다. 읽기에 실패하면 복구 가능한 오류를 표시하고, 원본 파일을 보존하며, 백그라운드에서 빈 모델을 자동 저장하지 못하게 해야 합니다. 이전 버전의 사본이 있다면 명시적인 복구 절차를 통해 선택해야 하며, 사용자 모르게 전환해서는 안 됩니다.
형식과 비즈니스 데이터는 따로 검증하기
온전한 기록을 성공적으로 복호화하더라도 비즈니스 데이터의 디코딩은 실패할 수 있습니다. 형식 버전 검사, 인증된 복호화, 내용 파싱을 세 단계로 나누고, 각 단계에서 구분 가능한 내부 오류를 반환하도록 합니다. 사용자에게는 짧은 안내만 표시하면 됩니다. 로그에는 단계와 오류 유형만 기록하고 평문이나 키를 출력하지 않습니다.
입력 크기도 제한해야 합니다. 파일 길이를 먼저 확인한 뒤 불러오고 복호화하면 비정상적으로 큰 파일이 메모리를 소진하는 일을 막을 수 있습니다. 상한은 서비스에서 허용하는 기록 용량을 기준으로 정하고, 임의의 숫자를 그대로 가져다 쓰지 마세요. 새 형식 버전을 추가할 때는 유효한 이전 버전 샘플과 지원하지 않는 버전임이 명확한 샘플을 함께 보관합니다.
다시 실행할 수 있는 검증 자료 남기기
테스트 파일, 형식 설명, 실행 명령을 함께 제출합니다. 실행할 때마다 커밋 식별자, Swift 버전, 테스트 개수, 종료 상태를 기록하되 실제 사용자 기록은 업로드하지 않습니다. 개발 단계에서는 바이트별 변조 테스트를 추가할 수 있지만, 무작위 변이 테스트가 실패하면 변이 위치와 입력 길이를 반드시 남겨야 합니다.
마지막으로 iOS 통합 검사를 한 번 더 수행합니다. 앱이 실제로 저장한 기록을 같은 디코딩 함수에 전달하고, 손상된 파일을 읽더라도 원래 내용을 덮어쓰지 않는지 확인합니다. 클라우드 Mac은 공통 암호화 코어의 빠른 테스트를 실행하기에 적합합니다. 다만 파일 접근과 UI 복구는 앱 타깃에서 별도로 검증해야 합니다. 마무리 기준은 한 번의 암호화·복호화 성공이 아니라 “비정상 입력이 거부되고, 원본 데이터가 남아 있으며, 사용자가 복구할 수 있는가”여야 합니다.
자주 묻는 질문
이 테스트에 iOS 시뮬레이터가 필요한가요?
순수 CryptoKit 로직은 클라우드 Mac에서 swift test로 검증할 수 있습니다. 파일 읽기, 오류 표시와 앱 통합은 iOS 대상에서 추가로 확인해야 합니다.
인증 실패 시 빈 데이터를 반환해도 되나요?
성공 결과로 반환하면 안 됩니다. 원본 기록을 보존하고 명시적인 오류를 전달해야 정상적인 빈 데이터와 손상된 기록을 구분할 수 있습니다.
다음 빌드를 위한 클라우드 Mac 예약하기
원하는 모델과 도시, 대여 기간을 선택하고 전용 물리 노드에서 개발 도구를 실행하세요. 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.