feat: add TOTPGenerator (RFC 6238)

This commit is contained in:
2026-05-22 14:44:50 +02:00
parent 3b71a92089
commit 3af6c9ad31
2 changed files with 84 additions and 0 deletions
@@ -0,0 +1,50 @@
import Foundation
import CryptoKit
public enum TOTPGenerator {
public static func generate(config: TOTPConfig, at date: Date = Date()) -> String {
let counter = UInt64(date.timeIntervalSince1970) / UInt64(config.period)
let keyBytes = base32Decode(config.secret)
let counterBytes = withUnsafeBytes(of: counter.bigEndian, Array.init)
let symKey = SymmetricKey(data: keyBytes)
let hmacBytes: [UInt8]
switch config.algorithm {
case .sha1:
hmacBytes = Array(HMAC<Insecure.SHA1>.authenticationCode(for: counterBytes, using: symKey))
case .sha256:
hmacBytes = Array(HMAC<SHA256>.authenticationCode(for: counterBytes, using: symKey))
case .sha512:
hmacBytes = Array(HMAC<SHA512>.authenticationCode(for: counterBytes, using: symKey))
}
let offset = Int(hmacBytes[hmacBytes.count - 1] & 0x0f)
let truncated = ((Int(hmacBytes[offset]) & 0x7f) << 24)
| (Int(hmacBytes[offset + 1]) << 16)
| (Int(hmacBytes[offset + 2]) << 8)
| Int(hmacBytes[offset + 3])
let otp = truncated % Int(pow(10.0, Double(config.digits)))
return String(format: "%0\(config.digits)d", otp)
}
public static func secondsRemaining(config: TOTPConfig, at date: Date = Date()) -> Int {
let elapsed = Int(date.timeIntervalSince1970) % config.period
return config.period - elapsed
}
private static func base32Decode(_ input: String) -> [UInt8] {
let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
let s = input.uppercased().filter { alphabet.contains($0) }
var result: [UInt8] = []
var buffer = 0
var bitsLeft = 0
for char in s {
guard let idx = alphabet.firstIndex(of: char) else { continue }
buffer = (buffer << 5) | alphabet.distance(from: alphabet.startIndex, to: idx)
bitsLeft += 5
if bitsLeft >= 8 {
bitsLeft -= 8
result.append(UInt8((buffer >> bitsLeft) & 0xff))
}
}
return result
}
}