클라우드 Mac에서 검증하는 iOS 요청 서명: HMAC과 재전송 방지의 경계

클라우드 Mac에서 검증하는 iOS 요청 서명: HMAC과 재전송 방지의 경계

클라우드 Mac에서 검증하는 iOS 요청 서명: HMAC과 재전송 방지의 경계

Windows에서 개발하는 크로스플랫폼 앱에 요청 서명을 적용했더니 일반 요청은 통과하지만, 중국어 쿼리나 JSON 본문이 포함된 요청은 실패할 수 있습니다. 원격 데스크톱에서 API 호출을 반복하기보다는 서명 모듈을 클라우드 Mac에서 단독으로 실행해 바이트 처리 규약부터 검증한 뒤 iOS 프로젝트에 다시 연결하는 편이 원인을 찾기 쉽습니다.

여기서는 Swift와 CryptoKit으로 오프라인 인수 테스트용 스크립트를 만듭니다. 실제 API, 운영 환경의 키, 서명 인증서는 필요하지 않습니다. 클라이언트와 서버 양쪽에서 이 테스트 픽스처를 함께 보관해야 합니다.

먼저 규약 정하기

HMAC-SHA256이 처리하는 대상은 바이트입니다. “겉보기에 같은” 요청이 아닙니다. 먼저 프로토콜 버전, 메서드, 요청 대상, 타임스탬프, 논스, 본문의 SHA-256 다이제스트라는 여섯 필드를 정의합니다.

이 예제에서는 메서드를 대문자로 고정합니다. 요청 대상에는 경로와 원본 쿼리 문자열을 포함하되 프래그먼트는 제외합니다. 타임스탬프는 초 단위의 십진 정수를 사용하고, 논스는 요청마다 새로 생성합니다. 본문 다이제스트는 소문자 16진수로 출력합니다.

쿼리 매개변수를 임의로 정렬하면 안 됩니다. 중복 키, 빈 값, 퍼센트 인코딩, 매개변수 순서를 동등하게 취급할지는 API 프로토콜에서 정해야 합니다. 클라이언트는 최종 인코딩이 끝난 뒤 서명하고, 서버는 프레임워크가 경로나 쿼리 매개변수를 변경하기 전에 합의된 표현을 확보해야 합니다.

HMAC은 메시지가 키와 일치한다는 사실을 증명할 뿐, 본문을 암호화하거나 재전송 공격을 자동으로 막아 주지는 않습니다. 전송 암호화를 대신할 수 없으며, 앱 패키지에 내장된 공유 키를 신뢰할 수 있는 인증 자격 증명으로 만들어 주지도 않습니다.

바이트 고정하기

필드를 단순히 줄바꿈으로 연결하지 마세요. 필드 안에 줄바꿈이 들어 있으면 구분이 모호해질 수 있습니다. 이 예제에서는 각 UTF-8 필드 앞에 바이트 길이를 나타내는 8바이트 부호 없는 빅엔디언 정수를 붙인 다음 필드 내용을 이어 붙입니다. 서버도 정확히 같은 규칙을 구현해야 합니다.

다음 내용을 request-signing.swift로 저장합니다. 클라우드 Mac에서 먼저 xcrun swift --version을 실행해 툴체인을 확인한 다음, xcrun swift request-signing.swift를 실행하세요. 성공하면 스크립트가 정상 종료되고, 단언문이 실패하면 0이 아닌 종료 상태를 반환합니다.

import Foundation
import CryptoKit

func hex<S: Sequence>(_ bytes: S) -> String
where S.Element == UInt8 {
    bytes.map { String(format: "%02x", $0) }.joined()
}

func frame(_ fields: [String]) -> Data {
    var output = Data()
    for field in fields {
        let bytes = Data(field.utf8)
        var length = UInt64(bytes.count).bigEndian
        withUnsafeBytes(of: &length) {
            output.append(contentsOf: $0)
        }
        output.append(bytes)
    }
    return output
}

func canonical(
    target: String,
    body: Data,
    timestamp: String,
    nonce: String
) -> Data {
    frame([
        "request-v1",
        "POST",
        target,
        timestamp,
        nonce,
        hex(SHA256.hash(data: body))
    ])
}

let vectorKey = SymmetricKey(
    data: Data(repeating: 0x0b, count: 20)
)
let vector = HMAC<SHA256>.authenticationCode(
    for: Data("Hi There".utf8),
    using: vectorKey
)
precondition(
    hex(vector) ==
    "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
)

let key = SymmetricKey(data: Data(repeating: 0x42, count: 32))
let body = Data(#"{"count":1}"#.utf8)
let target = "/v1/orders?a=1&b=2"
let timestamp = "1700000000"
let nonce = "fixture-nonce-001"

let message = canonical(
    target: target, body: body,
    timestamp: timestamp, nonce: nonce
)
let tag = Data(HMAC<SHA256>.authenticationCode(
    for: message, using: key
))

func accepts(_ candidate: Data) -> Bool {
    HMAC<SHA256>.isValidAuthenticationCode(
        tag, authenticating: candidate, using: key
    )
}

precondition(accepts(message))
precondition(!accepts(canonical(
    target: target, body: Data(#"{"count":2}"#.utf8),
    timestamp: timestamp, nonce: nonce
)))
precondition(!accepts(canonical(
    target: "/v1/orders?b=2&a=1", body: body,
    timestamp: timestamp, nonce: nonce
)))
precondition(!accepts(canonical(
    target: target, body: body,
    timestamp: timestamp, nonce: "fixture-nonce-002"
)))

고정된 키, 타임스탬프, 논스는 픽스처 전용입니다. 표준 테스트 벡터는 암호 알고리즘 호출이 올바른지 검증하고, 뒤에 이어지는 단언문은 프로토콜 필드가 실제로 서명에 반영되는지 검증합니다. 두 종류의 테스트는 서로를 대체할 수 없습니다.

차이점 빠짐없이 검증하기

앱에 연결할 때는 최종 본문의 Data를 한 번만 생성하고, 동일한 데이터를 다이제스트 계산과 네트워크 요청에 전달합니다. 서명한 뒤 JSON을 다시 인코딩하거나 미들웨어가 본문을 암묵적으로 바꾸게 해서는 안 됩니다.

인코딩 확인하기

중국어와 이모지가 포함된 쿼리, 중복 쿼리 키, 빈 본문과 빈 JSON 객체라는 세 그룹의 픽스처를 추가합니다. 각 그룹에 필드 내용, 정규화한 바이트의 16진수 값, 예상 서명을 저장하고 클라이언트와 서버가 각각 읽도록 합니다. 테스트 대상 함수로 실행 중에 예상값을 생성해서는 안 됩니다.

특히 빈 문자열, 누락된 필드, 문자열 "null"을 구분해야 합니다. 프로토콜에서 허용하지 않는 입력은 기본값으로 슬쩍 채우지 말고, HMAC 처리에 들어가기 전에 거부하세요.

전송 경로 확인하기

리버스 프록시가 경로 접두사를 바꿀 수 있고, 프레임워크가 %20과 더하기 기호를 같은 공백으로 디코딩할 수도 있습니다. 외부 요청 대상과 내부 라우트 중 어느 쪽에 서명하는지 명확히 정하고, 그에 해당하는 경계에서 바이트를 확보해야 합니다.

문제 해결 과정에서는 픽스처나 민감 정보를 제거한 다이제스트만 기록합니다. 운영 환경의 본문, 인증 헤더, 키가 빌드 로그에 남지 않게 하세요. 키를 명령줄 인수로 전달해서도 안 됩니다.

재전송 공격 차단하기

서명 검증을 통과한 뒤에도 서버는 타임스탬프 형식과 허용 가능한 시간 오차를 확인해야 합니다. 허용 시간 구간의 길이는 업무 요건에 따라 정합니다. 테스트에는 최소한 구간 안, 정확히 경계에 해당하는 시각, 경계를 막 벗어난 시각, 미래 시각이라는 네 종류의 입력을 포함해야 합니다.

이어서 “자격 증명 식별자+논스”를 중복 제거 키로 삼아 원자적으로 기록하고, 해당 요청이 더 이상 시간 검증을 통과할 수 없을 때까지 유지합니다. 먼저 조회한 뒤 삽입하는 방식에는 경쟁 상태가 있습니다. 동시에 들어온 두 요청이 모두 통과할 수 있으므로, 원자적 조건부 쓰기나 고유성 제약 조건을 사용해야 합니다.

오프라인 스크립트의 오래된 타임스탬프는 온라인 인수 테스트에 사용할 수 없습니다. 온라인 요청에서는 현재 시각을 읽고 새 논스를 생성해야 합니다. 재시도할 때 업무용 멱등성 키를 재사용할지는 별도로 합의해야 하며, 논스 중복 제거를 주문 처리의 멱등성과 동일시해서는 안 됩니다.

필수 검증 절차에 연결하기

먼저 스크립트를 커밋 검사에 포함한 다음, 같은 픽스처를 서버 테스트에도 적용합니다. 본문 변경뿐 아니라 잘못된 키, 잘린 서명, 잘못된 서명 인코딩을 거부하는 사례도 추가하세요. 수신 측은 암호 라이브러리의 검증 함수를 사용해야 하며, 일반 문자열 동등 비교로 대신해서는 안 됩니다.

마지막으로 완전히 동일한 유효 요청 두 개를 동시에 보내, 하나만 업무 처리로 넘어가는지 확인합니다. 이어서 유효 시간 구간 안에서 정상적인 재시도를 재현하고, 합의된 멱등성 결과를 얻는지 확인하세요.

툴체인 버전, 픽스처 버전, 실패한 단언문 번호만 남기면 충분합니다. 인수 완료 기준은 “요청이 한 번 성공했다”가 아닙니다. 양쪽이 동일한 바이트에서 같은 서명을 계산하고, 잘못된 입력과 중복 요청을 일관되고 재현 가능한 방식으로 거부하는 것이 기준입니다.

자주 묻는 질문

HMAC 검증만으로 재전송 공격을 막을 수 있나요?

아니요. 유효한 요청 전체를 다시 제출할 수 있습니다. 서버는 시간을 검사하고 자격 증명 식별자와 nonce 조합을 원자적으로 중복 제거해야 합니다. 업무 재시도에는 별도의 멱등 키가 필요합니다.

JSON을 파싱한 뒤 다시 인코딩해서 서명해야 하나요?

이 방식에서는 다시 인코딩하지 않습니다. 클라이언트는 실제 전송할 본문 Data를, 서버는 수신한 원본 본문 바이트를 해시합니다. 키 순서, 공백, 숫자 표현이 바뀌어 생기는 불일치를 피할 수 있습니다.

클라우드 Mac 대여

일간, 주간, 월간, 분기 단위로 전용 물리 서버를 이용할 수 있습니다. 프로젝트 일정에 맞춰 빌드하고, 기간이 끝나면 연장하지 않는 한 서비스가 중지됩니다.

기종 선택

빌드 일정을 계획해 보세요

기종과 이용 기간을 선택하고 만료일을 확인하세요. 기간이 끝나면 연장하지 않는 한 서비스가 중지됩니다.