클라우드 Mac에서 iOS Merkle 증명과 블록 무결성 검증

클라우드 Mac에서 iOS Merkle 증명과 블록 무결성 검증

iOS 오프라인 리소스를 블록 단위로 다운로드할 때 각 블록의 검증을 통과했더라도, 조립한 결과가 잘못된 버전일 수 있습니다. 클라이언트가 다른 매니페스트에 속한 블록을 받아들였거나, 생성 측과 검증 측이 홀수 개의 노드를 처리하는 규칙을 서로 다르게 적용했기 때문입니다. 클라우드 Mac의 고정된 Swift 환경에 검증기를 두고 작은 트리로 프로토콜을 먼저 검증한 뒤 실제 다운로드 흐름에 연결하면, 다운로드가 끝난 후 전체 해시를 한 번만 비교하는 방식보다 문제의 원인을 찾기 쉽습니다.

트리 규칙과 신뢰할 입력 확정하기

이 글에서는 SHA-256을 사용합니다. 리프는 H(0x00 || 원본 블록 바이트), 내부 노드는 H(0x01 || 왼쪽 해시 || 오른쪽 해시)로 계산합니다. 접두 바이트는 두 입력 유형을 구분합니다. 해시는 16진수 문자열이 아니라 원래의 32바이트 값으로 이어 붙입니다.

각 계층에 짝이 없는 노드가 하나 남으면 자기 자신을 복제하지 않고 그대로 다음 계층으로 올립니다. 증명은 리프에서 루트 방향으로 나열하며, 실제로 존재하는 형제 노드만 포함합니다. 왼쪽과 오른쪽의 방향은 리프 인덱스와 현재 계층의 너비로 결정합니다.

매니페스트에는 리소스 버전, 블록 길이, 전체 바이트 수, 리프 수, 루트를 함께 묶어 명시해야 합니다. 마지막 블록은 표준 블록 길이보다 짧을 수 있습니다. 빈 리소스의 처리 규칙은 별도로 정의해야 하며, 검증기에서 임의로 추정해서는 안 됩니다.

Merkle 증명은 “이 블록이 주어진 루트에 속하는가”에 답할 뿐, “이 루트를 누가 배포했는가”에는 답하지 않습니다. 루트와 리프 수는 신뢰할 수 있는 채널이나 서명 검증을 마친 매니페스트에서 가져와야 합니다.

엄격한 증명 검증기 구현하기

클라우드 Mac에서 아래 내용을 MerkleCheck.swift로 저장합니다. 먼저 xcrun swift --version을 실행해 툴체인 버전을 기록한 다음, xcrun swift MerkleCheck.swift를 실행합니다. 코드는 시스템 프레임워크만 사용하므로 서드 파티 패키지를 다운로드할 필요가 없습니다.

import Foundation
import CryptoKit

func leaf(_ bytes: Data) -> Data {
    Data(SHA256.hash(data: Data([0]) + bytes))
}

func parent(_ left: Data, _ right: Data) -> Data {
    Data(SHA256.hash(data: Data([1]) + left + right))
}

func verify(
    chunk: Data,
    index: Int,
    count: Int,
    siblings: [Data],
    root: Data
) -> Bool {
    guard count > 0, index >= 0, index < count,
          root.count == 32,
          siblings.allSatisfy({ $0.count == 32 })
    else { return false }

    var value = leaf(chunk)
    var position = index
    var width = count
    var consumed = 0

    while width > 1 {
        let hasSibling = position % 2 == 1 || position < width - 1
        if hasSibling {
            guard consumed < siblings.count else { return false }
            let sibling = siblings[consumed]
            value = position % 2 == 0
                ? parent(value, sibling)
                : parent(sibling, value)
            consumed += 1
        }
        position /= 2
        width = width / 2 + width % 2
    }

    return consumed == siblings.count && value == root
}

func require(_ condition: Bool, _ name: String) {
    guard condition else {
        fatalError("Check failed: \(name)")
    }
}

let a = Data("A".utf8)
let b = Data("B".utf8)
let c = Data("C".utf8)
let ab = parent(leaf(a), leaf(b))
let root = parent(ab, leaf(c))

require(verify(chunk: c, index: 2, count: 3,
               siblings: [ab], root: root), "odd promotion")
require(verify(chunk: a, index: 0, count: 3,
               siblings: [leaf(b), leaf(c)], root: root), "left branch")
require(!verify(chunk: Data("X".utf8), index: 2, count: 3,
                siblings: [ab], root: root), "tampered chunk")
require(!verify(chunk: c, index: 2, count: 3,
                siblings: [ab, ab], root: root), "excess proof")
require(!verify(chunk: c, index: 3, count: 3,
                siblings: [ab], root: root), "invalid index")

성공하면 스크립트는 아무 출력 없이 종료하고, 실패하면 0이 아닌 종료 코드를 반환합니다. 실행 상태를 기록하면 됩니다. 특정 성공 문구를 검색하는 방식으로 종료 코드 확인을 대신하지 마세요.

작은 트리로 형태별 경계 조건 점검하기

리프가 세 개인 트리에서는 세 번째 리프가 첫 번째 계층에서 그대로 다음 계층으로 올라가므로 형제 해시가 하나만 필요합니다. 생성 측이 마지막 노드를 복제하면 다른 루트가 나옵니다. 이는 네트워크 오류가 아니며, 재시도로 해결할 문제도 아닙니다.

스크립트를 테스트 타깃으로 옮긴 뒤에는 최소한 다음 검사를 추가합니다.

예제는 같은 해시 함수로 루트를 구성하므로 제어 흐름만 검증할 수 있습니다. 생성기와 검증기가 같은 오류를 공유하는 상황을 막으려면, 독립적인 구현으로 고정 테스트 벡터를 생성하고 블록 바이트, 루트, 증명의 16진수 값을 저장소에 함께 커밋해야 합니다. 서로 다른 위치의 내용이 완전히 같다면 블록 바이트만으로는 어느 위치의 블록인지 구분할 수 없습니다. 위치 바인딩이 필요하다면 프로토콜에 인덱스를 포함하고 정수 인코딩 방식도 명확히 정의해야 합니다.

쓰기 전에 검증하기

다운로드 계층은 먼저 매니페스트에 명시된 블록 길이와 인덱스를 확인한 다음 증명을 검증합니다. 통과한 블록만 해당 리소스 버전의 임시 저장 디렉터리에 기록합니다. 모든 블록이 모이면 전체 길이를 확인하고 완료 마커를 생성합니다. UI가 “파일이 존재함”을 “리소스를 사용할 수 있음”으로 간주해서는 안 됩니다.

증명은 신뢰할 수 없는 입력입니다. 파싱하기 전에 응답 본문 크기, 노드 수, 해시 길이를 제한해야 합니다. 유효한 증명의 최대 크기는 트리의 계층 수에 따라 증가하므로 길이 제한이 없는 배열을 받아서는 안 됩니다. 원본 블록 크기도 제한해, 검증을 시작하기도 전에 블록 전체를 메모리에 올리다가 메모리가 고갈되지 않도록 해야 합니다.

재시도할 때는 같은 매니페스트 스냅샷을 계속 사용해야 합니다. 다운로드 중 새 버전을 발견했다면 기존 작업을 종료하거나 기존 버전의 다운로드를 계속해야 합니다. 새 루트로 기존 블록을 검증해서는 안 됩니다. 검수 로그에는 리소스 버전, 블록 인덱스, 실패 유형만 기록하면 충분하며, 블록 내용이나 접근 자격 증명은 출력하지 않습니다.

원인을 설명할 수 있는 회귀 테스트 결과 만들기

고정된 툴체인에서 정상 벡터와 악의적으로 구성한 벡터를 각각 실행합니다. 정상 입력이 거부된다면 홀수 개 노드가 있는 계층의 처리 규칙, 증명 순서, 바이트 인코딩부터 확인합니다. 잘못된 입력이 허용된다면 인덱스 검사, 사용하고 남은 증명 노드 검사, 루트 출처의 신뢰성 확인이 빠지지 않았는지 먼저 살펴봅니다.

마지막으로 종단 간 장애 주입 테스트를 수행합니다. 임시 저장된 블록의 바이트 하나를 바꾸고, 해당 블록이 거부되며 완료 마커가 생성되지 않는지 확인합니다. 이어서 올바른 블록으로 복원한 뒤 작업을 계속할 수 있는지 확인합니다. 합격 기준은 “해시 코드가 끝까지 실행됨”이 아니라, 증명 검증을 통과하지 못한 데이터가 읽기 가능한 리소스 집합에 절대 들어가지 못하는 것입니다.

자주 묻는 질문

Merkle 루트를 리소스와 함께 다운로드해도 되나요?

가능하지만 루트와 리프 개수는 신뢰할 수 있는 채널이나 서명이 검증된 매니페스트로 인증해야 합니다. 데이터와 루트를 함께 바꿀 수 있다면 증명이 통과할 수 있습니다.

홀수 계층의 마지막 노드는 복제해야 하나요?

이 프로토콜은 해당 노드를 그대로 상위 계층으로 올리며 형제 해시를 소비하지 않습니다. 자기 복제는 별도 규칙이므로 생성 측과 검증 측에서 혼용하면 안 됩니다.

빌드 머신을 데이터센터에 맡기세요

전용 Mac mini를 대여해 Xcode 빌드와 macOS CI를 연중 가동되는 머신에서 실행하세요. 작업에 맞는 사양과 팀 위치에 맞는 리전을 선택할 수 있습니다.

클라우드 Mac 선택

빌드 머신을 데이터센터에 맡기세요

전용 Mac mini로 365일 내내 안정적으로 운영하세요.