클라우드 Mac에서 검증하는 iOS Ed25519 서명과 메시지 바이트 계약

클라우드 Mac에서 검증하는 iOS Ed25519 서명과 메시지 바이트 계약

지역이 다른 팀 사이에서 서명 설정을 인계하다 보면 “서버에서는 서명에 성공하는데 iOS에서는 검증에 실패하는” 상황을 자주 만납니다. 양쪽에서 보여 주는 JSON은 같고, 키를 다시 생성해도 문제가 해결되지 않습니다. 암호 API 호출, 전송 데이터 디코딩, 메시지 재구성 중 어디에 문제가 있는지 구분하려면 먼저 클라우드 Mac에서 서비스 API에 의존하지 않는 검증 기준을 세우고 원본 바이트를 대조해야 합니다. 아래에서는 Swift와 CryptoKit을 사용하며, 공개 테스트 데이터만 입력하므로 운영용 개인 키는 필요하지 않습니다.

알고리즘과 입력 계약부터 확정하기

알고리즘은 일반 Ed25519로 정하고, 공개 키는 32바이트 원시 표현, 서명은 64바이트 원시 표현을 사용합니다. CryptoKit에서는 Curve25519.Signing.PublicKey가 이에 해당합니다. 이름이 비슷하다는 이유로 키 합의용 타입으로 바꾸면 안 됩니다.

서명하는 쪽에서는 응답 본문의 원본 바이트에 서명하는지, 특정 정규화 결과에 서명하는지 명확히 밝혀야 합니다. 원본 본문을 사용한다면 클라이언트는 먼저 서명을 검증한 뒤 파싱해야 합니다. JSON을 파싱하고 다시 인코딩한 결과로 검증해서는 안 됩니다. 양쪽이 정규화를 사용하기로 했다면 규칙의 버전과 테스트 샘플까지 확정해야 합니다.

Ed25519 서명은 메시지가 신뢰할 수 있는 공개 키에 부합함을 증명할 뿐, 기밀성을 보장하거나 재전송 공격을 자동으로 막아 주지는 않습니다. 공개 키를 변조 가능한 동일 응답에서 가져온다면 서명 검증에 성공해도 그것만으로 신뢰를 확보할 수 없습니다.

일반 Ed25519와 Ed25519ph도 구분해야 합니다. 메시지의 해시를 먼저 구한 뒤 일반 API에 전달하는 것은 사전 해시 변형을 사용하는 것과 같지 않습니다. 프로토콜에 “둘 다 Ed25519”라고만 적고 이런 조건을 생략해서는 안 됩니다.

공개 표준 벡터로 최소 검증 실행하기

개발 도구가 설치된 클라우드 Mac에서 xcrun swift --version을 실행하고 도구 체인 정보와 sw_vers 출력을 기록합니다. 아래 내용을 verify.swift로 저장한 다음 xcrun swift verify.swift를 실행합니다. 코드는 RFC 8032의 빈 메시지 테스트 벡터를 사용합니다. 공개된 16진수 데이터이며 서비스에서 사용하는 키가 아닙니다.

import Foundation
import CryptoKit

enum FixtureError: Error {
    case invalidHex
    case checkFailed
}

func hex(_ text: String) throws -> Data {
    let chars = Array(text.utf8)
    guard chars.count % 2 == 0 else {
        throw FixtureError.invalidHex
    }
    var bytes = [UInt8]()
    for offset in stride(from: 0, to: chars.count, by: 2) {
        let pair = String(
            decoding: chars[offset..<(offset + 2)],
            as: UTF8.self
        )
        guard let value = UInt8(pair, radix: 16) else {
            throw FixtureError.invalidHex
        }
        bytes.append(value)
    }
    return Data(bytes)
}

func require(_ value: Bool) throws {
    guard value else {
        throw FixtureError.checkFailed
    }
}

let publicBytes = try hex(
    "d75a980182b10ab7d54bfed3c964073a0" +
    "ee172f3daa62325af021a68f707511a"
)
let signature = try hex(
    "e5564300c360ac729086e2cc806e828a8" +
    "4877f1eb8e5d974d873e0652249015555" +
    "5fb8821590a33bacc61e39701cf9b46bd" +
    "25bf5f0595bbe24655141438e7a100b"
)
try require(publicBytes.count == 32)
try require(signature.count == 64)

let key = try Curve25519.Signing.PublicKey(
    rawRepresentation: publicBytes
)
try require(key.isValidSignature(signature, for: Data()))
try require(!key.isValidSignature(
    signature, for: Data([0x00])
))

var altered = signature
altered[altered.startIndex] ^= 1
try require(!key.isValidSignature(altered, for: Data()))
try require(!key.isValidSignature(
    Data(signature.dropLast()), for: Data()
))

var rejectedKey = false
do {
    _ = try Curve25519.Signing.PublicKey(
        rawRepresentation: Data(publicBytes.dropLast())
    )
} catch {
    rejectedKey = true
}
try require(rejectedKey)

스크립트는 성공하면 아무것도 출력하지 않고, 실패하면 오류를 던진 뒤 0이 아닌 종료 코드로 끝납니다. 여기서는 최적화 컴파일 시 검사가 제거되지 않도록 assert 대신 오류를 던지는 검사를 사용합니다. 먼저 이 고정 데이터로 테스트를 통과하는지 확인한 다음 서비스 샘플을 연결해야 합니다. 그렇지 않으면 네트워크와 암호 API를 동시에 조사해야 해 변수의 범위가 커집니다.

텍스트 차이를 바이트 차이로 좁히기

서명 직전의 메시지 파일과 클라이언트가 실제로 받은 파일을 각각 보관합니다. 공개 테스트 픽스처라면 cmp로 비교한 뒤 xxd -g 1로 첫 번째 차이를 찾을 수 있습니다. 운영 환경의 본문에는 민감한 정보가 포함될 수 있으므로 공유 로그에 그대로 남겨서는 안 됩니다.

편집기에서 쉽게 바뀌는 요소부터 점검합니다. 끝에 줄바꿈이 추가되었는지, 줄바꿈이 LF인지 CRLF인지, 파일 앞부분에 UTF-8 BOM이 있는지, 문자에 유니코드 정규화가 적용되었는지 확인합니다. 눈으로 보기에 같은 악센트 문자도 바이트 시퀀스는 다를 수 있습니다.

파일을 만들 때 무심코 echo를 사용하지 마세요. 줄바꿈이 추가될 수 있습니다. 메시지는 파일의 원시 데이터로 읽고, 문자열로 바꿨다가 다시 데이터로 변환하지 않습니다. 프로토콜이 실제로 문자열에 서명하도록 되어 있다면 UTF-8을 명시하고, 끝의 줄바꿈, 빈 메시지, 결합 문자에 대한 픽스처를 각각 준비합니다.

전송 포맷은 별도로 검증하기

API가 Base64로 데이터를 전달한다면 먼저 디코딩한 뒤 길이를 확인합니다. 16진수 문자 128개가 128바이트 서명을 뜻하는 것은 아닙니다. 원시 공개 키와 헤더 정보가 포함된 키 파일도 서로 다릅니다. 포맷으로 감싼 데이터 전체를 rawRepresentation에 그대로 전달하면 안 됩니다.

디코딩에 실패했을 때 빈 데이터로 대체한 뒤 검증을 계속해서는 안 됩니다. 내부 오류 타입은 “잘못된 포맷”, “잘못된 길이”, “서명 불일치”로 구분하되, 외부에는 불필요한 파싱 세부 정보를 노출하지 않습니다.

연결 오류를 찾아내는 실패 사례 모음 만들기

최소한의 테스트 사례에는 올바른 메시지와 올바른 서명, 메시지에 널 바이트 하나 추가, 서명의 한 비트 반전, 잘린 서명, 잘린 공개 키, 그리고 다른 유효한 공개 키로 원래 서명을 검증하는 경우가 포함되어야 합니다. 마지막 사례에는 실제로 유효한 다른 공개 키를 사용해야 합니다. 손상된 공개 키로 초기화 실패만 유도해서는 안 됩니다.

서비스 계층에서는 “본문은 올바르지만 서명은 이전 메시지의 것인” 샘플도 추가해 코드가 캐시된 결과를 잘못 재사용하지 않는지 확인합니다. 서명 대상에 버전, 유효 기간, 용도 식별자가 포함된다면 이 필드가 서명되지 않은 외부 객체에만 들어 있는 것이 아니라 실제 서명 대상 바이트에 포함되는지도 검증해야 합니다.

이 테스트의 목적은 거부 경로를 검증하는 것이므로, 고정 픽스처를 무작위 데이터 손상으로 대신해서는 안 됩니다. 고정된 샘플이 있어야 서로 다른 노드와 근무조의 구성원도 같은 실패를 재현할 수 있습니다.

검증 증거 목록으로 지역 간 인계 마무리하기

인계할 때는 커밋 ID, 시스템 및 도구 체인 버전, 알고리즘 변형, 공개 키 식별자, 메시지 바이트 수, 서명 바이트 수, 테스트 결과를 기록합니다. 공개 픽스처는 함께 보관해도 됩니다. 실제 서비스 메시지는 민감도에 맞춰 접근을 통제하며 보관해야 하고, 해시값으로 원본 메시지를 대신해 서명을 검증할 수는 없습니다.

명령줄 기준 테스트를 통과한 뒤에는 같은 픽스처를 iOS 테스트 타깃에 넣고 앱이 실제로 사용하는 서명 검증 래퍼를 호출합니다. 그래야 CryptoKit 자체가 동작한다는 사실을 확인하는 데 그치지 않고 Base64 디코딩, 공개 키 선택, 메시지 읽기 경로까지 검증할 수 있습니다.

최종 완료 기준은 “한 번 검증했다”가 아닙니다. 올바른 입력은 안정적으로 통과하고, 모든 실패 사례는 명확히 거부되며, 양쪽이 서명 대상 바이트를 다르게 해석할 여지가 없어야 합니다. 또한 다른 근무조의 구성원이 개인 키 없이 모든 검사를 다시 실행할 수 있어야 합니다.

자주 묻는 질문

클라이언트 검증에도 개인 키가 필요한가요?

필요하지 않습니다. 신뢰할 수 있는 공개 키와 원본 메시지 바이트, 서명만 있으면 됩니다. 운영 개인 키는 검증 저장소나 작업 환경에 전달하지 마세요.

JSON 필드가 같은데 왜 검증에 실패하나요?

Ed25519는 JSON의 의미가 아니라 바이트를 검증합니다. 키 순서, 공백, 줄바꿈과 Unicode 표현이 다르면 입력도 달라집니다. 원본 바이트를 검증하거나 양쪽이 같은 정규화 규칙을 사용해야 합니다.

다음 작업에 맞는 클라우드 Mac 선택

팀이 주로 위치한 지역을 기준으로 리전을 선택하고, Mac mini 전체를 단독으로 사용하세요. 브라우저 원격 데스크톱을 이용하려면 최신 브라우저와 안정적인 네트워크가 필요합니다. 주문 전에 구성, 결제 주기, 실제 이용 가능 여부를 확인하세요.

다음 연결은 가까운 곳에서

먼저 구성과 이용 기간을 확인한 다음, 팀이 주로 사용하는 지역과 가까운 리전을 선택하세요.