From d00dd01e81d89d0dafd4770e4d50141733007584 Mon Sep 17 00:00:00 2001 From: Bradley Walters Date: Sat, 30 May 2020 19:11:29 -0600 Subject: [PATCH] Add support for generating Steam Guard codes --- .../OTPTokenSerializationTests.m | 4 +- Sources/Generator.swift | 82 ++++++++++++---- Sources/Token+URL.swift | 35 ++++++- Tests/GeneratorTests.swift | 47 +++++++--- Tests/TokenSerializationTests.swift | 94 ++++++++++++++++++- 5 files changed, 225 insertions(+), 37 deletions(-) diff --git a/OneTimePasswordLegacyTests/OTPTokenSerializationTests.m b/OneTimePasswordLegacyTests/OTPTokenSerializationTests.m index 272f8fda..e7304378 100644 --- a/OneTimePasswordLegacyTests/OTPTokenSerializationTests.m +++ b/OneTimePasswordLegacyTests/OTPTokenSerializationTests.m @@ -320,7 +320,7 @@ - (void)testSerialization XCTAssertEqualObjects(queryArguments[@"issuer"], issuer, @"The issuer value should be \"%@\"", issuer); - XCTAssertEqual(queryArguments.count, (NSUInteger)(issuer ? 4 : 3), @"There shouldn't be any unexpected query arguments"); + XCTAssertEqual(queryArguments.count, (NSUInteger)(issuer ? 5 : 4), @"There shouldn't be any unexpected query arguments"); // Check url again NSURL *checkURL = token.url; @@ -447,6 +447,7 @@ - (void)testTOTPURL NSArray *expectedQueryItems = @[[NSURLQueryItem queryItemWithName:@"algorithm" value:@"SHA256"], [NSURLQueryItem queryItemWithName:@"digits" value:@"8"], + [NSURLQueryItem queryItemWithName:@"representation" value:@"numeric"], [NSURLQueryItem queryItemWithName:@"issuer" value:@""], [NSURLQueryItem queryItemWithName:@"period" value:@"45"]]; NSArray *queryItems = [NSURLComponents componentsWithURL:url @@ -465,6 +466,7 @@ - (void)testHOTPURL NSArray *expectedQueryItems = @[[NSURLQueryItem queryItemWithName:@"algorithm" value:@"SHA256"], [NSURLQueryItem queryItemWithName:@"digits" value:@"8"], + [NSURLQueryItem queryItemWithName:@"representation" value:@"numeric"], [NSURLQueryItem queryItemWithName:@"issuer" value:@""], [NSURLQueryItem queryItemWithName:@"counter" value:@"18446744073709551615"]]; NSArray *queryItems = [NSURLComponents componentsWithURL:url diff --git a/Sources/Generator.swift b/Sources/Generator.swift index b0f420d7..5c43523a 100644 --- a/Sources/Generator.swift +++ b/Sources/Generator.swift @@ -39,31 +39,36 @@ public struct Generator: Equatable { /// The number of digits in the password. public let digits: Int + /// The digits or alphabet used to generate the human-readable password output. + public let representation: Representation + /// Initializes a new password generator with the given parameters. /// - /// - parameter factor: The moving factor. - /// - parameter secret: The shared secret. - /// - parameter algorithm: The cryptographic hash function. - /// - parameter digits: The number of digits in the password. + /// - parameter factor: The moving factor. + /// - parameter secret: The shared secret. + /// - parameter algorithm: The cryptographic hash function. + /// - parameter digits: The number of digits in the password. + /// - parameter representation: The output character set. /// /// - returns: A new password generator with the given parameters, or `nil` if the parameters /// are invalid. - public init?(factor: Factor, secret: Data, algorithm: Algorithm, digits: Int) { - try? self.init(_factor: factor, secret: secret, algorithm: algorithm, digits: digits) + public init?(factor: Factor, secret: Data, algorithm: Algorithm, digits: Int, representation: Representation = .numeric) { + try? self.init(_factor: factor, secret: secret, algorithm: algorithm, digits: digits, representation: representation) } // Eventually, this throwing initializer will replace the failable initializer above. For now, the failable // initializer remains to maintain a consistent public API. Since two different initializers cannot overload the // same initializer signature with both throwing an failable versions, this new initializer is currently prefixed // with an underscore and marked as internal. - internal init(_factor factor: Factor, secret: Data, algorithm: Algorithm, digits: Int) throws { + internal init(_factor factor: Factor, secret: Data, algorithm: Algorithm, digits: Int, representation: Representation) throws { try Generator.validateFactor(factor) - try Generator.validateDigits(digits) + try Generator.validateDigits(digits, representation: representation) self.factor = factor self.secret = secret self.algorithm = algorithm self.digits = digits + self.representation = representation } // MARK: Password Generation @@ -76,8 +81,6 @@ public struct Generator: Equatable { /// - throws: A `Generator.Error` if a valid password cannot be generated for the given time. /// - returns: The generated password, or throws an error if a password could not be generated. public func password(at time: Date) throws -> String { - try Generator.validateDigits(digits) - let counter = try factor.counterValue(at: time) // Ensure the counter value is big-endian var bigCounter = counter.bigEndian @@ -112,11 +115,9 @@ public struct Generator: Equatable { truncatedHash = UInt32(bigEndian: truncatedHash) // Discard the most significant bit truncatedHash &= 0x7fffffff - // Constrain to the right number of digits - truncatedHash = truncatedHash % UInt32(pow(10, Float(digits))) - // Pad the string representation with zeros, if necessary - return String(truncatedHash).padded(with: "0", toLength: digits) + // Obtain the string representation of the hash + return representation.stringify(truncatedHash, toLength: digits) } // MARK: Update @@ -135,7 +136,8 @@ public struct Generator: Equatable { _factor: .counter(counterValue + 1), secret: secret, algorithm: algorithm, - digits: digits + digits: digits, + representation: representation ) case .timer: // A timer-based generator does not need to be updated. @@ -191,6 +193,41 @@ public struct Generator: Equatable { case sha512 } + /// A configuration of digits or alphabet used to generate the human-readable password output. + public enum Representation: Equatable { + /// The digits 0-9. This is the standard representation. + case numeric + /// The steamguard character set, consisting of digits and letters. + case steamguard + + /// Generates human-readable output from a truncated HMAC value. + fileprivate func stringify(_ truncatedHash: UInt32, toLength digits: Int) -> String { + var truncatedHash = truncatedHash + switch self { + case .numeric: + // Constrain to the right number of digits + truncatedHash = truncatedHash % UInt32(pow(10, Float(digits))) + // Pad the string representation with zeros, if necessary + return String(truncatedHash).padded(with: "0", toLength: digits) + case .steamguard: + // Define the character set used by Steam Guard codes. + let alphabet: [Character] = + ["2", "3", "4", "5", "6", "7", "8", "9", "B", "C", + "D", "F", "G", "H", "J", "K", "M", "N", "P", "Q", + "R", "T", "V", "W", "X", "Y"] + let radix = UInt32(alphabet.count) + + // Obtain n digits of the base- representation of the hash. + return String((0.. String { switch algorithm { case .sha1: @@ -115,7 +122,27 @@ private func algorithmFromString(_ string: String) throws -> Generator.Algorithm } } -private func urlForToken(name: String, issuer: String, factor: Generator.Factor, algorithm: Generator.Algorithm, digits: Int) throws -> URL { +private func stringForRepresentation(_ representation: Generator.Representation) -> String { + switch representation { + case .numeric: + return kRepresentationNumeric + case .steamguard: + return kRepresentationSteamGuard + } +} + +private func representationFromString(_ string: String) throws -> Generator.Representation { + switch string { + case kRepresentationNumeric: + return .numeric + case kRepresentationSteamGuard: + return .steamguard + default: + throw DeserializationError.invalidRepresentation(string) + } +} + +private func urlForToken(name: String, issuer: String, factor: Generator.Factor, algorithm: Generator.Algorithm, digits: Int, representation: Generator.Representation) throws -> URL { var urlComponents = URLComponents() urlComponents.scheme = kOTPAuthScheme urlComponents.path = "/" + name @@ -123,6 +150,7 @@ private func urlForToken(name: String, issuer: String, factor: Generator.Factor, var queryItems = [ URLQueryItem(name: kQueryAlgorithmKey, value: stringForAlgorithm(algorithm)), URLQueryItem(name: kQueryDigitsKey, value: String(digits)), + URLQueryItem(name: kQueryRepresentationKey, value: stringForRepresentation(representation)), URLQueryItem(name: kQueryIssuerKey, value: issuer), ] @@ -166,10 +194,11 @@ private func token(from url: URL, secret externalSecret: Data? = nil) throws -> let algorithm = try queryItems.value(for: kQueryAlgorithmKey).map(algorithmFromString) ?? defaultAlgorithm let digits = try queryItems.value(for: kQueryDigitsKey).map(parseDigits) ?? defaultDigits + let representation = try queryItems.value(for: kQueryRepresentationKey).map(representationFromString) ?? defaultRepresentation guard let secret = try externalSecret ?? queryItems.value(for: kQuerySecretKey).map(parseSecret) else { throw DeserializationError.missingSecret } - let generator = try Generator(_factor: factor, secret: secret, algorithm: algorithm, digits: digits) + let generator = try Generator(_factor: factor, secret: secret, algorithm: algorithm, digits: digits, representation: representation) // Skip the leading "/" let fullName = String(url.path.dropFirst()) diff --git a/Tests/GeneratorTests.swift b/Tests/GeneratorTests.swift index 0c51ee1f..54799017 100644 --- a/Tests/GeneratorTests.swift +++ b/Tests/GeneratorTests.swift @@ -25,6 +25,7 @@ import XCTest import OneTimePassword +import Base32 class GeneratorTests: XCTestCase { func testInit() { @@ -38,7 +39,8 @@ class GeneratorTests: XCTestCase { factor: factor, secret: secret, algorithm: algorithm, - digits: digits + digits: digits, + representation: .numeric ) XCTAssertEqual(generator?.factor, factor) @@ -56,7 +58,8 @@ class GeneratorTests: XCTestCase { factor: otherFactor, secret: otherSecret, algorithm: otherAlgorithm, - digits: otherDigits + digits: otherDigits, + representation: .numeric ) XCTAssertEqual(otherGenerator?.factor, otherFactor) @@ -87,9 +90,9 @@ class GeneratorTests: XCTestCase { let timer = Generator.Factor.timer(period: period) let counter = Generator.Factor.counter(count) let secret = "12345678901234567890".data(using: String.Encoding.ascii)! - let hotp = Generator(factor: counter, secret: secret, algorithm: .sha1, digits: 6) + let hotp = Generator(factor: counter, secret: secret, algorithm: .sha1, digits: 6, representation: .numeric) .flatMap { try? $0.password(at: time) } - let totp = Generator(factor: timer, secret: secret, algorithm: .sha1, digits: 6) + let totp = Generator(factor: timer, secret: secret, algorithm: .sha1, digits: 6, representation: .numeric) .flatMap { try? $0.password(at: time) } XCTAssertEqual(hotp, totp, "TOTP with \(timer) should match HOTP with counter \(counter) at time \(time).") @@ -123,7 +126,8 @@ class GeneratorTests: XCTestCase { factor: .counter(0), secret: Data(), algorithm: .sha1, - digits: digits + digits: digits, + representation: .numeric ) // If the digits are invalid, password generation should throw an error let generatorIsValid = digitsAreValid @@ -138,7 +142,8 @@ class GeneratorTests: XCTestCase { factor: .timer(period: period), secret: Data(), algorithm: .sha1, - digits: digits + digits: digits, + representation: .numeric ) // If the digits or period are invalid, password generation should throw an error let generatorIsValid = digitsAreValid && periodIsValid @@ -156,7 +161,8 @@ class GeneratorTests: XCTestCase { factor: .timer(period: 30), secret: Data(), algorithm: .sha1, - digits: 6 + digits: 6, + representation: .numeric ) else { XCTFail("Failed to initialize a Generator.") return @@ -178,14 +184,14 @@ class GeneratorTests: XCTestCase { func testPasswordWithInvalidPeriod() { // It should not be possible to try to get a password from a generator with an invalid period, because the // generator initializer should fail when given an invalid period. - let generator = Generator(factor: .timer(period: 0), secret: Data(), algorithm: .sha1, digits: 8) + let generator = Generator(factor: .timer(period: 0), secret: Data(), algorithm: .sha1, digits: 8, representation: .numeric) XCTAssertNil(generator) } func testPasswordWithInvalidDigits() { // It should not be possible to try to get a password from a generator with an invalid digit count, because the // generator initializer should fail when given an invalid digit count. - let generator = Generator(factor: .timer(period: 30), secret: Data(), algorithm: .sha1, digits: 3) + let generator = Generator(factor: .timer(period: 30), secret: Data(), algorithm: .sha1, digits: 3, representation: .numeric) XCTAssertNil(generator) } @@ -206,7 +212,7 @@ class GeneratorTests: XCTestCase { 9: "520489", ] for (counter, expectedPassword) in expectedValues { - let generator = Generator(factor: .counter(counter), secret: secret, algorithm: .sha1, digits: 6) + let generator = Generator(factor: .counter(counter), secret: secret, algorithm: .sha1, digits: 6, representation: .numeric) let time = Date(timeIntervalSince1970: 0) let password = generator.flatMap { try? $0.password(at: time) } XCTAssertEqual(password, expectedPassword, @@ -233,7 +239,7 @@ class GeneratorTests: XCTestCase { for (algorithm, secretKey) in secretKeys { let secret = secretKey.data(using: String.Encoding.ascii)! - let generator = Generator(factor: .timer(period: 30), secret: secret, algorithm: algorithm, digits: 8) + let generator = Generator(factor: .timer(period: 30), secret: secret, algorithm: algorithm, digits: 8, representation: .numeric) for (timeSinceEpoch, expectedPassword) in zip(timesSinceEpoch, expectedValues[algorithm]!) { let time = Date(timeIntervalSince1970: timeSinceEpoch) @@ -257,7 +263,7 @@ class GeneratorTests: XCTestCase { ] for (algorithm, expectedPasswords) in expectedValues { - let generator = Generator(factor: .timer(period: 30), secret: secret, algorithm: algorithm, digits: 6) + let generator = Generator(factor: .timer(period: 30), secret: secret, algorithm: algorithm, digits: 6, representation: .numeric) for (timeSinceEpoch, expectedPassword) in zip(timesSinceEpoch, expectedPasswords) { let time = Date(timeIntervalSince1970: timeSinceEpoch) let password = generator.flatMap { try? $0.password(at: time) } @@ -266,4 +272,21 @@ class GeneratorTests: XCTestCase { } } } + + // The values in this test were extracted manually using a test Steam account. + func testSteamGuardTOTPValues() { + let secret = MF_Base32Codec.data(fromBase32String: "I6FMHELVR57Z2PCNB7D22MS6I2SRSQIB")! + let expectedValues: [TimeInterval: String] = [ + 1590895077: "Y4323", + 1590895162: "RFQNR", + ] + + let generator = Generator(factor: .timer(period: 30), secret: secret, algorithm: .sha1, digits: 5, representation: .steamguard) + for (timeSinceEpoch, expectedPassword) in expectedValues { + let time = Date(timeIntervalSince1970: timeSinceEpoch) + let password = generator.flatMap { try? $0.password(at: time) } + XCTAssertEqual(password, expectedPassword, + "Incorrect result for Steam Guard at \(timeSinceEpoch)") + } + } } diff --git a/Tests/TokenSerializationTests.swift b/Tests/TokenSerializationTests.swift index 4a278149..aac03bb4 100644 --- a/Tests/TokenSerializationTests.swift +++ b/Tests/TokenSerializationTests.swift @@ -97,7 +97,7 @@ class TokenSerializationTests: XCTestCase { let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) let items = urlComponents?.queryItems - let expectedItemCount = 4 + let expectedItemCount = 5 XCTAssertEqual(items?.count, expectedItemCount, "There shouldn't be any unexpected query arguments: \(url)") // swiftlint:enable vertical_parameter_alignment_on_call @@ -124,6 +124,17 @@ class TokenSerializationTests: XCTestCase { // Test digits XCTAssertEqual(queryArguments["digits"]!, String(digitNumber), "The digits value should be \"\(digitNumber)\"") + // Test representation + let representationString: String = { + switch $0 { + case .numeric: + return "numeric" + case .steamguard: + return "steamguard" + } + }(generator.representation) + XCTAssertEqual(queryArguments["representation"]!, representationString, + "The url query string should not contain the secret") // Test secret XCTAssertNil(queryArguments["secret"], "The url query string should not contain the secret") @@ -165,6 +176,87 @@ class TokenSerializationTests: XCTestCase { } } + func testSteamGuardSerialization() { + // There is only one valid configuration of factor, algorithm, digits, and representation for Steam Guard. + // Create the token + let secretString = "12345678901234567890" + guard let generator = Generator( + factor: .timer(period: 30), + secret: secretString.data(using: String.Encoding.ascii)!, + algorithm: .sha1, + digits: 5, + representation: .steamguard + ) else { + XCTFail("Failed to construct Generator.") + return + } + + let name = "testaccount" + let issuer = "Steam" + let token = Token( + name: name, + issuer: issuer, + generator: generator + ) + + // Serialize + guard let url = try? token.toURL() else { + XCTFail("Failed to convert Token to URL") + return + } + + // Test scheme + XCTAssertEqual(url.scheme, kOTPScheme, "The url scheme should be \"\(kOTPScheme)\"") + // Test Factor + let expectedHost: String = kOTPTokenTypeTimerHost + XCTAssertEqual(url.host!, expectedHost, "The url host should be \"\(expectedHost)\"") + // Test name + XCTAssertEqual(url.path, "/" + name, "The url path should be \"/\(name)\"") + + let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) + let queryArguments: [String: String] = urlComponents?.queryItems?.reduce(into: [:]) { acc, cur in + acc[cur.name] = cur.value + } ?? [:] + let expectedItemCount = 5 + XCTAssertEqual(queryArguments.count, expectedItemCount, + "There shouldn't be any unexpected query arguments: \(url)") + // swiftlint:enable vertical_parameter_alignment_on_call + + // Test algorithm + let algorithmString = "SHA1" + XCTAssertEqual(queryArguments["algorithm"]!, algorithmString, + "The algorithm value should be \"\(algorithmString)\"") + // Test digits + let digitNumber = 5 + XCTAssertEqual(queryArguments["digits"]!, String(digitNumber), + "The digits value should be \"\(digitNumber)\"") + // Test representation + let representationString = "steamguard" + XCTAssertEqual(queryArguments["representation"]!, representationString, + "The url query string should not contain the secret") + // Test secret + XCTAssertNil(queryArguments["secret"], + "The url query string should not contain the secret") + + // Test period + XCTAssertEqual(queryArguments["period"]!, String(Int(30)), + "The period value should be \"30\"") + // Test counter + XCTAssertNil(queryArguments["counter"], + "The url query string should not contain the counter") + + // Test issuer + XCTAssertEqual(queryArguments["issuer"]!, issuer, + "The issuer value should be \"\(issuer)\"") + + // Check url again + guard let checkURL = try? token.toURL() else { + XCTFail("Failed to convert Token to URL") + return + } + XCTAssertEqual(url, checkURL, "Repeated calls to url() should return the same result!") + } + func testTokenWithDefaultCounter() { let tokenURLString = "otpauth://hotp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4" guard let tokenURL = URL(string: tokenURLString) else {