Skip to content

OTP Code Generator — Swift source

Generate time-based one-time passwords (RFC 6238 TOTP) from a Base32 secret, with selectable algorithm, digit count, and period - updating live, entirely in your browser.

This is the Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.

// otp-code-generator — Swift port: TOTP (RFC 6238) / HOTP (RFC 4226) generator.
//
// Display port of the CosmoDev OTP Code Generator tool — same contract as
// cli/otp-code-generator/otp-code-generator.go (the live Go twin) and
// src/lib/otp.ts (canonical TypeScript, which wraps `otpauth`). HMAC-SHA1
// comes from CryptoKit; base32 is hand-rolled. Only the SHA1 path is ported
// — the TS default and the algorithm every published RFC 6238/4226 test
// vector uses; SHA256/512 follow the same HMAC construction in the Go twin.
import CryptoKit
import Foundation

/// TOTP options — mirrors `Options` in the Go twin.
public struct TotpOptions {
    /// Base32 (RFC 4648); spaces/case tolerated.
    public var secret: String
    /// Digits; <= 0 falls back to the TS default 6.
    public var digits: Int
    /// Period in seconds; <= 0 falls back to the TS default 30.
    public var period: Int

    public init(secret: String, digits: Int = 6, period: Int = 30) {
        self.secret = secret
        self.digits = digits
        self.period = period
    }
}

public enum Otp {
    /// TOTP at `timestampMs` (ms since epoch) — `Generate()` in the Go twin
    /// (RFC 6238). nil on an invalid secret, like generateTotp() in the TS lib.
    public static func generate(_ opts: TotpOptions, timestampMs: Int64) -> String? {
        let digits = opts.digits > 0 ? opts.digits : 6  // withDefaults in the Go twin
        let period = opts.period > 0 ? opts.period : 30
        guard let key = decodeSecret(opts.secret) else { return nil }

        let counter = UInt64(bitPattern: timestampMs) / 1000 / UInt64(period) // RFC 6238 §4.2
        var msg = [UInt8](repeating: 0, count: 8)
        for i in 0..<8 { msg[i] = UInt8(truncatingIfNeeded: counter >> (8 * (7 - i))) } // RFC 4226 §5.2

        // Insecure.SHA1 is CryptoKit's SHA1 (namespaced to flag its crypto age).
        let mac = Array(HMAC<Insecure.SHA1>.authenticationCode(for: msg, using: SymmetricKey(data: key)))

        let offset = Int(mac[mac.count - 1] & 0x0f) // dynamic truncation, RFC 4226 §5.4
        let b0 = UInt32(mac[offset] & 0x7f)
        let b1 = UInt32(mac[offset + 1])
        let b2 = UInt32(mac[offset + 2])
        let b3 = UInt32(mac[offset + 3])
        let bin = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3

        var mod: UInt64 = 1
        for _ in 0..<digits { mod *= 10 }
        var code = UInt64(bin) % mod
        var chars = [Character](repeating: "0", count: digits)
        var i = digits - 1
        while i >= 0 {
            chars[i] = Character(String(code % 10))
            code /= 10
            i -= 1
        }
        return String(chars)
    }

    /// `Validate()` in the Go twin: the current period and ±1 adjacent periods
    /// (otpauth's default window=1). `constantTimeEquals` mirrors hmac.Equal.
    public static func validate(_ token: String, _ opts: TotpOptions, timestampMs: Int64) -> Bool {
        let period = opts.period > 0 ? opts.period : 30
        let window = Int64(period) * 1000
        for ts in [timestampMs, timestampMs - window, timestampMs + window] {
            if let candidate = generate(opts, timestampMs: ts),
               constantTimeEquals(candidate, token) {
                return true
            }
        }
        return false
    }

    /// decodeSecret() in the Go twin: strip whitespace, uppercase, strip
    /// trailing '=' padding, then RFC 4648 decode. nil when the secret is
    /// invalid.
    static func decodeSecret(_ secret: String) -> [UInt8]? {
        var norm = ""
        for c in secret where !c.isWhitespace {
            norm.append(Character(c.uppercased()))
        }
        while norm.last == "=" { norm.removeLast() }

        var bytes: [UInt8] = []
        var acc = 0, bits = 0
        for c in norm {
            guard let a = c.asciiValue else { return nil }
            let v: Int
            if a >= 65 && a <= 90 { v = Int(a) - 65 }          // A-Z
            else if a >= 50 && a <= 55 { v = Int(a) - 50 + 26 } // 2-7
            else { return nil }
            acc = (acc << 5) | v
            bits += 5
            if bits >= 8 {
                bits -= 8
                bytes.append(UInt8((acc >> bits) & 0xff))
            }
        }
        return bytes
    }

    /// XOR-fold compare that does not short-circuit — the Swift counterpart of
    /// the Go twin's constant-time hmac.Equal.
    static func constantTimeEquals(_ a: String, _ b: String) -> Bool {
        let x = Array(a.utf8), y = Array(b.utf8)
        if x.count != y.count { return false }
        var diff: UInt8 = 0
        for i in x.indices { diff |= x[i] ^ y[i] }
        return diff == 0
    }
}

// RFC 6238 appendix-B vectors (SHA1, 8 digits, 30s):
// secret = base32("12345678901234567890").
let o8 = TotpOptions(secret: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", digits: 8)
for (ts, want) in [(59_000, "94287082"),
                   (1_111_111_109_000, "07081804"),
                   (1_234_567_890_000, "89005924"),
                   (2_000_000_000_000, "69279037")] {
    print("ts=\(ts) token=\(Otp.generate(o8, timestampMs: Int64(ts)) ?? "invalid") want=\(want)")
}

// Tool defaults (6 digits) + whitespace/case-tolerant secret entry.
let o6 = TotpOptions(secret: "gezd gnbv gy3t qojq gezd gnbv gy3t qojq")
let token = Otp.generate(o6, timestampMs: 59_000) ?? "invalid"
print("6-digit=\(token) validate(now)=\(Otp.validate(token, o6, timestampMs: 59_000)) " +
      "validate(+1)=\(Otp.validate(token, o6, timestampMs: 89_000)) " +
      "validate(wrong)=\(Otp.validate("123789", o6, timestampMs: 59_000))")

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →