diff --git a/MyPassCore/Sources/MyPassCore/TOTP/TOTPGenerator.swift b/MyPassCore/Sources/MyPassCore/TOTP/TOTPGenerator.swift new file mode 100644 index 0000000..326003c --- /dev/null +++ b/MyPassCore/Sources/MyPassCore/TOTP/TOTPGenerator.swift @@ -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.authenticationCode(for: counterBytes, using: symKey)) + case .sha256: + hmacBytes = Array(HMAC.authenticationCode(for: counterBytes, using: symKey)) + case .sha512: + hmacBytes = Array(HMAC.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 + } +} diff --git a/MyPassCore/Tests/MyPassCoreTests/TOTPGeneratorTests.swift b/MyPassCore/Tests/MyPassCoreTests/TOTPGeneratorTests.swift new file mode 100644 index 0000000..a0a3df4 --- /dev/null +++ b/MyPassCore/Tests/MyPassCoreTests/TOTPGeneratorTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import MyPassCore + +final class TOTPGeneratorTests: XCTestCase { + // RFC 6238 Section 8 test vectors for SHA-1 + // secret = "12345678901234567890" (ASCII), base32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + let sha1Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + + func test_sha1_at59s() { + let config = TOTPConfig(secret: sha1Secret, period: 30, digits: 8, algorithm: .sha1) + let date = Date(timeIntervalSince1970: 59) + XCTAssertEqual(TOTPGenerator.generate(config: config, at: date), "94287082") + } + + func test_sha1_at1111111109s() { + let config = TOTPConfig(secret: sha1Secret, period: 30, digits: 8, algorithm: .sha1) + let date = Date(timeIntervalSince1970: 1111111109) + XCTAssertEqual(TOTPGenerator.generate(config: config, at: date), "07081804") + } + + func test_secondsRemaining_isWithinPeriod() { + let config = TOTPConfig(secret: sha1Secret, period: 30) + let remaining = TOTPGenerator.secondsRemaining(config: config, at: Date()) + XCTAssertGreaterThan(remaining, 0) + XCTAssertLessThanOrEqual(remaining, 30) + } + + func test_generate_defaultSixDigits() { + let config = TOTPConfig(secret: sha1Secret) + let code = TOTPGenerator.generate(config: config, at: Date()) + XCTAssertEqual(code.count, 6) + XCTAssertNotNil(Int(code)) + } +}