Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion OneTimePasswordLegacyTests/OTPTokenSerializationTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
82 changes: 62 additions & 20 deletions Sources/Generator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Copy Markdown

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)

try? self.init(_factor: factor, secret: secret, algorithm: algorithm, digits: digits, representation: representation)
Copy link
Copy Markdown

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 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 {
Copy link
Copy Markdown

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 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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
}
Expand Down
35 changes: 32 additions & 3 deletions Sources/Token+URL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ public extension Token {
issuer: issuer,
factor: generator.factor,
algorithm: generator.algorithm,
digits: generator.digits
digits: generator.digits,
representation: generator.representation
)
}

Expand Down Expand Up @@ -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

Expand All @@ -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"

Expand All @@ -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:
Expand All @@ -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 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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)
Line Length Violation: Line should be 120 characters or less: currently 183 characters (line_length)

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),
]

Expand Down Expand Up @@ -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
Copy link
Copy Markdown

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 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)
Copy link
Copy Markdown

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 136 characters (line_length)


// Skip the leading "/"
let fullName = String(url.path.dropFirst())
Expand Down
47 changes: 35 additions & 12 deletions Tests/GeneratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import XCTest
import OneTimePassword
import Base32

class GeneratorTests: XCTestCase {
func testInit() {
Expand All @@ -38,7 +39,8 @@ class GeneratorTests: XCTestCase {
factor: factor,
secret: secret,
algorithm: algorithm,
digits: digits
digits: digits,
representation: .numeric
)

XCTAssertEqual(generator?.factor, factor)
Expand All @@ -56,7 +58,8 @@ class GeneratorTests: XCTestCase {
factor: otherFactor,
secret: otherSecret,
algorithm: otherAlgorithm,
digits: otherDigits
digits: otherDigits,
representation: .numeric
)

XCTAssertEqual(otherGenerator?.factor, otherFactor)
Expand Down Expand Up @@ -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).")
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Copy link
Copy Markdown

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 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)
Copy link
Copy Markdown

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 132 characters (line_length)

XCTAssertNil(generator)
}

Expand All @@ -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)
Copy link
Copy Markdown

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 135 characters (line_length)

let time = Date(timeIntervalSince1970: 0)
let password = generator.flatMap { try? $0.password(at: time) }
XCTAssertEqual(password, expectedPassword,
Expand All @@ -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)
Copy link
Copy Markdown

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 140 characters (line_length)


for (timeSinceEpoch, expectedPassword) in zip(timesSinceEpoch, expectedValues[algorithm]!) {
let time = Date(timeIntervalSince1970: timeSinceEpoch)
Expand All @@ -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)
Copy link
Copy Markdown

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 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) }
Expand All @@ -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",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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)
Copy link
Copy Markdown

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 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)")
}
}
}
Loading