diff --git a/.codecov.yml b/.codecov.yml index 930c7dbd..46fd814f 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,6 +1,16 @@ # Configuration for Codecov (https://codecov.io) +codecov: + # Use `develop` as the default branch + branch: develop + +ignore: + - Tests + - OneTimePasswordLegacyTests + coverage: - ignore: - - "Tests" - - "OneTimePasswordLegacyTests" + status: + patch: + default: + # Allow patch to be 0% covered without marking a PR with a failing status. + target: 0 diff --git a/.swift-version b/.swift-version index a3ec5a4b..5186d070 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -3.2 +4.0 diff --git a/.swiftlint.yml b/.swiftlint.yml index fb3eebbb..7b047986 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -3,36 +3,51 @@ excluded: - Carthage opt_in_rules: + - array_init - attributes - closure_end_indentation - closure_spacing - conditional_returns_on_newline + - contains_over_first_not_nil + - discouraged_object_literal + - discouraged_optional_boolean - empty_count + - explicit_enum_raw_value - explicit_init + - extension_access_modifier + - fatal_error_message - file_header - first_where -# - missing_docs + - implicit_return + - implicitly_unwrapped_optional + - joined_default_parameter + - let_var_whitespace + - literal_expression_end_indentation + - multiline_parameters - nimble_operator - operator_usage_whitespace - overridden_super_call + - override_in_extension + - pattern_matching_keywords - private_outlet - prohibited_super_call - redundant_nil_coalescing + - single_test_class + - sorted_first_last - switch_case_on_newline - - vertical_whitespace + - unneeded_parentheses_in_closure_argument + - vertical_parameter_alignment_on_call + - yoda_condition disabled_rules: - colon - comma - cyclomatic_complexity - - function_body_length - - syntactic_sugar - - valid_docs trailing_comma: mandatory_comma: true line_length: - warning: 120 + ignores_function_declarations: true file_header: required_pattern: | diff --git a/.travis.yml b/.travis.yml index 18e47890..347f0a3b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,28 +5,26 @@ language: objective-c xcode_workspace: OneTimePassword.xcworkspace xcode_scheme: OneTimePassword (iOS) -osx_image: xcode8.2 +osx_image: xcode9 env: - RUNTIME="iOS 8.1" DEVICE="iPad 2" - - RUNTIME="iOS 8.2" DEVICE="iPhone 4s" - - RUNTIME="iOS 8.3" DEVICE="iPhone 5" - - RUNTIME="iOS 8.4" DEVICE="iPhone 5s" - - RUNTIME="iOS 9.0" DEVICE="iPhone 6" - - RUNTIME="iOS 9.1" DEVICE="iPhone 6 Plus" - - RUNTIME="iOS 9.2" DEVICE="iPhone 6s" - - RUNTIME="iOS 9.3" DEVICE="iPhone 6s Plus" + - RUNTIME="iOS 8.4" DEVICE="iPhone 4s" + - RUNTIME="iOS 9.0" DEVICE="iPhone 5s" + - RUNTIME="iOS 9.3" DEVICE="iPhone 6s" - RUNTIME="iOS 10.0" DEVICE="iPhone SE" - - RUNTIME="iOS 10.1" DEVICE="iPhone 7" - - RUNTIME="iOS 10.2" DEVICE="iPhone 7 Plus" + - RUNTIME="iOS 10.3" DEVICE="iPhone 7 Plus" + - RUNTIME="iOS 11.0" DEVICE="iPhone X" # Include builds for watchOS matrix: include: - xcode_scheme: OneTimePassword (watchOS) - env: BUILD_ONLY="YES" RUNTIME="watchOS 3.1" DEVICE="Apple Watch Series 2 - 42mm" + env: BUILD_ONLY="YES" RUNTIME="watchOS 4.0" DEVICE="Apple Watch Series 3 - 38mm" - xcode_scheme: OneTimePassword (watchOS) - env: BUILD_ONLY="YES" RUNTIME="watchOS 2.0" DEVICE="Apple Watch - 38mm" + env: BUILD_ONLY="YES" RUNTIME="watchOS 3.2" DEVICE="Apple Watch Series 2 - 42mm" + - xcode_scheme: OneTimePassword (watchOS) + env: BUILD_ONLY="YES" RUNTIME="watchOS 2.2" DEVICE="Apple Watch - 38mm" # Check out nested dependencies before_install: git submodule update --init --recursive diff --git a/CHANGELOG.md b/CHANGELOG.md index 164335de..9815d969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,34 @@ +## [3.1][] (2018-03-27) +- Upgrade to Swift 4 and Xcode 9. +([#147](https://github.com/mattrubin/OneTimePassword/pull/147), +[#149](https://github.com/mattrubin/OneTimePassword/pull/149), +[#151](https://github.com/mattrubin/OneTimePassword/pull/151), +[#153](https://github.com/mattrubin/OneTimePassword/pull/153), +[#160](https://github.com/mattrubin/OneTimePassword/pull/160)) +- Handle keychain deserialization errors. +([#161](https://github.com/mattrubin/OneTimePassword/pull/161)) +- Refactor token URL parsing. +([#150](https://github.com/mattrubin/OneTimePassword/pull/150)) +- Refactor Generator validation. +([#155](https://github.com/mattrubin/OneTimePassword/pull/155)) +- Update SwiftLint configuration and improve code formatting. +([#148](https://github.com/mattrubin/OneTimePassword/pull/148), +[#154](https://github.com/mattrubin/OneTimePassword/pull/154), +[#159](https://github.com/mattrubin/OneTimePassword/pull/159)) +- Update CodeCov configuration. +([#162](https://github.com/mattrubin/OneTimePassword/pull/162)) + + ## [3.0.1][] (2018-03-08) - Fix an issue where CocoaPods was trying to build OneTimePassword with Swift 4. ([#157](https://github.com/mattrubin/OneTimePassword/pull/157)) - Fix the Base32-decoding function in the token creation example code. ([#134](https://github.com/mattrubin/OneTimePassword/pull/134)) - Tweak the Travis CI configuration to work around test timeout flakiness. ([#131](https://github.com/mattrubin/OneTimePassword/pull/131)) - Clean up some old Keychain code which was used to provide Xcode 7 backwards compatibility. ([#133](https://github.com/mattrubin/OneTimePassword/pull/133)) + ## [3.0][] (2017-02-07) - Convert to Swift 3 and update the library API to follow the Swift [API Design Guidelines](https://swift.org/documentation/api-design-guidelines/). ([#74](https://github.com/mattrubin/OneTimePassword/pull/74), @@ -121,8 +143,9 @@ Changes between prerelease versions of OneTimePassword version 2 can be found be ## [1.0.0][] (2014-07-17) -[develop]: https://github.com/mattrubin/OneTimePassword/compare/3.0.1...develop +[develop]: https://github.com/mattrubin/OneTimePassword/compare/3.1...develop +[3.1]: https://github.com/mattrubin/OneTimePassword/compare/3.0.1...3.1 [3.0.1]: https://github.com/mattrubin/OneTimePassword/compare/3.0...3.0.1 [3.0]: https://github.com/mattrubin/OneTimePassword/compare/2.1.1...3.0 [2.1.1]: https://github.com/mattrubin/OneTimePassword/compare/2.1...2.1.1 diff --git a/Cartfile b/Cartfile index 9f92ec46..dda7e5c9 100644 --- a/Cartfile +++ b/Cartfile @@ -1,3 +1,3 @@ # Configuration for Carthage (https://github.com/Carthage/Carthage) -github "mattrubin/Base32" "1.1.2+carthage" +github "mattrubin/Base32" "xcode9" diff --git a/Cartfile.private b/Cartfile.private index 3291dc67..3269cf46 100644 --- a/Cartfile.private +++ b/Cartfile.private @@ -1,3 +1,3 @@ # Configuration for Carthage (https://github.com/Carthage/Carthage) -github "jspahrsummers/xcconfigs" ~> 0.10 +github "jspahrsummers/xcconfigs" ~> 0.11 diff --git a/Cartfile.resolved b/Cartfile.resolved index 333b0889..0f12af37 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,2 +1,2 @@ -github "mattrubin/Base32" "1.1.2+carthage" -github "jspahrsummers/xcconfigs" "0.10" +github "jspahrsummers/xcconfigs" "0.11" +github "mattrubin/Base32" "xcode9" diff --git a/Carthage/Checkouts/Base32 b/Carthage/Checkouts/Base32 index 1af92986..24f64bf8 160000 --- a/Carthage/Checkouts/Base32 +++ b/Carthage/Checkouts/Base32 @@ -1 +1 @@ -Subproject commit 1af92986863c852c2343e9ae3b427b81abc6a5d6 +Subproject commit 24f64bf81b15c815019f37cf64cfbb3a253e2bea diff --git a/Carthage/Checkouts/xcconfigs b/Carthage/Checkouts/xcconfigs index cc451b08..40f9bcc6 160000 --- a/Carthage/Checkouts/xcconfigs +++ b/Carthage/Checkouts/xcconfigs @@ -1 +1 @@ -Subproject commit cc451b08e052b6146f5caf66bc1120420c529c7b +Subproject commit 40f9bcc63752cdd95deee267d2fbf9da09a9f6f2 diff --git a/OneTimePassword.podspec b/OneTimePassword.podspec index 20e1cc8a..710c9760 100644 --- a/OneTimePassword.podspec +++ b/OneTimePassword.podspec @@ -1,11 +1,11 @@ Pod::Spec.new do |s| s.name = "OneTimePassword" - s.version = "3.0.1" + s.version = "3.1" s.summary = "A small library for generating TOTP and HOTP one-time passwords." s.homepage = "https://github.com/mattrubin/OneTimePassword" s.license = "MIT" s.author = "Matt Rubin" - s.swift_version = "3.2" + s.swift_version = "4.0" s.ios.deployment_target = "8.0" s.watchos.deployment_target = "2.0" s.source = { :git => "https://github.com/mattrubin/OneTimePassword.git", :tag => s.version } diff --git a/OneTimePassword.xcodeproj/project.pbxproj b/OneTimePassword.xcodeproj/project.pbxproj index 85c5e6f0..6b08df04 100644 --- a/OneTimePassword.xcodeproj/project.pbxproj +++ b/OneTimePassword.xcodeproj/project.pbxproj @@ -40,7 +40,7 @@ C97142361C1155FC0063B37E /* OTPToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9DC7ECC196C4D3D00B50C82 /* OTPToken.swift */; }; C97142371C1156DB0063B37E /* OneTimePassword.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C97C82381946E51D00FD9F4C /* OneTimePassword.framework */; }; C97C82441946E51D00FD9F4C /* OneTimePassword.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C97C82381946E51D00FD9F4C /* OneTimePassword.framework */; }; - C9A486C8196F38C800524F51 /* OTPTokenSerializationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = C93A2515196AFE1100F86892 /* OTPTokenSerializationTests.m */; }; + C9A486C8196F38C800524F51 /* OTPTokenSerializationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = C93A2515196AFE1100F86892 /* OTPTokenSerializationTests.m */; settings = {COMPILER_FLAGS = "-Wno-nullable-to-nonnull-conversion"; }; }; C9A486C9196F38CD00524F51 /* OTPTypeStrings.m in Sources */ = {isa = PBXBuildFile; fileRef = C9C9FE25196D181800C7ACEE /* OTPTypeStrings.m */; }; C9B2A19C199A7F1B00BC4A8A /* EquatableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9B2A19B199A7F1B00BC4A8A /* EquatableTests.swift */; }; C9B77D771C03078B00BAF6BF /* KeychainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C93A2514196AFE1100F86892 /* KeychainTests.swift */; }; @@ -521,7 +521,7 @@ attributes = { LastSwiftMigration = 0700; LastSwiftUpdateCheck = 0700; - LastUpgradeCheck = 0800; + LastUpgradeCheck = 0900; ORGANIZATIONNAME = "Matt Rubin"; TargetAttributes = { 5B39F4931DBD06BA00CD2DAB = { @@ -530,22 +530,23 @@ }; C97C82371946E51D00FD9F4C = { CreatedOnToolsVersion = 6.0; - LastSwiftMigration = 0800; + LastSwiftMigration = 0900; ProvisioningStyle = Manual; }; C97C82421946E51D00FD9F4C = { CreatedOnToolsVersion = 6.0; - LastSwiftMigration = 0800; + LastSwiftMigration = 0900; ProvisioningStyle = Manual; TestTargetID = FD6C3C0B1E0200F800EC4528; }; C9A486B2196F352E00524F51 = { CreatedOnToolsVersion = 6.0; - LastSwiftMigration = 0800; + LastSwiftMigration = 0900; ProvisioningStyle = Manual; }; FD6C3C0B1E0200F800EC4528 = { CreatedOnToolsVersion = 8.2; + LastSwiftMigration = 0900; ProvisioningStyle = Automatic; }; FDD3B8661DD6E59E00F87980 = { @@ -731,7 +732,7 @@ baseConfigurationReference = C996EC2C1A74D5830076B105 /* Debug.xcconfig */; buildSettings = { IPHONEOS_DEPLOYMENT_TARGET = 8.0; - SWIFT_VERSION = 3.0; + SWIFT_VERSION = 4.0; WATCHOS_DEPLOYMENT_TARGET = 2.0; }; name = Debug; @@ -741,7 +742,7 @@ baseConfigurationReference = C996EC2E1A74D5830076B105 /* Release.xcconfig */; buildSettings = { IPHONEOS_DEPLOYMENT_TARGET = 8.0; - SWIFT_VERSION = 3.0; + SWIFT_VERSION = 4.0; WATCHOS_DEPLOYMENT_TARGET = 2.0; }; name = Release; diff --git a/OneTimePassword.xcodeproj/xcshareddata/xcschemes/OneTimePassword (iOS).xcscheme b/OneTimePassword.xcodeproj/xcshareddata/xcschemes/OneTimePassword (iOS).xcscheme index 6b03da75..97fa36ad 100644 --- a/OneTimePassword.xcodeproj/xcshareddata/xcschemes/OneTimePassword (iOS).xcscheme +++ b/OneTimePassword.xcodeproj/xcshareddata/xcschemes/OneTimePassword (iOS).xcscheme @@ -1,6 +1,6 @@ @@ -66,6 +67,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + language = "" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" diff --git a/OneTimePassword.xcodeproj/xcshareddata/xcschemes/OneTimePassword (watchOS).xcscheme b/OneTimePassword.xcodeproj/xcshareddata/xcschemes/OneTimePassword (watchOS).xcscheme index c5f6ec4d..af2297cd 100644 --- a/OneTimePassword.xcodeproj/xcshareddata/xcschemes/OneTimePassword (watchOS).xcscheme +++ b/OneTimePassword.xcodeproj/xcshareddata/xcschemes/OneTimePassword (watchOS).xcscheme @@ -1,6 +1,6 @@ @@ -46,6 +47,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + language = "" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" diff --git a/OneTimePasswordLegacyTests/OTPToken.swift b/OneTimePasswordLegacyTests/OTPToken.swift index 9c4945de..195bf2cb 100644 --- a/OneTimePasswordLegacyTests/OTPToken.swift +++ b/OneTimePasswordLegacyTests/OTPToken.swift @@ -32,14 +32,14 @@ import OneTimePassword public final class OTPToken: NSObject { required public override init() {} - public var name: String = OTPToken.defaultName - public var issuer: String = OTPToken.defaultIssuer - public var type: OTPTokenType = .timer - public var secret: Data = Data() - public var algorithm: OTPAlgorithm = OTPToken.defaultAlgorithm - public var digits: UInt = OTPToken.defaultDigits - public var period: TimeInterval = OTPToken.defaultPeriod - public var counter: UInt64 = OTPToken.defaultInitialCounter + @objc public var name: String = OTPToken.defaultName + @objc public var issuer: String = OTPToken.defaultIssuer + @objc public var type: OTPTokenType = .timer + @objc public var secret: Data = Data() + @objc public var algorithm: OTPAlgorithm = OTPToken.defaultAlgorithm + @objc public var digits: UInt = OTPToken.defaultDigits + @objc public var period: TimeInterval = OTPToken.defaultPeriod + @objc public var counter: UInt64 = OTPToken.defaultInitialCounter private static let defaultName: String = "" private static let defaultIssuer: String = "" @@ -66,11 +66,12 @@ public final class OTPToken: NSObject { } } - fileprivate convenience init(token: Token) { + private convenience init(token: Token) { self.init() update(with: token) } + @objc public func validate() -> Bool { return (tokenForOTPToken(self) != nil) } @@ -90,6 +91,7 @@ public extension OTPToken { return self.init(token: token) } + @objc func url() -> URL? { guard let token = tokenForOTPToken(self) else { return nil @@ -100,6 +102,7 @@ public extension OTPToken { // MARK: Enums +// swiftlint:disable explicit_enum_raw_value @objc public enum OTPTokenType: UInt8 { case counter @@ -112,6 +115,7 @@ public enum OTPAlgorithm: UInt32 { @objc(OTPAlgorithmSHA256) case sha256 @objc(OTPAlgorithmSHA512) case sha512 } +// swiftlint:enable explicit_enum_raw_value // MARK: Conversion diff --git a/OneTimePasswordLegacyTests/OTPTokenSerializationTests.m b/OneTimePasswordLegacyTests/OTPTokenSerializationTests.m index 01ac3521..449e2e98 100644 --- a/OneTimePasswordLegacyTests/OTPTokenSerializationTests.m +++ b/OneTimePasswordLegacyTests/OTPTokenSerializationTests.m @@ -372,9 +372,11 @@ - (void)testTokenWithInvalidURLs { NSArray *badURLs = @[@"http://foo", // invalid scheme @"otpauth://foo", // invalid type + @"otpauth:///bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4", // missing type @"otpauth://totp/bar", // missing secret @"otpauth://totp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4&period=0", // invalid period @"otpauth://totp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4&period=x", // non-numeric period + @"otpauth://totp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4&period=30&period=60", // multiple period @"otpauth://totp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4&algorithm=MD5", // invalid algorithm @"otpauth://totp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4&digits=2", // invalid digits @"otpauth://totp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4&digits=x", // non-numeric digits diff --git a/README.md b/README.md index 62ea3e3c..8d98ce0f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The OneTimePassword library is the core of [Authenticator][]. It can generate bo Add the following line to your [Cartfile][]: ````config -github "mattrubin/OneTimePassword" ~> 3.0 +github "mattrubin/OneTimePassword" ~> 3.1 ```` Then run `carthage update OneTimePassword` to install the latest version of the framework. @@ -39,7 +39,7 @@ Be sure to check the Carthage README file for the latest instructions on [adding Add the following line to your [Podfile][]: ````ruby -pod 'OneTimePassword', '~> 3.0' +pod 'OneTimePassword', '~> 3.1' ```` OneTimePassword, like all pods written in Swift, can only be integrated as a framework. Make sure to add the line `use_frameworks!` to your Podfile or target to opt into frameworks instead of static libraries. @@ -52,8 +52,9 @@ Then run `pod install` to install the latest version of the framework. ## Usage -> The [latest version][swift-3] of OneTimePassword targets Swift 3. To use OneTimePassword with Swift 2.3, check out the [`swift-2.3` branch][swift-2.3] and the [2.x releases][releases]. To use OneTimePassword in an Objective-C based project, check out the [`objc` branch][objc] and the [1.x releases][releases]. +> The [latest version][swift-4] of OneTimePassword uses Swift 4, and can be linked with Swift 3.2 projects using the Swift compiler's [compatibility mode](https://swift.org/blog/swift-4-0-released/#new-compatibility-modes). To use OneTimePassword with earlier versions of Swift, check out the [`swift-3`][swift-3] and [`swift-2.3`][swift-2.3] branches. To use OneTimePassword in an Objective-C based project, check out the [`objc` branch][objc] and the [1.x releases][releases]. +[swift-4]: https://github.com/mattrubin/OneTimePassword/tree/swift-4 [swift-3]: https://github.com/mattrubin/OneTimePassword/tree/swift-3 [swift-2.3]: https://github.com/mattrubin/OneTimePassword/tree/swift-2.3 [objc]: https://github.com/mattrubin/OneTimePassword/tree/objc diff --git a/Sources/Generator.swift b/Sources/Generator.swift index c9c0b467..664ca289 100644 --- a/Sources/Generator.swift +++ b/Sources/Generator.swift @@ -49,9 +49,17 @@ public struct Generator: Equatable { /// - 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) { - guard Generator.validateFactor(factor) && Generator.validateDigits(digits) else { - return nil - } + try? self.init(_factor: factor, secret: secret, algorithm: algorithm, digits: digits) + } + + // 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 { + try Generator.validateFactor(factor) + try Generator.validateDigits(digits) + self.factor = factor self.secret = secret self.algorithm = algorithm @@ -68,9 +76,7 @@ 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 { - guard Generator.validateDigits(digits) else { - throw Error.invalidDigits - } + try Generator.validateDigits(digits) let counter = try factor.counterValue(at: time) // Ensure the counter value is big-endian @@ -110,16 +116,16 @@ public struct Generator: Equatable { /// - requires: The next generator is valid. public func successor() -> Generator { switch factor { - case .counter(let counter): - // Update a counter-based generator by incrementing the counter. Force-unwrapping should - // be safe here, since any valid generator should have a valid successor. - let nextGenerator = Generator( - factor: .counter(counter + 1), + case .counter(let counterValue): + // Update a counter-based generator by incrementing the counter. + // Force-trying should be safe here, since any valid generator should have a valid successor. + // swiftlint:disable:next force_try + return try! Generator( + _factor: .counter(counterValue + 1), secret: secret, algorithm: algorithm, digits: digits ) - return nextGenerator! case .timer: // A timer-based generator does not need to be updated. return self @@ -156,12 +162,8 @@ public struct Generator: Equatable { return counter case .timer(let period): let timeSinceEpoch = time.timeIntervalSince1970 - guard Generator.validateTime(timeSinceEpoch) else { - throw Error.invalidTime - } - guard Generator.validatePeriod(period) else { - throw Error.invalidPeriod - } + try Generator.validateTime(timeSinceEpoch) + try Generator.validatePeriod(period) return UInt64(timeSinceEpoch / period) } } @@ -215,30 +217,36 @@ public func == (lhs: Generator.Factor, rhs: Generator.Factor) -> Bool { private extension Generator { // MARK: Validation - static func validateDigits(_ digits: Int) -> Bool { + 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 - return acceptableDigits.contains(digits) + guard acceptableDigits.contains(digits) else { + throw Error.invalidDigits + } } - static func validateFactor(_ factor: Factor) -> Bool { + static func validateFactor(_ factor: Factor) throws { switch factor { case .counter: - return true + return case .timer(let period): - return validatePeriod(period) + try validatePeriod(period) } } - static func validatePeriod(_ period: TimeInterval) -> Bool { + static func validatePeriod(_ period: TimeInterval) throws { // The period must be positive and non-zero to produce a valid counter value. - return (period > 0) + guard period > 0 else { + throw Error.invalidPeriod + } } - static func validateTime(_ timeSinceEpoch: TimeInterval) -> Bool { + static func validateTime(_ timeSinceEpoch: TimeInterval) throws { // The time must be positive to produce a valid counter value. - return (timeSinceEpoch >= 0) + guard timeSinceEpoch >= 0 else { + throw Error.invalidTime + } } } @@ -250,7 +258,7 @@ private extension String { /// /// - returns: A new string padded to the given length. func padded(with character: Character, toLength length: Int) -> String { - let paddingCount = length - characters.count + let paddingCount = length - count guard paddingCount > 0 else { return self } diff --git a/Sources/Info.plist b/Sources/Info.plist index bf64edb3..54106c8d 100644 --- a/Sources/Info.plist +++ b/Sources/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 3.0.1 + 3.1 CFBundleSignature ???? CFBundleVersion - 3.0.1 + 3.1 NSPrincipalClass diff --git a/Sources/Keychain.swift b/Sources/Keychain.swift index f72dce08..e84a1a09 100644 --- a/Sources/Keychain.swift +++ b/Sources/Keychain.swift @@ -40,14 +40,14 @@ public final class Keychain { /// - throws: A `Keychain.Error` if an error occurred. /// - returns: The persistent token, or `nil` if no token matched the given identifier. public func persistentToken(withIdentifier identifier: Data) throws -> PersistentToken? { - return try keychainItem(forPersistentRef: identifier).flatMap(PersistentToken.init(keychainDictionary:)) + return try keychainItem(forPersistentRef: identifier).map(PersistentToken.init(keychainDictionary:)) } /// Returns the set of all persistent tokens found in the keychain. /// /// - throws: A `Keychain.Error` if an error occurred. public func allPersistentTokens() throws -> Set { - return Set(try allKeychainItems().flatMap(PersistentToken.init(keychainDictionary:))) + return Set(try allKeychainItems().map(PersistentToken.init(keychainDictionary:))) } // MARK: Write @@ -74,7 +74,7 @@ public final class Keychain { public func update(_ persistentToken: PersistentToken, with token: Token) throws -> PersistentToken { let attributes = try token.keychainAttributes() try updateKeychainItem(forPersistentRef: persistentToken.identifier, - withAttributes: attributes) + withAttributes: attributes) return PersistentToken(token: token, identifier: persistentToken.identifier) } @@ -123,15 +123,28 @@ private extension Token { } private extension PersistentToken { - init?(keychainDictionary: NSDictionary) { - guard let urlData = keychainDictionary[kSecAttrGeneric as String] as? Data, - let urlString = String(data: urlData, encoding: urlStringEncoding), - let secret = keychainDictionary[kSecValueData as String] as? Data, - let keychainItemRef = keychainDictionary[kSecValuePersistentRef as String] as? Data, - let url = URL(string: urlString as String), - let token = Token(url: url, secret: secret) else { - return nil + enum DeserializationError: Error { + case missingData + case missingSecret + case missingPersistentRef + case unreadableData + } + + init(keychainDictionary: NSDictionary) throws { + guard let urlData = keychainDictionary[kSecAttrGeneric as String] as? Data else { + throw DeserializationError.missingData + } + guard let secret = keychainDictionary[kSecValueData as String] as? Data else { + throw DeserializationError.missingSecret + } + guard let keychainItemRef = keychainDictionary[kSecValuePersistentRef as String] as? Data else { + throw DeserializationError.missingPersistentRef + } + guard let urlString = String(data: urlData, encoding: urlStringEncoding), + let url = URL(string: urlString) else { + throw DeserializationError.unreadableData } + let token = try Token(_url: url, secret: secret) self.init(token: token, identifier: keychainItemRef) } } diff --git a/Sources/Token+URL.swift b/Sources/Token+URL.swift index 9501587b..fbc0aa26 100644 --- a/Sources/Token+URL.swift +++ b/Sources/Token+URL.swift @@ -26,11 +26,11 @@ import Foundation import Base32 -extension Token { +public extension Token { // MARK: Serialization /// Serializes the token to a URL. - public func toURL() throws -> URL { + func toURL() throws -> URL { return try urlForToken( name: name, issuer: issuer, @@ -41,12 +41,16 @@ extension Token { } /// Attempts to initialize a token represented by the give URL. - public init?(url: URL, secret: Data? = nil) { - if let token = token(from: url, secret: secret) { - self = token - } else { - return nil - } + init?(url: URL, secret: Data? = nil) { + try? self.init(_url: url, secret: secret) + } + + // 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(_url url: URL, secret: Data? = nil) throws { + self = try token(from: url, secret: secret) } } @@ -54,6 +58,19 @@ internal enum SerializationError: Swift.Error { case urlGenerationFailure } +internal enum DeserializationError: Swift.Error { + case invalidURLScheme + case duplicateQueryItem(String) + case missingFactor + case invalidFactor(String) + case invalidCounterValue(String) + case invalidTimerPeriod(String) + case missingSecret + case invalidSecret(String) + case invalidAlgorithm(String) + case invalidDigits(String) +} + private let defaultAlgorithm: Generator.Algorithm = .sha1 private let defaultDigits: Int = 6 private let defaultCounter: UInt64 = 0 @@ -85,7 +102,7 @@ private func stringForAlgorithm(_ algorithm: Generator.Algorithm) -> String { } } -private func algorithmFromString(_ string: String) -> Generator.Algorithm? { +private func algorithmFromString(_ string: String) throws -> Generator.Algorithm { switch string { case kAlgorithmSHA1: return .sha1 @@ -94,12 +111,11 @@ private func algorithmFromString(_ string: String) -> Generator.Algorithm? { case kAlgorithmSHA512: return .sha512 default: - return nil + throw DeserializationError.invalidAlgorithm(string) } } -private func urlForToken(name: String, issuer: String, factor: Generator.Factor, algorithm: Generator.Algorithm, - digits: Int) throws -> URL { +private func urlForToken(name: String, issuer: String, factor: Generator.Factor, algorithm: Generator.Algorithm, digits: Int) throws -> URL { var urlComponents = URLComponents() urlComponents.scheme = kOTPAuthScheme urlComponents.path = "/" + name @@ -127,93 +143,101 @@ private func urlForToken(name: String, issuer: String, factor: Generator.Factor, return url } -private func token(from url: URL, secret externalSecret: Data? = nil) -> Token? { +private func token(from url: URL, secret externalSecret: Data? = nil) throws -> Token { guard url.scheme == kOTPAuthScheme else { - return nil - } - - var queryDictionary = Dictionary() - URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.forEach { item in - queryDictionary[item.name] = item.value - } - - let factorParser: (String) -> Generator.Factor? = { string in - if string == kFactorCounterKey { - if let counter: UInt64 = parse(queryDictionary[kQueryCounterKey], - with: { - guard let counterValue = UInt64($0, radix: 10) else { - return nil - } - return counterValue - }, - defaultTo: defaultCounter) { - return .counter(counter) - } - } else if string == kFactorTimerKey { - if let period: TimeInterval = parse(queryDictionary[kQueryPeriodKey], - with: { - guard let int = Int($0) else { - return nil - } - return TimeInterval(int) - }, - defaultTo: defaultPeriod) { - return .timer(period: period) - } - } - return nil + throw DeserializationError.invalidURLScheme } - guard let factor = parse(url.host, with: factorParser, defaultTo: nil), - let secret = parse(queryDictionary[kQuerySecretKey], with: { MF_Base32Codec.data(fromBase32String: $0) }, - overrideWith: externalSecret), - let algorithm = parse(queryDictionary[kQueryAlgorithmKey], with: algorithmFromString, - defaultTo: defaultAlgorithm), - let digits = parse(queryDictionary[kQueryDigitsKey], with: { Int($0) }, defaultTo: defaultDigits), - let generator = Generator(factor: factor, secret: secret, algorithm: algorithm, digits: digits) else { - return nil + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + + let factor: Generator.Factor + switch url.host { + case .some(kFactorCounterKey): + let counterValue = try queryItems.value(for: kQueryCounterKey).map(parseCounterValue) ?? defaultCounter + factor = .counter(counterValue) + case .some(kFactorTimerKey): + let period = try queryItems.value(for: kQueryPeriodKey).map(parseTimerPeriod) ?? defaultPeriod + factor = .timer(period: period) + case let .some(rawValue): + throw DeserializationError.invalidFactor(rawValue) + case .none: + throw DeserializationError.missingFactor } - var name = "" - let path = url.path - if path.characters.count > 1 { - // Skip the leading "/" - name = path.substring(from: path.characters.index(after: path.startIndex)) + let algorithm = try queryItems.value(for: kQueryAlgorithmKey).map(algorithmFromString) ?? defaultAlgorithm + let digits = try queryItems.value(for: kQueryDigitsKey).map(parseDigits) ?? defaultDigits + 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) - var issuer = "" - if let issuerString = queryDictionary[kQueryIssuerKey] { + // Skip the leading "/" + let fullName = String(url.path.dropFirst()) + + let issuer: String + if let issuerString = try queryItems.value(for: kQueryIssuerKey) { issuer = issuerString - } else { + } else if let separatorRange = fullName.range(of: ":") { // If there is no issuer string, try to extract one from the name - let components = name.components(separatedBy: ":") - if components.count > 1 { - issuer = components[0] - } + issuer = String(fullName[..(_ item: P?, with parser: ((P) -> T?), defaultTo defaultValue: T? = nil, - overrideWith overrideValue: T? = nil) -> T? { - if let value = overrideValue { - return value +private func parseCounterValue(_ rawValue: String) throws -> UInt64 { + guard let counterValue = UInt64(rawValue) else { + throw DeserializationError.invalidCounterValue(rawValue) } + return counterValue +} + +private func parseTimerPeriod(_ rawValue: String) throws -> TimeInterval { + guard let period = TimeInterval(rawValue) else { + throw DeserializationError.invalidTimerPeriod(rawValue) + } + return period +} + +private func parseSecret(_ rawValue: String) throws -> Data { + guard let secret = MF_Base32Codec.data(fromBase32String: rawValue) else { + throw DeserializationError.invalidSecret(rawValue) + } + return secret +} + +private func parseDigits(_ rawValue: String) throws -> Int { + guard let digits = Int(rawValue) else { + throw DeserializationError.invalidDigits(rawValue) + } + return digits +} + +private func shortName(byTrimming issuer: String, from fullName: String) -> String { + if !issuer.isEmpty { + let prefix = issuer + ":" + if fullName.hasPrefix(prefix), let prefixRange = fullName.range(of: prefix) { + let substringAfterSeparator = fullName[prefixRange.upperBound...] + return substringAfterSeparator.trimmingCharacters(in: CharacterSet.whitespaces) + } + } + return String(fullName) +} - if let concrete = item { - guard let value = parser(concrete) else { - return nil +extension Array where Element == URLQueryItem { + func value(for name: String) throws -> String? { + let matchingQueryItems = self.filter({ + $0.name == name + }) + guard matchingQueryItems.count <= 1 else { + throw DeserializationError.duplicateQueryItem(name) } - return value + return matchingQueryItems.first?.value } - return defaultValue } diff --git a/Tests/EquatableTests.swift b/Tests/EquatableTests.swift index 989656ec..c822115f 100644 --- a/Tests/EquatableTests.swift +++ b/Tests/EquatableTests.swift @@ -68,7 +68,7 @@ class EquatableTests: XCTestCase { func testTokenEquality() { guard let generator = Generator(factor: .counter(0), secret: Data(), algorithm: .sha1, digits: 6), let other_generator = Generator(factor: .counter(1), secret: Data(), algorithm: .sha512, digits: 8) else { - XCTFail() + XCTFail("Failed to construct Generator.") return } diff --git a/Tests/GeneratorTests.swift b/Tests/GeneratorTests.swift index 9e9a4d14..f82c9ec9 100644 --- a/Tests/GeneratorTests.swift +++ b/Tests/GeneratorTests.swift @@ -90,7 +90,7 @@ class GeneratorTests: XCTestCase { let totp = Generator(factor: timer, secret: secret, algorithm: .sha1, digits: 6) .flatMap { try? $0.password(at: time) } XCTAssertEqual(hotp, totp, - "TOTP with \(timer) should match HOTP with counter \(counter) at time \(time).") + "TOTP with \(timer) should match HOTP with counter \(counter) at time \(time).") } } @@ -226,7 +226,7 @@ class GeneratorTests: XCTestCase { let time = Date(timeIntervalSince1970: 0) let password = generator.flatMap { try? $0.password(at: time) } XCTAssertEqual(password, expectedPassword, - "The generator did not produce the expected OTP.") + "The generator did not produce the expected OTP.") } } @@ -255,7 +255,7 @@ class GeneratorTests: XCTestCase { let time = Date(timeIntervalSince1970: timeSinceEpoch) let password = generator.flatMap { try? $0.password(at: time) } XCTAssertEqual(password, expectedPassword, - "Incorrect result for \(algorithm) at \(timeSinceEpoch)") + "Incorrect result for \(algorithm) at \(timeSinceEpoch)") } } } @@ -278,7 +278,7 @@ class GeneratorTests: XCTestCase { let time = Date(timeIntervalSince1970: timeSinceEpoch) let password = generator.flatMap { try? $0.password(at: time) } XCTAssertEqual(password, expectedPassword, - "Incorrect result for \(algorithm) at \(timeSinceEpoch)") + "Incorrect result for \(algorithm) at \(timeSinceEpoch)") } } } diff --git a/Tests/KeychainTests.swift b/Tests/KeychainTests.swift index 0826af8d..c2068368 100644 --- a/Tests/KeychainTests.swift +++ b/Tests/KeychainTests.swift @@ -101,6 +101,7 @@ class KeychainTests: XCTestCase { } } + // swiftlint:disable:next function_body_length func testDuplicateTokens() { let token1 = testToken, token2 = testToken @@ -221,4 +222,107 @@ class KeychainTests: XCTestCase { XCTFail("allPersistentTokens() failed with error: \(error)") } } + + func testMissingData() throws { + let keychainAttributes: [String: AnyObject] = [ + kSecValueData as String: testToken.generator.secret as NSData, + ] + + let persistentRef = try addKeychainItem(withAttributes: keychainAttributes) + + XCTAssertThrowsError(try keychain.persistentToken(withIdentifier: persistentRef)) + XCTAssertThrowsError(try keychain.allPersistentTokens()) + + XCTAssertNoThrow(try deleteKeychainItem(forPersistentRef: persistentRef), + "Failed to delete the test token from the keychain. This may cause future test runs to fail.") + } + + func testMissingSecret() throws { + let data = try testToken.toURL().absoluteString.data(using: .utf8)! + + let keychainAttributes: [String: AnyObject] = [ + kSecAttrGeneric as String: data as NSData, + ] + + let persistentRef = try addKeychainItem(withAttributes: keychainAttributes) + + XCTAssertThrowsError(try keychain.persistentToken(withIdentifier: persistentRef)) + XCTAssertThrowsError(try keychain.allPersistentTokens()) + + XCTAssertNoThrow(try deleteKeychainItem(forPersistentRef: persistentRef), + "Failed to delete the test token from the keychain. This may cause future test runs to fail.") + } + + func testBadData() throws { + let badData = " ".data(using: .utf8)! + + let keychainAttributes: [String: AnyObject] = [ + kSecAttrGeneric as String: badData as NSData, + kSecValueData as String: testToken.generator.secret as NSData, + ] + + let persistentRef = try addKeychainItem(withAttributes: keychainAttributes) + + XCTAssertThrowsError(try keychain.persistentToken(withIdentifier: persistentRef)) + XCTAssertThrowsError(try keychain.allPersistentTokens()) + + XCTAssertNoThrow(try deleteKeychainItem(forPersistentRef: persistentRef), + "Failed to delete the test token from the keychain. This may cause future test runs to fail.") + } + + func testBadURL() throws { + let badData = "http://example.com".data(using: .utf8)! + + let keychainAttributes: [String: AnyObject] = [ + kSecAttrGeneric as String: badData as NSData, + kSecValueData as String: testToken.generator.secret as NSData, + ] + + let persistentRef = try addKeychainItem(withAttributes: keychainAttributes) + + XCTAssertThrowsError(try keychain.persistentToken(withIdentifier: persistentRef)) + XCTAssertThrowsError(try keychain.allPersistentTokens()) + + XCTAssertNoThrow(try deleteKeychainItem(forPersistentRef: persistentRef), + "Failed to delete the test token from the keychain. This may cause future test runs to fail.") + } +} + +// MARK: Keychain helpers + +private func addKeychainItem(withAttributes attributes: [String: AnyObject]) throws -> Data { + var mutableAttributes = attributes + mutableAttributes[kSecClass as String] = kSecClassGenericPassword + mutableAttributes[kSecReturnPersistentRef as String] = kCFBooleanTrue + // Set a random string for the account name. + // We never query by or display this value, but the keychain requires it to be unique. + if mutableAttributes[kSecAttrAccount as String] == nil { + mutableAttributes[kSecAttrAccount as String] = UUID().uuidString as NSString + } + + var result: AnyObject? + let resultCode: OSStatus = withUnsafeMutablePointer(to: &result) { + SecItemAdd(mutableAttributes as CFDictionary, $0) + } + + guard resultCode == errSecSuccess else { + throw Keychain.Error.systemError(resultCode) + } + guard let persistentRef = result as? Data else { + throw Keychain.Error.incorrectReturnType + } + return persistentRef +} + +public func deleteKeychainItem(forPersistentRef persistentRef: Data) throws { + let queryDict: [String : AnyObject] = [ + kSecClass as String: kSecClassGenericPassword, + kSecValuePersistentRef as String: persistentRef as NSData, + ] + + let resultCode = SecItemDelete(queryDict as CFDictionary) + + guard resultCode == errSecSuccess else { + throw Keychain.Error.systemError(resultCode) + } } diff --git a/Tests/TokenSerializationTests.swift b/Tests/TokenSerializationTests.swift index 36581498..4ef313ba 100644 --- a/Tests/TokenSerializationTests.swift +++ b/Tests/TokenSerializationTests.swift @@ -50,6 +50,7 @@ class TokenSerializationTests: XCTestCase { let algorithms: [OneTimePassword.Generator.Algorithm] = [.sha1, .sha256, .sha512] let digits = [6, 7, 8] + // swiftlint:disable:next function_body_length func testSerialization() { for factor in factors { for name in names { @@ -64,7 +65,7 @@ class TokenSerializationTests: XCTestCase { algorithm: algorithm, digits: digitNumber ) else { - XCTFail() + XCTFail("Failed to construct Generator.") continue } @@ -92,17 +93,19 @@ class TokenSerializationTests: XCTestCase { } XCTAssertEqual(url.host!, expectedHost, "The url host should be \"\(expectedHost)\"") // Test name - let path = url.path - XCTAssertEqual(path.substring(from: path.index(after: path.startIndex)), name, - "The url path should be \"\(name)\"") + XCTAssertEqual(url.path, "/" + name, "The url path should be \"/\(name)\"") let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) let items = urlComponents?.queryItems let expectedItemCount = 4 + // SwiftLint gives a false positive here because of a Swift/SourceKit bug. + // See https://github.com/realm/SwiftLint/issues/1785 + // swiftlint:disable vertical_parameter_alignment_on_call XCTAssertEqual(items?.count, expectedItemCount, "There shouldn't be any unexpected query arguments: \(url)") + // swiftlint:enable vertical_parameter_alignment_on_call - var queryArguments = Dictionary() + var queryArguments: [String: String] = [:] for item in items ?? [] { queryArguments[item.name] = item.value } @@ -164,4 +167,17 @@ class TokenSerializationTests: XCTestCase { } } } + + func testTokenWithDefaultCounter() { + let tokenURLString = "otpauth://hotp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4" + guard let tokenURL = URL(string: tokenURLString) else { + XCTFail("Failed to initialize a URL from String \"\(tokenURLString)\"") + return + } + guard let token = Token(url: tokenURL) else { + XCTFail("Failed to initialize a Token from URL \"\(tokenURL)\"") + return + } + XCTAssertEqual(token.generator.factor, .counter(0)) + } } diff --git a/Tests/TokenTests.swift b/Tests/TokenTests.swift index b80bd600..a1ba2495 100644 --- a/Tests/TokenTests.swift +++ b/Tests/TokenTests.swift @@ -40,7 +40,7 @@ class TokenTests: XCTestCase { algorithm: .sha1, digits: 6 ) else { - XCTFail() + XCTFail("Failed to construct Generator.") return } @@ -63,7 +63,7 @@ class TokenTests: XCTestCase { algorithm: .sha512, digits: 8 ) else { - XCTFail() + XCTFail("Failed to construct Generator.") return } @@ -90,7 +90,7 @@ class TokenTests: XCTestCase { algorithm: .sha1, digits: 6 ) else { - XCTFail() + XCTFail("Failed to construct Generator.") return } let n = "Test Name" @@ -116,7 +116,7 @@ class TokenTests: XCTestCase { algorithm: .sha1, digits: 6 ) else { - XCTFail() + XCTFail("Failed to construct Generator.") return } let timerToken = Token(generator: timerGenerator) @@ -128,7 +128,7 @@ class TokenTests: XCTestCase { let oldPassword = try timerToken.generator.password(at: Date(timeIntervalSince1970: 0)) XCTAssertNotEqual(timerToken.currentPassword, oldPassword) } catch { - XCTFail() + XCTFail("Failed to generate password with error: \(error)") return } @@ -138,7 +138,7 @@ class TokenTests: XCTestCase { algorithm: .sha1, digits: 6 ) else { - XCTFail() + XCTFail("Failed to construct Generator.") return } let counterToken = Token(generator: counterGenerator) @@ -150,7 +150,7 @@ class TokenTests: XCTestCase { let oldPassword = try counterToken.generator.password(at: Date(timeIntervalSince1970: 0)) XCTAssertEqual(counterToken.currentPassword, oldPassword) } catch { - XCTFail() + XCTFail("Failed to generate password with error: \(error)") return } } @@ -162,7 +162,7 @@ class TokenTests: XCTestCase { algorithm: .sha1, digits: 6 ) else { - XCTFail() + XCTFail("Failed to construct Generator.") return } let timerToken = Token(generator: timerGenerator) @@ -177,7 +177,7 @@ class TokenTests: XCTestCase { algorithm: .sha1, digits: 6 ) else { - XCTFail() + XCTFail("Failed to construct Generator.") return } let counterToken = Token(generator: counterGenerator)