클라우드 Mac에서 iOS 보안 아카이브 디코딩 검증하기

클라우드 Mac에서 iOS 보안 아카이브 디코딩 검증하기

이전 버전 앱이 남긴 아카이브를 새 버전에서 읽을 때 가장 위험한 결과는 오류가 발생하는 것이 아닙니다. 빈 데이터를 반환하고 그대로 저장을 이어 가 원본 파일을 덮어쓰는 것입니다. 클라우드 Mac에서 이 읽기 경로를 검증해 보겠습니다. 먼저 Foundation만 사용하는 디코딩 테스트를 실행한 다음, iOS 테스트 타깃에서 같은 샘플을 읽습니다. 핵심은 저장 후 다시 읽기에 한 번 성공하는 것이 아니라, 잘못된 타입과 알 수 없는 버전, 손상된 입력을 모두 명확히 거부하는지 확인하는 데 있습니다.

먼저 읽기 경계를 정의하기

이 글은 NSKeyedArchiver로 생성한 객체 아카이브를 다루며, 암호화는 다루지 않습니다. 예제에서는 버전 번호와 텍스트 필드를 담은 루트 객체 하나만 허용합니다. 검증에 앞서 다음 세 가지 규칙을 확정합니다.

supportsSecureCoding은 타입의 지원 여부를 선언할 뿐, 비즈니스 필드를 자동으로 검증하지 않습니다. 읽기 측에서도 requiresSecureCoding을 활성화해야 하며, 각 객체 필드는 허용 타입을 지정하는 메서드로 디코딩해야 합니다.

보안 디코딩은 인스턴스화할 수 있는 객체 타입을 제한합니다. 출처 인증이나 기밀성, 파일 크기 제한을 제공하지는 않습니다. 디코더에 전달하기 전에 입력 크기부터 제한하세요.

잘못된 입력을 거부하는 디코더 작성하기

다음 내용을 archive-check.swift로 저장합니다. Foundation에만 의존하는 코드입니다. 먼저 xcrun swift --version이 실행되는지 확인하세요. 예제의 버전 번호는 운영체제 버전이 아니라 아카이브 프로토콜 버전입니다.

import Foundation

final class Memo: NSObject, NSSecureCoding {
    static var supportsSecureCoding: Bool { true }
    let schema: Int
    let text: String

    init(schema: Int = 1, text: String) {
        self.schema = schema
        self.text = text
        super.init()
    }

    required init?(coder: NSCoder) {
        guard coder.containsValue(forKey: "schema"),
              coder.decodeInteger(forKey: "schema") == 1,
              let value = coder.decodeObject(
                  of: NSString.self, forKey: "text"
              ),
              value.length <= 4096 else {
            return nil
        }
        schema = 1
        text = value as String
        super.init()
    }

    func encode(with coder: NSCoder) {
        coder.encode(schema, forKey: "schema")
        coder.encode(text as NSString, forKey: "text")
    }
}

enum ArchiveFailure: Error {
    case invalidSize
    case invalidObject
}

func readMemo(_ data: Data) throws -> Memo {
    guard !data.isEmpty, data.count <= 1_048_576 else {
        throw ArchiveFailure.invalidSize
    }
    let reader = try NSKeyedUnarchiver(forReadingFrom: data)
    reader.requiresSecureCoding = true
    reader.decodingFailurePolicy = .setErrorAndReturn
    let memo = reader.decodeObject(
        of: Memo.self, forKey: NSKeyedArchiveRootObjectKey
    )
    reader.finishDecoding()
    if let error = reader.error {
        throw error
    }
    guard let memo else {
        throw ArchiveFailure.invalidObject
    }
    return memo
}

func pack(_ value: Any) throws -> Data {
    try NSKeyedArchiver.archivedData(
        withRootObject: value, requiringSecureCoding: true
    )
}

여기서는 버전 필드의 존재 여부를 먼저 확인합니다. 정수 디코딩의 기본값 때문에 키 누락이 가려지는 것을 막기 위해서입니다. 텍스트 길이 제한은 NSString.length와 동일하게 UTF-16 코드 단위로 계산합니다. 실제 구현에서는 제품의 데이터 규약에 맞게 상한을 정해야 합니다. 예제의 임계값을 범용 보안 기준으로 그대로 사용하지 마세요.

setErrorAndReturn은 객체를 읽기 전에 설정해야 합니다. 바깥쪽 do/catch가 모든 디코딩 실패를 처리해 줄 것이라고 가정해서는 안 됩니다. 일부 유효하지 않은 객체의 처리는 디코딩 실패 정책에 따라 달라지며, 일반적인 Swift 오류 던지기 경로를 따르지 않습니다.

네 가지 입력으로 최소 회귀 테스트 구성하기

아래 코드를 이어서 추가합니다. 정상 샘플에서는 원래 텍스트를 그대로 읽어야 하고, 나머지 세 샘플은 반드시 실패해야 합니다. 검증 프로그램은 예상과 다른 결과가 하나라도 나오면 0이 아닌 종료 코드로 종료합니다.

func expectRejection(_ data: Data) {
    do {
        _ = try readMemo(data)
    } catch {
        return
    }
    fatalError("Unexpected archive acceptance")
}

let valid = try pack(Memo(text: "draft"))
let restored = try readMemo(valid)
guard restored.text == "draft" else {
    fatalError("Round-trip mismatch")
}

expectRejection(try pack("wrong root" as NSString))
expectRejection(try pack(Memo(schema: 2, text: "future")))
expectRejection(Data(valid.prefix(12)))

실행할 때는 먼저 컴파일한 다음 바이너리를 실행합니다. 인터프리터 실행 환경이 이후 샘플의 타입 이름에 영향을 주지 않도록 하기 위해서입니다.

set -eu
xcrun swiftc archive-check.swift -o archive-check
./archive-check

오류 샘플을 처리하면서 Foundation이 표준 오류로 진단 메시지를 출력할 수 있지만, 이것만으로 검증 프로그램이 실패한 것은 아닙니다. 판단 기준은 로그가 완전히 비어 있는지가 아니라 프로세스 종료 코드와 단언문입니다. 잘못된 루트 객체가 허용된다면 먼저 허용 목록을 수정해야 합니다. 실패해야 할 테스트 케이스를 삭제해 테스트를 통과시켜서는 안 됩니다.

중첩 객체와 이전 버전 샘플로 확장하기

필드별로 허용 타입 제한하기

실제 아카이브에 배열이 들어 있다면 NSArray만 허용했다고 해서 배열 요소까지 안전하다고 볼 수는 없습니다. decodeObject(of:forKey:)의 클래스 집합을 받는 오버로드를 사용해 컨테이너와 예상 요소 클래스를 함께 허용 목록에 넣고, 요소 수와 필드 길이, 객체 간 관계를 추가로 검사해야 합니다. 실패를 우회하려고 범위가 넓은 NSObject를 허용 목록에 추가하지 마세요.

아카이브의 타입 제약은 객체 그래프의 복잡도 제어를 대신할 수 없습니다. 입력 크기를 제한해도 디코딩 후의 메모리 사용량까지 보장되지는 않습니다. 신뢰할 수 없는 입력에는 별도로 제약된 프로토콜이나 격리된 처리 경계를 적용해야 합니다.

이전 버전의 실제 파일을 고정 테스트 자료로 보관하기

민감 정보를 제거한 이전 버전 아카이브를 테스트 리소스로 커밋하고, 파일을 생성한 앱 버전과 예상 필드, 허용되는 마이그레이션 결과를 기록합니다. 실행 중에 생성하는 샘플은 거부 로직을 검증하는 데 적합하지만, 이전 버전이 실제로 기록한 바이트를 여전히 읽을 수 있다는 증거는 되지 못합니다.

Swift 클래스의 런타임 이름은 모듈에 따라 달라질 수 있습니다. 타입을 옮기거나 모듈 이름을 바꿀 때는 먼저 이전 버전 샘플로 검증한 뒤, 명확히 지정한 이전 이름에 대해 클래스 매핑을 설정해야 합니다. 입력에서 임의의 클래스 이름을 읽어 자동으로 허용해서는 안 됩니다. 명령줄 테스트를 통과했더라도 동일한 파일 세트를 iOS 테스트 타깃에서 실행해 대상 런타임의 차이까지 검증해야 합니다.

읽기 실패가 저장 단계로 번지지 않게 차단하기

디코딩 결과는 성공 또는 명시적인 오류로 표현하고, try? 뒤에 빈 객체를 기본값으로 제공하는 패턴은 피하세요. 읽기에 실패하면 원본 파일을 보존하고 화면에 복구 방법을 안내합니다. 마이그레이션이 끝나고 새 데이터를 다시 읽어 검증한 뒤에만 기존 파일을 교체할 수 있도록 해야 합니다.

병합 전에는 다섯 가지를 확인합니다. 정상 샘플의 필드가 일치하는지, 잘못된 루트 타입을 거부하는지, 알 수 없는 버전을 거부하는지, 잘린 입력이 성공으로 처리되어 파일에 다시 기록되지 않는지, 이전 버전 샘플이 iOS 타깃에서 통과하는지 확인하세요. 각 거부 사례는 독립된 테스트로 분리해, 실패 시 어떤 규칙이 지켜지지 않았는지 바로 파악할 수 있게 합니다.

클라우드 Mac의 명령줄 프로그램은 빠른 피드백을 제공하고, iOS 테스트는 대상 플랫폼에서 동작을 확인합니다. 두 계층을 모두 통과해야 아카이브 읽기 경로에 반복 가능한 검증 근거가 있다고 할 수 있습니다. 개발용 Mac에서 한 번 성공했다는 것만으로는 충분하지 않습니다.

자주 묻는 질문

supportsSecureCoding만 구현하면 충분한가요?

아닙니다. 읽을 때 requiresSecureCoding을 활성화하고 클래스가 제한된 decodeObject 메서드를 사용해야 합니다. 버전과 필드 값도 별도로 검증하세요.

디코딩 실패 시 빈 목록을 반환해도 되나요?

실패를 정상적인 빈 결과로 바꾸지 마세요. 명시적인 오류를 반환하고 원본 파일을 보존해야 다음 저장에서 기존 데이터가 덮어써지는 일을 막을 수 있습니다.

다음 빌드를 위한 클라우드 Mac을 준비하세요

가상 머신이 아닌 전용 물리 Mac mini입니다. 일·주·월·분기 단위로 대여 기간을 선택할 수 있으며, 실제 이용 가능 여부와 구성은 콘솔에서 확인하세요.