Test iOS TOTP generation on a cloud Mac

Test iOS TOTP generation on a cloud Mac

After adding time-based one-time passwords to an iOS project, the hardest bugs to reproduce are often the ones where “it worked a moment ago, but now the codes don’t match.” If you develop a cross-platform interface on Windows and then validate native modules on a cloud Mac, repeatedly entering codes is no substitute for testing. Treat time as an input and standard test vectors as the reference so you can distinguish algorithm errors from display issues and server-side clock-skew tolerance.

Define the contract

TOTP converts Unix time into an incrementing counter, then uses HMAC to generate a fixed-length numeric code. Start by fixing four parameters: the raw secret bytes, the hash algorithm, the time step, and the number of output digits. This article uses the SHA-1 test configuration from RFC 6238: a starting time of zero, a thirty-second time step, and eight output digits.

SHA-1 is used here to check compatibility against standard test vectors, not as a recommendation to choose an algorithm arbitrarily for a new protocol. If both sides agree on a different hash algorithm, you must also replace the corresponding test vectors. Changing only the client is not enough.

The Base32 secret text commonly shown on configuration pages is not the direct input to HMAC. Decode it according to the agreed format first, then pass in the raw bytes. Do not mistake the text’s UTF-8 bytes for the decoded secret.

The secret below is public test data and must only be used for regression testing. Never write real secrets, QR code contents, or one-time passwords to build logs.

Implement the generator

Save totp-check.swift in the project directory on your XcodeHost cloud Mac. First, confirm that xcrun swift --version runs, then add the following code. It relies only on the system SDK and requires no third-party packages.

The counter must be encoded as eight bytes in big-endian order. Dynamic truncation uses the low four bits of the final digest byte as an offset, clears the most significant bit of the extracted value, and finally applies a modulo operation for the required number of digits. The return value must be a string; otherwise, leading zeros will be lost.

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
}

Do not derive the counter from a local date string, a time-zone offset, or the countdown displayed in the interface. The protocol requires Unix time in seconds; the countdown is only a display. Changing the display time zone must not change the code for the same instant.

Run the reference vectors

Append the six standard test vectors below to the same file. The twenty-byte ASCII secret here consists of the raw bytes explicitly specified by the standard; it must not be Base32-decoded. The large timestamps also expose implementations that inadvertently use integer types that are too narrow.

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")
}

Run the script with xcrun swift totp-check.swift. It produces no output on success and exits with a nonzero status if a precondition fails. Do not add optimization options that disable these checks. Once you integrate the checks into the app’s test target, replace them with XCTAssertEqual so each failing case is reported independently.

If all six vectors fail, check the secret and byte order first. If only cases with leading zeros fail, check the conversion from numbers to strings. If only large timestamps fail, check the integer width rather than widening the authentication tolerance.

Test time-step boundaries

Once the standard vectors pass, cover integer division and invalid-parameter rejection as well. The following checks confirm that fifty-nine seconds still falls within the second time step, while sixty seconds starts the next one. There is no need to sleep or change the machine’s clock.

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 {
}

Also add rejection tests for an empty secret, a zero time step, and unsupported digit counts. Codes from different time steps can occasionally be identical, so “codes must always differ across windows” is not a valid general assertion. Compare against known results, as shown above.

If the app receives timestamps in milliseconds, convert them explicitly once in the adapter layer. Dividing by a thousand twice, or not converting at all, will produce codes that look valid but cannot authenticate.

Integrate regression tests

Place the generator function in a module accessible to the test target. The UI layer should obtain the current time and pass it in; the function must not read the clock directly. When the app returns from the background, read the time again instead of continuing to decrement the countdown as though no timer callbacks had been missed.

The simulator can validate the digit count, leading zeros, and the display at time-step transitions. Server-side validation must separately cover incorrect codes, codes outside the allowed tolerance, and repeated submissions. Accepting adjacent time steps accommodates clock skew; it does not mean a code can be reused indefinitely.

Before release, retain three kinds of evidence: standard-vector results, invalid-parameter rejection results, and server-side replay rejection results. Record only test case names and pass status, not real authentication material. This lets you rerun the same checks offline after moving to another cloud Mac workstation or upgrading the toolchain, turning intermittent authentication failures into identifiable differences in the contract.

Frequently asked questions

Do TOTP regression tests need network access or real-time delays?

No. Pass Unix seconds into the generator explicitly. A fixed secret and reference vectors let you test normal cases and time-step boundaries offline.

Does correct code generation make authentication secure?

No. The server must limit attempts, define acceptable clock skew, and track accepted time steps per credential to prevent reuse within a valid window.

Rent a cloud Mac

Rent a dedicated physical machine by the day, week, month, or quarter. Schedule builds around your project; service stops when your rental ends unless you renew.

Choose a model

Get your build started

Choose a model and rental period, then confirm the end date. Service stops when your rental ends unless you renew.