클라우드 맥에서 iOS TOTP 생성 검증하기

클라우드 맥에서 iOS TOTP 생성 검증하기

클라우드 맥에서 iOS TOTP 생성 검증하기

iOS 프로젝트에 일회용 비밀번호를 도입하면 “아까는 됐는데 지금은 일치하지 않는다”는 오류가 가장 재현하기 어렵습니다. Windows에서 크로스 플랫폼 UI를 개발하고 클라우드 맥에서 네이티브 모듈을 검증할 때도 비밀번호를 반복해서 입력하며 운에 맡겨서는 안 됩니다. 시간을 입력값으로 다루고 표준 테스트 벡터를 판단 기준으로 삼아야 알고리즘 오류, 표시 오류, 서버의 시간 허용 범위 문제를 구분할 수 있습니다.

구현 계약 명확히 하기

TOTP는 Unix 시간을 증가하는 카운터로 변환한 뒤 HMAC으로 고정 자릿수의 숫자 문자열을 생성합니다. 먼저 비밀 키의 원시 바이트, 해시 알고리즘, 시간 간격, 출력 자릿수라는 네 가지 매개변수를 확정해야 합니다. 이 글에서는 RFC 6238의 SHA-1 테스트 설정을 사용합니다. 기준 시점은 0, 시간 간격은 30초, 출력은 8자리입니다.

여기서 SHA-1을 사용하는 이유는 표준 호환성 테스트 벡터와 대조하기 위해서이지, 새 프로토콜에서 알고리즘을 임의로 선택해도 된다는 뜻은 아닙니다. 클라이언트와 서버가 다른 해시 알고리즘을 사용하기로 했다면 해당 테스트 벡터도 함께 바꿔야 합니다. 클라이언트만 바꿔서는 안 됩니다.

설정 화면에서 흔히 볼 수 있는 Base32 형식의 비밀 키 텍스트는 HMAC에 바로 넣는 입력값이 아닙니다. 정해진 방식으로 먼저 디코딩한 다음 원시 바이트를 전달해야 합니다. 텍스트의 UTF-8 바이트를 디코딩 결과로 착각하지 마세요.

아래 비밀 키는 공개된 테스트 데이터이며 회귀 테스트에만 사용해야 합니다. 실제 비밀 키, QR 코드 내용, 일회용 비밀번호는 빌드 로그에 기록하면 안 됩니다.

생성 로직 구현하기

XcodeHost 클라우드 맥의 프로젝트 디렉터리에 totp-check.swift를 저장합니다. 먼저 xcrun swift --version이 실행되는지 확인한 뒤 다음 코드를 추가하세요. 시스템 SDK만 사용하므로 서드 파티 패키지를 다운로드할 필요가 없습니다.

카운터는 반드시 8바이트 빅 엔디언으로 인코딩해야 합니다. 동적 절단에서는 다이제스트 마지막 바이트의 하위 4비트를 오프셋으로 사용하고, 추출한 값의 최상위 비트를 지운 다음 출력 자릿수에 맞춰 나머지 연산을 수행합니다. 앞자리 0이 사라지지 않도록 반환값은 반드시 문자열이어야 합니다.

import Foundation
import CryptoKit

enum TOTPError: Error {
    case invalidInput
}

func totp(
    secret: Data,
    unixSeconds: Int64,
    digits: Int = 8,
    period: Int64 = 30
) throws -> String {
    guard !secret.isEmpty,
          unixSeconds >= 0,
          period > 0,
          digits == 6 || digits == 8 else {
        throw TOTPError.invalidInput
    }

    let counter = UInt64(unixSeconds / period)
    let message = Data((0..<8).map { index in
        UInt8(truncatingIfNeeded: counter >> ((7 - index) * 8))
    })
    let digest = Array(HMAC<Insecure.SHA1>.authenticationCode(
        for: message,
        using: SymmetricKey(data: secret)
    ))
    let offset = Int(digest[digest.count - 1] & 0x0f)
    let binary =
        (UInt32(digest[offset] & 0x7f) << 24) |
        (UInt32(digest[offset + 1]) << 16) |
        (UInt32(digest[offset + 2]) << 8) |
        UInt32(digest[offset + 3])
    let modulus: UInt32 = digits == 6 ? 1_000_000 : 100_000_000
    let text = String(binary % modulus)
    return String(repeating: "0", count: digits - text.count) + text
}

로컬 날짜 문자열, 시간대 오프셋, 화면의 카운트다운으로 카운터를 계산하면 안 됩니다. 프로토콜에 필요한 값은 Unix 초이며, 카운트다운은 표시용일 뿐입니다. 표시 시간대를 바꾸더라도 같은 시점의 비밀번호는 달라지지 않아야 합니다.

표준 테스트 벡터 실행하기

같은 파일 끝에 표준 테스트 벡터 여섯 세트를 추가합니다. 여기서 사용하는 20바이트 ASCII 비밀 키는 표준에 명시된 원시 바이트이므로 Base32 디코딩을 거치지 않습니다. 큰 타임스탬프를 사용하면 구현 내부에서 비트 폭이 작은 정수형을 쓰는 문제도 찾아낼 수 있습니다.

let secret = Data("12345678901234567890".utf8)
let vectors: [(Int64, String)] = [
    (59, "94287082"),
    (1111111109, "07081804"),
    (1111111111, "14050471"),
    (1234567890, "89005924"),
    (2000000000, "69279037"),
    (20000000000, "65353130")
]

for (time, expected) in vectors {
    let actual = try totp(secret: secret, unixSeconds: time)
    precondition(actual == expected, "TOTP vector mismatch")
}

xcrun swift totp-check.swift로 실행합니다. 성공하면 출력이 없고, 어설션이 실패하면 0이 아닌 종료 상태를 반환합니다. 검사를 비활성화하는 최적화 옵션은 추가하지 마세요. 앱의 테스트 타깃에 통합한 뒤에는 이 어설션들을 XCTAssertEqual로 옮겨 실패한 사례가 각각 보고되도록 합니다.

여섯 세트가 모두 실패하면 비밀 키와 바이트 순서부터 확인하세요. 앞자리가 0인 사례만 실패하면 숫자를 문자열로 변환하는 부분을, 큰 타임스탬프만 실패하면 정수형의 비트 폭을 먼저 확인해야 합니다. 인증 허용 범위를 넓혀서 해결할 문제가 아닙니다.

시간 구간 경계 검증하기

표준 테스트 벡터를 통과한 뒤에는 정수 나눗셈의 버림 처리와 잘못된 매개변수 거부도 검증해야 합니다. 아래 코드는 59초가 여전히 두 번째 시간 구간에 속하고, 60초에는 다음 구간으로 전환되는지 확인합니다. 실행을 대기시키거나 머신의 시계를 바꿀 필요가 없습니다.

let at30 = try totp(secret: secret, unixSeconds: 30)
let at59 = try totp(secret: secret, unixSeconds: 59)
let at60 = try totp(secret: secret, unixSeconds: 60)
precondition(at30 == "94287082")
precondition(at59 == at30)
precondition(at60 == "37359152")

do {
    _ = try totp(secret: secret, unixSeconds: -1)
    preconditionFailure("Negative time accepted")
} catch TOTPError.invalidInput {
}

빈 비밀 키, 0인 시간 간격, 지원하지 않는 자릿수를 거부하는 테스트도 같은 방식으로 추가합니다. 서로 다른 시간 구간에서도 우연히 같은 비밀번호가 나올 수 있으므로 “구간이 바뀌면 반드시 달라진다”를 일반적인 어설션으로 사용하면 안 됩니다. 위 예제처럼 이미 알고 있는 결과와 비교해야 합니다.

앱이 밀리초 단위 타임스탬프를 받는다면 어댑터 계층에서 명시적으로 한 번만 변환하세요. 1,000으로 여러 번 나누거나 아예 변환하지 않으면 형식은 올바르지만 인증에는 사용할 수 없는 숫자 문자열이 생성됩니다.

회귀 테스트에 통합하기

생성 함수는 테스트 타깃에서 호출할 수 있는 모듈에 둡니다. 현재 시간을 읽어서 전달하는 일은 UI 계층이 맡고, 함수 내부에서 시계를 직접 읽지 않도록 합니다. 앱이 백그라운드에서 복귀하면 시간을 다시 읽어야 합니다. 실행되지 못한 타이머 콜백을 기준으로 카운트다운을 계속 줄여서는 안 됩니다.

시뮬레이터에서는 자릿수, 앞자리 0, 시간 구간 전환 시의 표시를 검증할 수 있습니다. 서버 쪽에서는 잘못된 비밀번호, 허용 범위를 벗어난 시간, 중복 제출을 별도로 검증해야 합니다. 인접한 시간 구간을 허용하는 것은 시계 오차를 수용하기 위한 것이지, 비밀번호를 무제한으로 재사용해도 된다는 뜻이 아닙니다.

배포 전에는 표준 테스트 벡터 결과, 매개변수 거부 결과, 서버의 재전송 거부 결과라는 세 종류의 검증 기록을 남기세요. 테스트 사례 이름과 통과 여부만 기록하고 실제 인증 정보는 저장하지 않습니다. 이렇게 하면 클라우드 맥 워크스테이션을 바꾸거나 툴체인을 업그레이드해도 같은 검사를 오프라인으로 다시 실행할 수 있고, 간헐적인 인증 장애를 명확한 계약 불일치로 좁혀 나갈 수 있습니다.

자주 묻는 질문

TOTP 회귀 테스트에 네트워크나 실제 시간 대기가 필요한가요?

필요하지 않습니다. Unix 초를 생성 함수의 인자로 전달하고 고정 키와 표준 벡터를 사용하면 시간 구간 경계까지 오프라인으로 검증할 수 있습니다.

생성 결과가 맞으면 인증도 안전한가요?

아닙니다. 서버는 시도 횟수와 허용 시간 차이를 제한하고, 인증 정보별로 이미 승인한 시간 단계를 기록해 같은 구간에서의 재사용을 막아야 합니다.

클라우드 Mac 대여

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

기종 선택

빌드 일정을 계획해 보세요

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