-
Notifications
You must be signed in to change notification settings - Fork 110
Add support for generating Steam Guard codes #225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Line Length Violation: Line should be 120 characters or less: currently 125 characters (line_length) |
||
| } | ||
|
|
||
| // 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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Line Length Violation: Line should be 120 characters or less: currently 131 characters (line_length) |
||
| 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-<radix> representation of the hash. | ||
| return String((0..<digits).map { _ in | ||
| let digit = truncatedHash % radix | ||
| let character = alphabet[Int(digit)] | ||
| truncatedHash /= radix | ||
| return character | ||
| }) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// An error type enum representing the various errors a `Generator` can throw when computing a | ||
| /// password. | ||
| public enum Error: Swift.Error { | ||
|
|
@@ -208,11 +245,16 @@ public struct Generator: Equatable { | |
| private extension Generator { | ||
| // MARK: Validation | ||
|
|
||
| static func validateDigits(_ digits: Int) throws { | ||
| // https://tools.ietf.org/html/rfc4226#section-5.3 states "Implementations MUST extract a | ||
| // 6-digit code at a minimum and possibly 7 and 8-digit codes." | ||
| let acceptableDigits = 6...8 | ||
| guard acceptableDigits.contains(digits) else { | ||
| static func validateDigits(_ digits: Int, representation: Representation) throws { | ||
| switch (representation, digits) { | ||
| case (.numeric, 6...8): | ||
| // https://tools.ietf.org/html/rfc4226#section-5.3 states "Implementations MUST | ||
| // extract a 6-digit code at a minimum and possibly 7 and 8-digit codes." | ||
| break | ||
| case (.steamguard, 5): | ||
| // Steam Guard codes use 5 digits with a larger base. | ||
| break | ||
| default: | ||
| throw Error.invalidDigits | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,7 +36,8 @@ public extension Token { | |
| issuer: issuer, | ||
| factor: generator.factor, | ||
| algorithm: generator.algorithm, | ||
| digits: generator.digits | ||
| digits: generator.digits, | ||
| representation: generator.representation | ||
| ) | ||
| } | ||
|
|
||
|
|
@@ -69,10 +70,12 @@ internal enum DeserializationError: Swift.Error { | |
| case invalidSecret(String) | ||
| case invalidAlgorithm(String) | ||
| case invalidDigits(String) | ||
| case invalidRepresentation(String) | ||
| } | ||
|
|
||
| private let defaultAlgorithm: Generator.Algorithm = .sha1 | ||
| private let defaultDigits: Int = 6 | ||
| private let defaultRepresentation: Generator.Representation = .numeric | ||
| private let defaultCounter: UInt64 = 0 | ||
| private let defaultPeriod: TimeInterval = 30 | ||
|
|
||
|
|
@@ -81,6 +84,7 @@ private let kQueryAlgorithmKey = "algorithm" | |
| private let kQuerySecretKey = "secret" | ||
| private let kQueryCounterKey = "counter" | ||
| private let kQueryDigitsKey = "digits" | ||
| private let kQueryRepresentationKey = "representation" | ||
| private let kQueryPeriodKey = "period" | ||
| private let kQueryIssuerKey = "issuer" | ||
|
|
||
|
|
@@ -91,6 +95,9 @@ private let kAlgorithmSHA1 = "SHA1" | |
| private let kAlgorithmSHA256 = "SHA256" | ||
| private let kAlgorithmSHA512 = "SHA512" | ||
|
|
||
| private let kRepresentationNumeric = "numeric" | ||
| private let kRepresentationSteamGuard = "steamguard" | ||
|
|
||
| private func stringForAlgorithm(_ algorithm: Generator.Algorithm) -> String { | ||
| switch algorithm { | ||
| case .sha1: | ||
|
|
@@ -115,14 +122,35 @@ 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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Function Parameter Count Violation: Function should have 5 parameters or less: it currently has 6 (function_parameter_count) |
||
| var urlComponents = URLComponents() | ||
| urlComponents.scheme = kOTPAuthScheme | ||
| urlComponents.path = "/" + name | ||
|
|
||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Line Length Violation: Line should be 120 characters or less: currently 130 characters (line_length) |
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Line Length Violation: Line should be 120 characters or less: currently 136 characters (line_length) |
||
|
|
||
| // Skip the leading "/" | ||
| let fullName = String(url.path.dropFirst()) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Line Length Violation: Line should be 120 characters or less: currently 131 characters (line_length) |
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Line Length Violation: Line should be 120 characters or less: currently 132 characters (line_length) |
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Line Length Violation: Line should be 120 characters or less: currently 135 characters (line_length) |
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Line Length Violation: Line should be 120 characters or less: currently 140 characters (line_length) |
||
|
|
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Line Length Violation: Line should be 120 characters or less: currently 140 characters (line_length) |
||
| 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", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trailing Comma Violation: Collection literals should not have trailing commas. (trailing_comma) |
||
| ] | ||
|
|
||
| let generator = Generator(factor: .timer(period: 30), secret: secret, algorithm: .sha1, digits: 5, representation: .steamguard) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Line Length Violation: Line should be 120 characters or less: currently 135 characters (line_length) |
||
| 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)") | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Line Length Violation: Line should be 120 characters or less: currently 126 characters (line_length)