feat: add TOTPGenerator (RFC 6238)
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user