diff --git a/Sources/OTPKit/Account.swift b/Sources/OTPKit/Account.swift index 1f87df2..b8221ad 100644 --- a/Sources/OTPKit/Account.swift +++ b/Sources/OTPKit/Account.swift @@ -35,11 +35,24 @@ public struct Account: Codable, Hashable, Identifiable { return components.url! } - public enum AccountError: LocalizedError { + public enum Error: LocalizedError { case accountAlreadyExists + + public var errorDescription: String? { + switch self { + case .accountAlreadyExists: + return "The account already exists" + } + } + + public var failureReason: String? { + switch self { + case .accountAlreadyExists: + return "Accounts cannot share the same label and issuer" + } + } } - /// - Parameter label: The label is used to identify which account a key is associated with. It contains an account name, which is a URI-encoded string, optionally prefixed by an issuer string identifying the provider or service managing that account. This issuer prefix can be used to prevent collisions between different accounts with different providers that might be identified using the same account name, e.g. the user's email address. /// - Parameter otp: OTP instance used by this account. /// - Parameter issuer: The issuer parameter is a string value indicating the provider or service this account is associated with, URL-encoded according to RFC 3986. If the issuer parameter is absent, issuer information may be taken from the issuer prefix of the label. If both issuer parameter and issuer label prefix are present, they should be equal. @@ -60,13 +73,13 @@ public struct Account: Codable, Hashable, Identifiable { /// Used to initalize a account from a URL. /// - Parameter url: A url encoded like this: otpauth://TYPE/ISSUER:LABEL?PARAMETERS - public init?(from url: URL) { + public init(from url: URL) throws { // otpauth://TYPE/LABEL?PARAMETERS - guard url.scheme == "otpauth" else { return nil } + guard url.scheme == "otpauth" else { throw URLDecodingError.invalidURLScheme(url.scheme) } let components = url.pathComponents.dropFirst().first?.split(separator: ":") - guard let labelComponent = components?.last else { return nil } + guard let labelComponent = components?.last else { throw URLDecodingError.invalidURLLabel(nil) } let label = String(labelComponent) var issuer: String? @@ -79,7 +92,7 @@ public struct Account: Codable, Hashable, Identifiable { imageURL = URL(string: imageURLString) } - guard let otp = OTPType(from: url) else { return nil } + let otp = try OTPType(from: url) self.init(label: label, otp: otp, issuer: issuer, imageURL: imageURL) } @@ -89,7 +102,7 @@ public struct Account: Codable, Hashable, Identifiable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let url = try container.decode(URL.self) - self.init(from: url)! + try self.init(from: url) } public func encode(to encoder: Encoder) throws { @@ -102,8 +115,7 @@ public struct Account: Codable, Hashable, Identifiable { /// Saves the account to a keychain /// - Parameter keychain public func save(to keychain: Keychain) throws { - - guard (try? keychain.get(keychainKey)) == nil else { throw AccountError.accountAlreadyExists } + guard try keychain.get(keychainKey) == nil else { throw Error.accountAlreadyExists } try keychain .label(label) @@ -122,7 +134,7 @@ public struct Account: Codable, Hashable, Identifiable { let items = keychain.allKeys() let accounts = try items.compactMap { key throws -> Account? in guard let urlString = try keychain.get(key), let url = URL(string: urlString) else { return nil } - return Account(from: url) + return try Account(from: url) } return accounts } diff --git a/Sources/OTPKit/Algorithm.swift b/Sources/OTPKit/Algorithm.swift index 85c58d9..fac48e2 100644 --- a/Sources/OTPKit/Algorithm.swift +++ b/Sources/OTPKit/Algorithm.swift @@ -7,7 +7,7 @@ import Foundation import CommonCrypto -public enum Algorithm: RawRepresentable, Hashable, Codable { +public enum Algorithm: RawRepresentable, CaseIterable, Hashable, Codable { case md5 case sha1 case sha224 diff --git a/Sources/OTPKit/HOTP.swift b/Sources/OTPKit/HOTP.swift index 2ba0ab6..5bbddd9 100644 --- a/Sources/OTPKit/HOTP.swift +++ b/Sources/OTPKit/HOTP.swift @@ -8,7 +8,6 @@ import Foundation import Base32 - public final class HOTP: OTP { public static let typeString = "hotp" @@ -34,17 +33,18 @@ public final class HOTP: OTP { self.digits = digits ?? 6 } - required public convenience init?(from url: URL) { - guard url.scheme == "otpauth", url.host == "hotp" else { return nil } - - guard let query = url.queryParameters else { return nil } + required public convenience init(from url: URL) throws { + guard url.scheme == "otpauth" else { throw URLDecodingError.invalidURLScheme(url.scheme) } + guard url.host == "hotp" else { throw URLDecodingError.invalidOTPType(url.host) } + guard let query = url.queryParameters else { throw URLDecodingError.invalidURLQueryParamters } var algorithm: Algorithm? if let algorithmString = query["algorithm"] { - algorithm = Algorithm(from: algorithmString) + guard let algo = Algorithm(from: algorithmString) else { throw URLDecodingError.invalidAlgorithm(algorithmString) } + algorithm = algo } - guard let secret = query["secret"]?.base32DecodedData, secret.count != 0 else { return nil } + guard let secret = query["secret"]?.base32DecodedData, secret.count != 0 else { throw URLDecodingError.invalidSecret } var digits: Int? if let digitsString = query["digits"], let value = Int(digitsString), value >= 6 { diff --git a/Sources/OTPKit/OTP.swift b/Sources/OTPKit/OTP.swift index adaa4a8..d39d6b3 100644 --- a/Sources/OTPKit/OTP.swift +++ b/Sources/OTPKit/OTP.swift @@ -30,7 +30,7 @@ public protocol OTP: Codable, Hashable { /// Initalizes the OTP instance from a URL /// - Parameter url - init?(from url: URL) + init(from url: URL) throws } extension OTP { diff --git a/Sources/OTPKit/TOTP.swift b/Sources/OTPKit/TOTP.swift index 109166f..e65810a 100644 --- a/Sources/OTPKit/TOTP.swift +++ b/Sources/OTPKit/TOTP.swift @@ -7,6 +7,7 @@ import Foundation + public final class TOTP: OTP { public static let typeString = "totp" @@ -52,17 +53,19 @@ public final class TOTP: OTP { } } - public required convenience init?(from url: URL) { - guard url.scheme == "otpauth", url.host == "totp" else { return nil } + public required convenience init(from url: URL) throws { + guard url.scheme == "otpauth" else { throw URLDecodingError.invalidURLScheme(url.scheme) } + guard url.host == "totp" else { throw URLDecodingError.invalidOTPType(url.host) } - guard let query = url.queryParameters else { return nil } + guard let query = url.queryParameters else { throw URLDecodingError.invalidURLQueryParamters } var algorithm: Algorithm? if let algorithmString = query["algorithm"] { - algorithm = Algorithm(from: algorithmString) + guard let algo = Algorithm(from: algorithmString) else { throw URLDecodingError.invalidAlgorithm(algorithmString) } + algorithm = algo } - guard let secret = query["secret"]?.base32DecodedData, secret.count != 0 else { return nil } + guard let secret = query["secret"]?.base32DecodedData, secret.count != 0 else { throw URLDecodingError.invalidSecret } var digits: Int? if let digitsString = query["digits"], let value = Int(digitsString), value >= 6 { diff --git a/Sources/OTPKit/URLDecodingError.swift b/Sources/OTPKit/URLDecodingError.swift new file mode 100644 index 0000000..664dbec --- /dev/null +++ b/Sources/OTPKit/URLDecodingError.swift @@ -0,0 +1,80 @@ +// +// URLDecodingError.swift +// +// +// Created by Tim Gymnich on 25.08.20. +// + +import Foundation + +public enum URLDecodingError: LocalizedError { + case invalidURLScheme(String?) + case invalidURLLabel(String?) + case invalidOTPType(String?) + case invalidURLQueryParamters + case invalidSecret + case invalidAlgorithm(String?) + + public var errorDescription: String? { + switch self { + case .invalidURLScheme: + return "Invalid URL scheme" + case .invalidOTPType: + return "Invalid OTP type" + case .invalidURLQueryParamters: + return "Invalid URL query paramters" + case .invalidSecret: + return "Invalid secret" + case .invalidAlgorithm: + return "Invalid algorithm" + case .invalidURLLabel: + return "Invalid label" + } + } + + public var failureReason: String? { + switch self { + case let .invalidURLScheme(scheme): + if let scheme = scheme { + return "The URL scheme \"\(scheme)\" is not supported" + } else { + return "The URL scheme is missing or not supported" + } + case let .invalidOTPType(type): + if let type = type { + return "The OTP type \"\(type)\" is not supported" + } else { + return "The OTP type is missing or not supported" + } + case .invalidURLQueryParamters: + return nil + case .invalidSecret: + return nil + case let .invalidAlgorithm(algorithm): + if let algorithm = algorithm { + return "The algorithm \"\(algorithm)\" is not supported" + } else { + return "The algorithm is missing or not supported" + } + case .invalidURLLabel: + return nil + } + } + + public var recoverySuggestion: String? { + switch self { + case .invalidURLScheme: + return "Try using one of the supported URL schemes. Supported URL schemes are: otpauth" + case .invalidOTPType: + return "Try using one of the supported OTP types" + case .invalidURLQueryParamters: + return nil + case .invalidSecret: + return nil + case .invalidAlgorithm: + return "Try using one of the supported algorithms. Supported algorithms are: \(Algorithm.allCases.map { $0.string }.joined(separator: ","))" + case .invalidURLLabel: + return nil + } + } +} diff --git a/Tests/OTPKitTests/AccountTests.swift b/Tests/OTPKitTests/AccountTests.swift index 3e801b3..a703117 100644 --- a/Tests/OTPKitTests/AccountTests.swift +++ b/Tests/OTPKitTests/AccountTests.swift @@ -14,7 +14,7 @@ final class AccountTests: XCTestCase { func testBasicTOTPAccount() { let url = URL(string: "otpauth://totp/foo?secret=wew3k6ztd7kuh5ucg4pejqi4swwrrneh72ad2sdovikfatzbc5huto2j&algorithm=SHA256&digits=6&period=30")! - let account = Account(from: url) + let account = try? Account(from: url) XCTAssertNotNil(account) XCTAssertEqual(account?.label, "foo") @@ -23,7 +23,7 @@ final class AccountTests: XCTestCase { func testAdvancedTOTPAccount() { let url = URL(string: "otpauth://totp/www.example.com:foo?secret=avelj2f3hqxgbm5gi7rvrfskdvxnia72rt7kxwfa5l5yuisqfpjlezm5&algorithm=SHA256&digits=7&period=30&image=http%3A%2F%2Fwww.example.com%2Fimage")! - let account = Account(from: url) + let account = try? Account(from: url) XCTAssertNotNil(account) XCTAssertEqual(account?.label, "foo") @@ -34,7 +34,7 @@ final class AccountTests: XCTestCase { func testBasicHOTPAccount() { let url = URL(string: "otpauth://hotp/foo?secret=qtezwnbabbgdb3kspqx3kjp5z6n7qtc5xcrkvk3p4scbyeuzwlfpbnhe&algorithm=SHA1&digits=6&counter=0")! - let account = Account(from: url) + let account = try? Account(from: url) XCTAssertNotNil(account) XCTAssertEqual(account?.label, "foo") @@ -43,7 +43,7 @@ final class AccountTests: XCTestCase { func testAdvancedHOTPAccount() { let url = URL(string: "otpauth://hotp/www.example.com:foo?secret=vutgq34hz4fi4ljm2ycg6im6sd5pl6jmy4rihpvzaddliiqoi64gnquq&algorithm=SHA256&digits=7&counter=0&image=http%3A%2F%2Fwww.example.com%2Fimage")! - let account = Account(from: url) + let account = try? Account(from: url) XCTAssertNotNil(account) XCTAssertEqual(account?.label, "foo") diff --git a/Tests/OTPKitTests/HOTPTests.swift b/Tests/OTPKitTests/HOTPTests.swift index 2a8b18c..492d283 100644 --- a/Tests/OTPKitTests/HOTPTests.swift +++ b/Tests/OTPKitTests/HOTPTests.swift @@ -26,7 +26,7 @@ final class HOTPTests: XCTestCase { func testInitFromURLBasic() { let url = URL(string: "otpauth://hotp/foo?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&algorithm=SHA1&digits=6")! - let hotp = HOTP(from: url) + let hotp = try? HOTP(from: url) XCTAssertNotNil(hotp) XCTAssertEqual(hotp?.algorithm, Algorithm.sha1) @@ -37,7 +37,7 @@ final class HOTPTests: XCTestCase { func testInitFromURLAdvanced() { let url = URL(string: "otpauth://hotp/www.example.com:foo?secret=rk7xql2piogveotejq2ulv7d2aicbpzlh33xeaqnkqjck4iyz2cm6xzg&algorithm=SHA256&digits=7&period=30&counter=34&image=http%3A%2F%2Fwww.example.com%2Fimage")! - let hotp = HOTP(from: url) + let hotp = try? HOTP(from: url) XCTAssertNotNil(hotp) XCTAssertEqual(hotp?.algorithm, Algorithm.sha256) @@ -48,21 +48,21 @@ final class HOTPTests: XCTestCase { func testBrokenURL() { let url = URL(string: "otpauth://hotp/foo?secret=&algorithm=SHA1&digits=6")! - let hotp = HOTP(from: url) + let hotp = try? HOTP(from: url) XCTAssertNil(hotp) } func testWrongURLType() { let url = URL(string: "otpauth://totp/foo?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")! - let hotp = HOTP(from: url) + let hotp = try? HOTP(from: url) XCTAssertNil(hotp) } func testWrongURLScheme() { let url = URL(string: "http://hotp/foo?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&algorithm=SHA1&digits=6")! - let hotp = HOTP(from: url) + let hotp = try? HOTP(from: url) XCTAssertNil(hotp) } diff --git a/Tests/OTPKitTests/TOTPTests.swift b/Tests/OTPKitTests/TOTPTests.swift index 06edaa0..8f0cadc 100644 --- a/Tests/OTPKitTests/TOTPTests.swift +++ b/Tests/OTPKitTests/TOTPTests.swift @@ -77,7 +77,7 @@ final class TOTPTests: XCTestCase { func testInitFromURLBasic() { let url = URL(string: "otpauth://totp/foo?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")! - let totp = TOTP(from: url) + let totp = try? TOTP(from: url) XCTAssertNotNil(totp) XCTAssertEqual(totp?.algorithm, Algorithm.sha1) @@ -88,7 +88,7 @@ final class TOTPTests: XCTestCase { func testInitFromURLAdvanced() { let url = URL(string: "otpauth://totp/www.example.com:foo?secret=ahkzlrgopti4qd2u5olxmj6dj6d3ag6zxddbutu6oaukrkuup2r7wklw&algorithm=SHA256&digits=7&period=15&image=http%3A%2F%2Fwww.example.com%2Fimage")! - let totp = TOTP(from: url) + let totp = try? TOTP(from: url) XCTAssertNotNil(totp) XCTAssertEqual(totp?.algorithm, Algorithm.sha256) @@ -99,21 +99,21 @@ final class TOTPTests: XCTestCase { func testBrokenURL() { let url = URL(string: "otpauth://totp/foo?secret=")! - let totp = TOTP(from: url) + let totp = try? TOTP(from: url) XCTAssertNil(totp) } func testWrongURLType() { let url = URL(string: "otpauth://hotp/foo?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&algorithm=SHA1&digits=6")! - let totp = TOTP(from: url) + let totp = try? TOTP(from: url) XCTAssertNil(totp) } func testWrongURLScheme() { let url = URL(string: "http://totp/foo?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")! - let totp = TOTP(from: url) + let totp = try? TOTP(from: url) XCTAssertNil(totp) }