From b184737442cdda72c40c7206499ac77d542ba233 Mon Sep 17 00:00:00 2001 From: Joel-David <4586209+joeldavidw@users.noreply.github.com> Date: Mon, 7 Oct 2024 00:42:54 +0800 Subject: [PATCH] feat: Enhanced token validations (#71) * bump packages * Updated show error * optimised code * Fixed OtpAuthUrlParser and tests * Added TokenValidator tests * Fixed lint --- Chronos.xcodeproj/project.pbxproj | 30 +++- .../Tokens/AddToken/AddManualTokenView.swift | 4 +- .../Tabs/Tokens/AddToken/AddTokenView.swift | 8 +- Chronos/App/Tabs/Tokens/Row/HOTPRowView.swift | 9 +- Chronos/App/Tabs/Tokens/Row/TOTPRowView.swift | 5 +- Chronos/App/Tabs/Tokens/Row/TokenQRView.swift | 4 +- .../App/Tabs/Tokens/Row/TokenRowView.swift | 86 +++++----- .../App/Tabs/Tokens/Row/UpdateTokenView.swift | 4 +- Chronos/Data/Token.swift | 96 ++++++++--- Chronos/Helper/OtpAuthUrlParser.swift | 101 ++++++++++++ Chronos/Helper/TokenValidator.swift | 65 ++++++++ Chronos/Services/Container.swift | 4 - Chronos/Services/ExportService.swift | 3 +- Chronos/Services/Import/ImportService.swift | 3 +- Chronos/Services/OTPService.swift | 151 ------------------ ChronosTests/Token/ParseOtpAuthUrl.swift | 75 ++++++--- .../Token/QrCodeGenerationAndParsing.swift | 36 ++--- ChronosTests/Token/TokenToOtpAuthUrl.swift | 36 ++--- ChronosTests/Token/TokenValidator.swift | 136 ++++++++++++++++ 19 files changed, 542 insertions(+), 314 deletions(-) create mode 100644 Chronos/Helper/OtpAuthUrlParser.swift create mode 100644 Chronos/Helper/TokenValidator.swift delete mode 100644 Chronos/Services/OTPService.swift create mode 100644 ChronosTests/Token/TokenValidator.swift diff --git a/Chronos.xcodeproj/project.pbxproj b/Chronos.xcodeproj/project.pbxproj index bc2343f..7ae131e 100644 --- a/Chronos.xcodeproj/project.pbxproj +++ b/Chronos.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 6B09CEBF2CB2DEA10054AB61 /* TokenValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B09CEBE2CB2DEA00054AB61 /* TokenValidator.swift */; }; + 6B09CEC12CB2DF060054AB61 /* OtpAuthUrlParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B09CEC02CB2DF060054AB61 /* OtpAuthUrlParser.swift */; }; + 6B09CEC32CB2EE9C0054AB61 /* TokenValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B09CEC22CB2EE980054AB61 /* TokenValidator.swift */; }; 6B12B0A02C19DB7800E9ED2D /* ExportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B12B09F2C19DB7800E9ED2D /* ExportService.swift */; }; 6B12B0A22C19F1E600E9ED2D /* ExportVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B12B0A12C19F1E600E9ED2D /* ExportVault.swift */; }; 6B193C8A2C27F03300E759B7 /* CloudKitSyncMonitor in Frameworks */ = {isa = PBXBuildFile; productRef = 6B193C892C27F03300E759B7 /* CloudKitSyncMonitor */; }; @@ -73,7 +76,6 @@ 6BC5F0522C52429100BA106F /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = 6BC5F0512C52429100BA106F /* SwiftProtobuf */; }; 6BC5F0552C52464600BA106F /* GoogleAuth.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC5F0542C5242B500BA106F /* GoogleAuth.pb.swift */; }; 6BC5F0572C529A2A00BA106F /* GoogleAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC5F0562C529A2A00BA106F /* GoogleAuthenticator.swift */; }; - 6BD6D2012C11FEB4004512BF /* OTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BD6D2002C11FEB4004512BF /* OTPService.swift */; }; 6BD90AA52B8E34BB00FABD91 /* PasswordLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BD90AA42B8E34BB00FABD91 /* PasswordLoginView.swift */; }; 6BE122922BD6413D008636D2 /* ChronosCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BE122912BD6413D008636D2 /* ChronosCrypto.swift */; }; 6BF53E4F2C317AA400356461 /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = 6BF53E4E2C317AA400356461 /* ZipArchive */; }; @@ -94,6 +96,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 6B09CEBE2CB2DEA00054AB61 /* TokenValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenValidator.swift; sourceTree = ""; }; + 6B09CEC02CB2DF060054AB61 /* OtpAuthUrlParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtpAuthUrlParser.swift; sourceTree = ""; }; + 6B09CEC22CB2EE980054AB61 /* TokenValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenValidator.swift; sourceTree = ""; }; 6B12B09F2C19DB7800E9ED2D /* ExportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportService.swift; sourceTree = ""; }; 6B12B0A12C19F1E600E9ED2D /* ExportVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportVault.swift; sourceTree = ""; }; 6B19CA712B7A70B800690390 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; @@ -152,7 +157,6 @@ 6BC5F0492C4FDE6E00BA106F /* ParseOtpAuthUrl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseOtpAuthUrl.swift; sourceTree = ""; }; 6BC5F0542C5242B500BA106F /* GoogleAuth.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleAuth.pb.swift; sourceTree = ""; }; 6BC5F0562C529A2A00BA106F /* GoogleAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleAuthenticator.swift; sourceTree = ""; }; - 6BD6D2002C11FEB4004512BF /* OTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPService.swift; sourceTree = ""; }; 6BD90AA42B8E34BB00FABD91 /* PasswordLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordLoginView.swift; sourceTree = ""; }; 6BE122912BD6413D008636D2 /* ChronosCrypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChronosCrypto.swift; sourceTree = ""; }; 6BF53E512C317F1C00356461 /* ExportSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportSelectionView.swift; sourceTree = ""; }; @@ -192,6 +196,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 6B09CEBD2CB2DE7A0054AB61 /* Helper */ = { + isa = PBXGroup; + children = ( + 6B09CEBE2CB2DEA00054AB61 /* TokenValidator.swift */, + 6B09CEC02CB2DF060054AB61 /* OtpAuthUrlParser.swift */, + ); + path = Helper; + sourceTree = ""; + }; 6B19CA6E2B7A695200690390 /* Onboarding */ = { isa = PBXGroup; children = ( @@ -241,6 +254,7 @@ 6BD0562B2BDF7F190099616B /* Extensions */, 6B7383E12B9C3975008E8867 /* Services */, 6B8581BB2B99FB4400A19CE1 /* Data */, + 6B09CEBD2CB2DE7A0054AB61 /* Helper */, 6B2583B02B975D3200938F3A /* Chronos.entitlements */, 6B7A546F2B94584E0057DCF9 /* Info.plist */, 6B3BB1062B503CB900DCEF0B /* Database */, @@ -361,7 +375,6 @@ 6BA5DA5B2B9E94F8009908E5 /* SecureEnclaveService.swift */, 6B39629D2BF63F27000410B0 /* SwiftDataService.swift */, 6B39629F2BF6423B000410B0 /* CryptoService.swift */, - 6BD6D2002C11FEB4004512BF /* OTPService.swift */, 6B9D74642C14ADDC008E6582 /* StateService.swift */, 6B12B09F2C19DB7800E9ED2D /* ExportService.swift */, 6B3F92AA2C1C7987004125A8 /* VaultService.swift */, @@ -380,6 +393,7 @@ 6B8132F82C4C0F3B00DB367E /* Token */ = { isa = PBXGroup; children = ( + 6B09CEC22CB2EE980054AB61 /* TokenValidator.swift */, 6B8132F92C4C0F6300DB367E /* TokenToOtpAuthUrl.swift */, 6B8132FC2C4E5F6B00DB367E /* QrCodeGenerationAndParsing.swift */, 6BC5F0492C4FDE6E00BA106F /* ParseOtpAuthUrl.swift */, @@ -660,6 +674,7 @@ 6B7A54742B94B7A30057DCF9 /* PrivacyView.swift in Sources */, 6BD90AA52B8E34BB00FABD91 /* PasswordLoginView.swift in Sources */, 6B9D74682C1553A1008E6582 /* SecureBytes.swift in Sources */, + 6B09CEC12CB2DF060054AB61 /* OtpAuthUrlParser.swift in Sources */, 6B3BB0E22B4ED19300DCEF0B /* TokensTab.swift in Sources */, 6BB37D832C480B07008DA122 /* ImportConfirmationView.swift in Sources */, 6BF53E542C31856A00356461 /* EncryptedExportPasswordView.swift in Sources */, @@ -667,6 +682,7 @@ 6B8132F42C4BAD5A00DB367E /* TokenQRView.swift in Sources */, 6B3BB0E02B4ECE6F00DCEF0B /* TOTPRowView.swift in Sources */, 6B65F80B2C21C2EA00AC8606 /* VaultSetupView.swift in Sources */, + 6B09CEBF2CB2DEA10054AB61 /* TokenValidator.swift in Sources */, 6B5E41CE2BD790F80045DBC6 /* EncryptedToken.swift in Sources */, 6B4B48F32BD7BB3C007D357D /* Token.swift in Sources */, 6BB37D7C2C457059008DA122 /* ImportSourceDetailView.swift in Sources */, @@ -675,7 +691,6 @@ 6BA5DA5C2B9E94F8009908E5 /* SecureEnclaveService.swift in Sources */, 6B66D5EF2B52BAC2006DB79D /* HOTPRowView.swift in Sources */, 6B39629E2BF63F27000410B0 /* SwiftDataService.swift in Sources */, - 6BD6D2012C11FEB4004512BF /* OTPService.swift in Sources */, 6B39629A2BF5E935000410B0 /* MainAppView.swift in Sources */, 6BB37D852C483066008DA122 /* ImportFailureView.swift in Sources */, 6BF53E522C317F1C00356461 /* ExportSelectionView.swift in Sources */, @@ -697,6 +712,7 @@ 6B8132FD2C4E5F6B00DB367E /* QrCodeGenerationAndParsing.swift in Sources */, 6BBF32032C562F20003CBA66 /* Aegis.swift in Sources */, 6B4CBF2F2C490FB700983D44 /* Chronos.swift in Sources */, + 6B09CEC32CB2EE9C0054AB61 /* TokenValidator.swift in Sources */, 6B2F7A842C636CE500DB1450 /* Ente.swift in Sources */, 6BC5F0572C529A2A00BA106F /* GoogleAuthenticator.swift in Sources */, 6B4987282C569A6E00A7D97A /* LastPass.swift in Sources */, @@ -871,7 +887,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 14.0; + MARKETING_VERSION = 15.0; PRODUCT_BUNDLE_IDENTIFIER = com.joeldavidw.ChronosDevDebug; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -918,7 +934,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 14.0; + MARKETING_VERSION = 15.0; PRODUCT_BUNDLE_IDENTIFIER = com.joeldavidw.ChronosDevRelease; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1244,7 +1260,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 14.0; + MARKETING_VERSION = 15.0; PRODUCT_BUNDLE_IDENTIFIER = com.joeldavidw.Chronos; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Chronos/App/Tabs/Tokens/AddToken/AddManualTokenView.swift b/Chronos/App/Tabs/Tokens/AddToken/AddManualTokenView.swift index 5490ac2..8fbac6f 100644 --- a/Chronos/App/Tabs/Tokens/AddToken/AddManualTokenView.swift +++ b/Chronos/App/Tabs/Tokens/AddToken/AddManualTokenView.swift @@ -152,8 +152,6 @@ extension AddManualTokenView { tempToken.counter = counter tempToken.period = period - let valid = validateToken(token: tempToken) - - return valid.isValid + return tempToken.isValid } } diff --git a/Chronos/App/Tabs/Tokens/AddToken/AddTokenView.swift b/Chronos/App/Tabs/Tokens/AddToken/AddTokenView.swift index f6371fb..ae6a1ab 100644 --- a/Chronos/App/Tabs/Tokens/AddToken/AddTokenView.swift +++ b/Chronos/App/Tabs/Tokens/AddToken/AddTokenView.swift @@ -9,7 +9,6 @@ struct AddTokenView: View { @State private var showTokenManualAddSheet = false let cryptoService = Container.shared.cryptoService() - let otpService = Container.shared.otpService() let vaultService = Container.shared.vaultService() var body: some View { @@ -80,6 +79,8 @@ struct AddTokenView: View { func handleScan(result: Result) { switch result { case let .success(result): + dismiss() + let otpAuthStr = result.string guard otpAuthStr.starts(with: "otpauth://") else { AlertKitAPI.present( @@ -92,12 +93,10 @@ struct AddTokenView: View { } do { - let newToken = try otpService.parseOtpAuthUrl(otpAuthStr: otpAuthStr) + let newToken = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: otpAuthStr) let newEncToken = cryptoService.encryptToken(token: newToken) vaultService.insertEncryptedToken(newEncToken) - dismiss() - AlertKitAPI.present( title: "Successfully added \(!newToken.issuer.isEmpty ? newToken.issuer : newToken.account)", icon: .done, @@ -107,6 +106,7 @@ struct AddTokenView: View { } catch { AlertKitAPI.present( title: "Invalid 2FA QR Code", + subtitle: error.localizedDescription.description, icon: .error, style: .iOS17AppleMusic, haptic: .error diff --git a/Chronos/App/Tabs/Tokens/Row/HOTPRowView.swift b/Chronos/App/Tabs/Tokens/Row/HOTPRowView.swift index d8e34a4..b16dc42 100644 --- a/Chronos/App/Tabs/Tokens/Row/HOTPRowView.swift +++ b/Chronos/App/Tabs/Tokens/Row/HOTPRowView.swift @@ -8,21 +8,20 @@ struct HOTPRowView: View { @State private var disableIncrementBtn = false let cryptoService = Container.shared.cryptoService() - let otpService = Container.shared.otpService() var token: Token var encryptedToken: EncryptedToken var body: some View { - Text(!otp.isEmpty ? formatOtp(otp: otp) : otpService.generateHOTP(token: token)) + Text(!otp.isEmpty ? formatOtp(otp: otp) : token.generateOtp()) .font(.largeTitle) .fontWeight(.light) .lineLimit(1) .onAppear { - otp = otpService.generateHOTP(token: token) + otp = token.generateOtp() } .onChange(of: token.counter) { _, _ in - otp = otpService.generateHOTP(token: token) + otp = token.generateOtp() } Spacer() Button { @@ -31,7 +30,7 @@ struct HOTPRowView: View { token.counter += 1 cryptoService.updateEncryptedToken(encryptedToken: encryptedToken, token: token) - otp = otpService.generateHOTP(token: token) + otp = token.generateOtp() DispatchQueue.main.asyncAfter(deadline: .now() + 2) { disableIncrementBtn = false } diff --git a/Chronos/App/Tabs/Tokens/Row/TOTPRowView.swift b/Chronos/App/Tabs/Tokens/Row/TOTPRowView.swift index 5013da4..85cf818 100644 --- a/Chronos/App/Tabs/Tokens/Row/TOTPRowView.swift +++ b/Chronos/App/Tabs/Tokens/Row/TOTPRowView.swift @@ -10,11 +10,10 @@ struct TOTPRowView: View { @State private var progress: Double = 1.0 let timer: Publishers.Autoconnect - let otpService = Container.shared.otpService() var body: some View { Group { - Text(!otp.isEmpty ? formatOtp(otp: otp) : otpService.generateTOTP(token: token)) + Text(!otp.isEmpty ? formatOtp(otp: otp) : token.generateOtp()) .font(.largeTitle) .fontWeight(.light) .lineLimit(1) @@ -51,7 +50,7 @@ struct TOTPRowView: View { } private func updateOtp() { - otp = otpService.generateTOTP(token: token) + otp = token.generateOtp() } private func updateProgress() { diff --git a/Chronos/App/Tabs/Tokens/Row/TokenQRView.swift b/Chronos/App/Tabs/Tokens/Row/TokenQRView.swift index 06640c2..b2af9f5 100644 --- a/Chronos/App/Tabs/Tokens/Row/TokenQRView.swift +++ b/Chronos/App/Tabs/Tokens/Row/TokenQRView.swift @@ -7,8 +7,6 @@ struct TokenQRView: View { @State var token: Token - let otpService = Container.shared.otpService() - var body: some View { NavigationView { VStack { @@ -29,7 +27,7 @@ struct TokenQRView: View { } .padding(.bottom, 8) - if let otpAuthUrl = otpService.tokenToOtpAuthUrl(token: token), + if let otpAuthUrl = token.otpAuthUrl(), let imageData = try? QRCode.build .text(otpAuthUrl) .generate diff --git a/Chronos/App/Tabs/Tokens/Row/TokenRowView.swift b/Chronos/App/Tabs/Tokens/Row/TokenRowView.swift index ebde7f6..3205b4d 100644 --- a/Chronos/App/Tabs/Tokens/Row/TokenRowView.swift +++ b/Chronos/App/Tabs/Tokens/Row/TokenRowView.swift @@ -30,7 +30,6 @@ struct TokenRowView: View { return tokenPair.encToken } - private let otpService = Container.shared.otpService() private let cryptoService = Container.shared.cryptoService() var body: some View { @@ -66,11 +65,19 @@ struct TokenRowView: View { } } else { HStack { - switch token.type { - case TokenTypeEnum.TOTP: - TOTPRowView(token: token, timer: timer) - case TokenTypeEnum.HOTP: - HOTPRowView(token: token, encryptedToken: encryptedToken) + if token.isValid { + switch token.type { + case TokenTypeEnum.TOTP: + TOTPRowView(token: token, timer: timer) + case TokenTypeEnum.HOTP: + HOTPRowView(token: token, encryptedToken: encryptedToken) + } + } else { + Text("Invalid Token") + .font(.title) + .fontWeight(.light) + .opacity(0.5) + .lineLimit(1) } } } @@ -80,19 +87,24 @@ struct TokenRowView: View { .listRowBackground(Color(red: 0.04, green: 0, blue: 0.11)) .onTapGesture { if !stateTapToRevealEnabled { - switch token.type { - case TokenTypeEnum.TOTP: - UIPasteboard.general.string = otpService.generateTOTP(token: token) - case TokenTypeEnum.HOTP: - UIPasteboard.general.string = otpService.generateHOTP(token: token) + if token.isValid { + UIPasteboard.general.string = token.generateOtp() + + AlertKitAPI.present( + title: "Copied", + icon: .done, + style: .iOS17AppleMusic, + haptic: .success + ) + } else { + AlertKitAPI.present( + title: "Invalid Token", + subtitle: token.validationError?.localizedDescription.description, + icon: .error, + style: .iOS17AppleMusic, + haptic: .success + ) } - - AlertKitAPI.present( - title: "Copied", - icon: .done, - style: .iOS17AppleMusic, - haptic: .success - ) } else { tokenRevealed.toggle() } @@ -160,27 +172,29 @@ struct TokenRowView: View { } .tint(.blue) - Button { - token.pinned = !(token.pinned ?? false) - cryptoService.updateEncryptedToken(encryptedToken: encryptedToken, token: token) - triggerSortAndFilterTokenPairs() - } label: { - VStack(alignment: .center) { - Image(systemName: token.pinned ?? false ? "pin.slash" : "pin") - Text(token.pinned ?? false ? "Unpin" : "Pin") + if token.isValid { + Button { + token.pinned = !(token.pinned ?? false) + cryptoService.updateEncryptedToken(encryptedToken: encryptedToken, token: token) + triggerSortAndFilterTokenPairs() + } label: { + VStack(alignment: .center) { + Image(systemName: token.pinned ?? false ? "pin.slash" : "pin") + Text(token.pinned ?? false ? "Unpin" : "Pin") + } } - } - .tint(.indigo) - - Button { - self.showTokenQRSheet = true - } label: { - VStack(alignment: .center) { - Image(systemName: "qrcode") - Text("QR") + .tint(.indigo) + + Button { + self.showTokenQRSheet = true + } label: { + VStack(alignment: .center) { + Image(systemName: "qrcode") + Text("QR") + } } + .tint(.gray) } - .tint(.gray) } } diff --git a/Chronos/App/Tabs/Tokens/Row/UpdateTokenView.swift b/Chronos/App/Tabs/Tokens/Row/UpdateTokenView.swift index bb649ad..931d827 100644 --- a/Chronos/App/Tabs/Tokens/Row/UpdateTokenView.swift +++ b/Chronos/App/Tabs/Tokens/Row/UpdateTokenView.swift @@ -142,9 +142,7 @@ extension UpdateTokenView { tempToken.counter = counter tempToken.period = period - let valid = validateToken(token: tempToken) - - return valid.isValid + return tempToken.isValid } var hasChanged: Bool { diff --git a/Chronos/Data/Token.swift b/Chronos/Data/Token.swift index d0a1c95..a9cd844 100644 --- a/Chronos/Data/Token.swift +++ b/Chronos/Data/Token.swift @@ -33,42 +33,88 @@ class Token: Codable, Identifiable { // Extra Data var pinned: Bool? = false -} -func validateToken( - token: Token -) -> (isValid: Bool, errorMessage: String?) { - if token.account.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return (false, "Invalid account") + var isValid: Bool { + return validationError == nil } - if token.secret.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return (false, "Invalid secret - empty") + var validationError: Error? { + do { + try TokenValidator.validate(token: self) + return nil + } catch { + return error + } } - if base32DecodeToData(token.secret) == nil { - return (false, "Invalid secret - not base32 encoded") + func generateOtp() -> String { + switch type { + case .TOTP: + return generateTOTP() + case .HOTP: + return generateHOTP() + } } -// // Validate algorithm -// guard let algorithm = TokenAlgorithmEnum(rawValue: algorithmString) else { -// return (false, "Invalid algorithm") -// } + private func generateTOTP() -> String { + let digits = digits + let secret = base32DecodeToData(secret)! + let period = period - // Validate digits - guard (6 ... 8).contains(token.digits) else { - return (false, "Invalid digits") - } + var otpAlgo = OTPAlgorithm.sha1 + switch algorithm { + case TokenAlgorithmEnum.SHA1: + otpAlgo = OTPAlgorithm.sha1 + case TokenAlgorithmEnum.SHA256: + otpAlgo = OTPAlgorithm.sha256 + case TokenAlgorithmEnum.SHA512: + otpAlgo = OTPAlgorithm.sha512 + } + + let totp = TOTP(secret: secret, digits: digits, timeInterval: period, algorithm: otpAlgo) - // Validate counter - guard token.counter >= 0 else { - return (false, "Invalid counter") + return totp!.generate(time: Date()) ?? "" } - // Validate period - guard token.period > 0 else { - return (false, "Invalid period") + private func generateHOTP() -> String { + let digits = digits + let secret = base32DecodeToData(secret)! + let counter = counter + let algorithm = OTPAlgorithm.sha1 + + let hotp = HOTP(secret: secret, digits: digits, algorithm: algorithm) + + return hotp!.generate(counter: UInt64(counter)) ?? "" } - return (true, nil) + func otpAuthUrl() -> String? { + var components = URLComponents() + components.scheme = "otpauth" + components.host = type.rawValue.lowercased() + + components.path = !account.isEmpty + ? (!issuer.isEmpty ? "/\(issuer):\(account)" : "/\(account)") + : "/" + + var queryItems: [URLQueryItem] = [ + URLQueryItem(name: "secret", value: secret), + URLQueryItem(name: "algorithm", value: algorithm.rawValue), + URLQueryItem(name: "digits", value: "\(digits)"), + ] + + if !issuer.isEmpty { + queryItems.append(URLQueryItem(name: "issuer", value: issuer)) + } + + switch type { + case .TOTP: + queryItems.append(URLQueryItem(name: "period", value: "\(period)")) + case .HOTP: + queryItems.append(URLQueryItem(name: "counter", value: "\(counter)")) + } + + components.queryItems = queryItems + + return components.string + } } diff --git a/Chronos/Helper/OtpAuthUrlParser.swift b/Chronos/Helper/OtpAuthUrlParser.swift new file mode 100644 index 0000000..b4c532d --- /dev/null +++ b/Chronos/Helper/OtpAuthUrlParser.swift @@ -0,0 +1,101 @@ +import Foundation +import SwiftOTP + +public enum OtpAuthUrlError: LocalizedError, Equatable { + case invalidURL + case invalidQueryItem + case invalidType + + public var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid Otp Auth URL." + case .invalidQueryItem: + return "Invalid Query Item." + case .invalidType: + return "Invalid Type." + } + } +} + +public class OtpAuthUrlParser { + static func parseOtpAuthUrl(otpAuthStr: String) throws -> Token { + guard let otpAuthURL = URL(string: otpAuthStr), + let components = URLComponents(url: otpAuthURL, resolvingAgainstBaseURL: false), + let scheme = components.scheme, scheme == "otpauth", + let host = components.host + else { + throw OtpAuthUrlError.invalidURL + } + + let tokenType = try getTokenType(from: host) + let token = Token() + token.type = tokenType + + var path = components.path + if !path.isEmpty { + path.remove(at: path.startIndex) + + if !path.contains(":") { + token.account = path + } else { + let label = path.split(separator: ":", maxSplits: 1).map { String($0) } + + if label.count == 2 { + token.issuer = label[0] + token.account = label[1] + } + + // Label is malformed e.g. :Account or Issuer: + if label.count == 1 { + if path.hasPrefix(":") { + token.account = label[0] + } else if path.hasSuffix(":") { + token.issuer = label[0] + } + } + } + } + + guard let queryItems = components.queryItems else { throw OtpAuthUrlError.invalidQueryItem } + for item in queryItems { + guard let value = item.value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { continue } + switch item.name.lowercased() { + case "secret": + token.secret = value + case "issuer": + token.issuer = value + case "algorithm": + guard let algo = TokenAlgorithmEnum(rawValue: value.uppercased()) else { + throw TokenError.invalidAlgorithm(value) + } + token.algorithm = algo + case "digits": + token.digits = Int(value) ?? 6 + case "counter": + token.counter = Int(value) ?? 0 + case "period": + token.period = Int(value) ?? 30 + default: + break + } + } + + do { + try TokenValidator.validate(token: token) + } + + return token + } + + private static func getTokenType(from host: String) throws -> TokenTypeEnum { + switch host.lowercased() { + case "totp": + return .TOTP + case "hotp": + return .HOTP + default: + throw OtpAuthUrlError.invalidType + } + } +} diff --git a/Chronos/Helper/TokenValidator.swift b/Chronos/Helper/TokenValidator.swift new file mode 100644 index 0000000..45147f8 --- /dev/null +++ b/Chronos/Helper/TokenValidator.swift @@ -0,0 +1,65 @@ +import Foundation +import SwiftOTP + +public enum TokenError: LocalizedError, Equatable { + case invalidType + case invalidSecret(String) + case invalidAlgorithm(String) + case invalidDigits(Int) + case invalidCounter(Int) + case invalidPeriod(Int) + + public var errorDescription: String? { + switch self { + case .invalidType: + return "The token type is unsupported." + case let .invalidSecret(reason): + return "Invalid secret: \(reason)." + case let .invalidAlgorithm(reason): + return "Invalid algorithm: \(reason)." + case let .invalidDigits(digits): + return "Invalid number of digits: \(digits). Must be between 6 and 8." + case let .invalidCounter(counter): + return "Invalid counter value: \(counter)." + case let .invalidPeriod(period): + return "Invalid period value: \(period)." + } + } +} + +public class TokenValidator { + static func validate(token: Token) throws { + // Validate Secret + guard !token.secret.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw TokenError.invalidSecret("Secret cannot be empty.") + } + + guard let _ = base32DecodeToData(token.secret) else { + throw TokenError.invalidSecret("Secret is not base32 encoded.") + } + + // Validate Algorithm + guard let _ = TokenAlgorithmEnum(rawValue: token.algorithm.rawValue) else { + throw TokenError.invalidAlgorithm("The provided algorithm is not valid.") + } + + // Validate Digits + guard (6 ... 8).contains(token.digits) else { + throw TokenError.invalidDigits(token.digits) + } + + // Validate Counter (specific to HOTP) + if token.type == .HOTP { + guard token.counter >= 0 else { + throw TokenError.invalidCounter(token.counter) + } + } + + // Validate Period (specific to TOTP) + if token.type == .TOTP { + guard token.period > 0 else { + throw TokenError.invalidPeriod(token.period) + } + } + } +} diff --git a/Chronos/Services/Container.swift b/Chronos/Services/Container.swift index 92fcbb5..0bc8307 100644 --- a/Chronos/Services/Container.swift +++ b/Chronos/Services/Container.swift @@ -18,10 +18,6 @@ extension Container { Factory(self) { CryptoService() } } - var otpService: Factory { - Factory(self) { OTPService() } - } - var stateService: Factory { Factory(self) { StateService() } .singleton diff --git a/Chronos/Services/ExportService.swift b/Chronos/Services/ExportService.swift index ff77aa3..d06c2b9 100644 --- a/Chronos/Services/ExportService.swift +++ b/Chronos/Services/ExportService.swift @@ -12,7 +12,6 @@ public class ExportService { private let swiftDataService = Container.shared.swiftDataService() private let cryptoService = Container.shared.cryptoService() private let vaultService = Container.shared.vaultService() - private let otpService = Container.shared.otpService() private let verbatimStyle = Date.VerbatimFormatStyle( format: "\(day: .twoDigits)-\(month: .twoDigits)-\(year: .defaultDigits)", @@ -229,7 +228,7 @@ extension ExportService { } func tokenDetailsDiv(token: Token) -> Node { - let base64Img = otpService.tokenToOtpAuthUrl(token: token).flatMap { otpAuthUrl in + let base64Img = token.otpAuthUrl().flatMap { otpAuthUrl in guard let image = try? QRCode.build .text(otpAuthUrl) .generate diff --git a/Chronos/Services/Import/ImportService.swift b/Chronos/Services/Import/ImportService.swift index 62ec40d..b74552f 100644 --- a/Chronos/Services/Import/ImportService.swift +++ b/Chronos/Services/Import/ImportService.swift @@ -6,7 +6,6 @@ import SwiftyJSON public class ImportService { private let logger = Logger(label: "ImportService") private let vaultService = Container.shared.vaultService() - private let otpService = Container.shared.otpService() func importTokensViaJsonFile(importSource: ImportSource, url: URL) -> [Token]? { guard let inputJson = readJsonFile(url: url) else { @@ -148,7 +147,7 @@ extension ImportService { for otpAuthUrl in inputOtpAuthUrls { do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: otpAuthUrl) + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: otpAuthUrl) tokens.append(token) } catch { return nil diff --git a/Chronos/Services/OTPService.swift b/Chronos/Services/OTPService.swift deleted file mode 100644 index 3a01728..0000000 --- a/Chronos/Services/OTPService.swift +++ /dev/null @@ -1,151 +0,0 @@ -import Factory -import Foundation -import SwiftOTP - -public enum OTPError: Error { - case invalidURL - case unsupportedTokenType - case invalidQueryItem - case invalidSecret -} - -public class OTPService { - func generateTOTP(token: Token) -> String { - let digits = token.digits - let secret = base32DecodeToData(token.secret)! - let period = token.period - var algorithm = OTPAlgorithm.sha1 - - switch token.algorithm { - case TokenAlgorithmEnum.SHA1: - algorithm = OTPAlgorithm.sha1 - case TokenAlgorithmEnum.SHA256: - algorithm = OTPAlgorithm.sha256 - case TokenAlgorithmEnum.SHA512: - algorithm = OTPAlgorithm.sha512 - } - - let totp = TOTP(secret: secret, digits: digits, timeInterval: period, algorithm: algorithm) - - return totp!.generate(time: Date()) ?? "" - } - - func generateHOTP(token: Token) -> String { - let digits = token.digits - let secret = base32DecodeToData(token.secret)! - let counter = token.counter - let algorithm = OTPAlgorithm.sha1 - - let hotp = HOTP(secret: secret, digits: digits, algorithm: algorithm) - - return hotp!.generate(counter: UInt64(counter)) ?? "" - } - - func parseOtpAuthUrl(otpAuthStr: String) throws -> Token { - guard let otpAuthURL = URL(string: otpAuthStr), - let components = URLComponents(url: otpAuthURL, resolvingAgainstBaseURL: false), - let scheme = components.scheme, scheme == "otpauth", - let host = components.host - else { - throw OTPError.invalidURL - } - - var path = components.path - - let tokenType = try getTokenType(from: host) - let token = Token() - token.type = tokenType - - if !path.isEmpty { - path.remove(at: path.startIndex) - - if !path.contains(":") { - token.account = path - } else { - let label = path.split(separator: ":", maxSplits: 1).map { String($0) } - - if label.count == 2 { - token.issuer = label[0] - token.account = label[1] - } - - // Label is malformed e.g. :Account or Issuer: - if label.count == 1 { - if path.hasPrefix(":") { - token.account = label[0] - } else if path.hasSuffix(":") { - token.issuer = label[0] - } - } - } - } - - guard let queryItems = components.queryItems else { throw OTPError.invalidQueryItem } - - for item in queryItems { - switch item.name.lowercased() { - case "secret": - guard let secret = item.value?.trimmingCharacters(in: .whitespacesAndNewlines), secret.count > 0, base32DecodeToData(secret) != nil else { - throw OTPError.invalidSecret - } - token.secret = secret - case "issuer": - token.issuer = item.value ?? "" - case "algorithm": - token.algorithm = TokenAlgorithmEnum(rawValue: item.value?.uppercased() ?? "") ?? .SHA1 - case "digits": - token.digits = Int(item.value ?? "") ?? 6 - case "counter": - token.counter = Int(item.value ?? "") ?? 0 - case "period": - token.period = Int(item.value ?? "") ?? 30 - default: - break - } - } - - return token - } - - func tokenToOtpAuthUrl(token: Token) -> String? { - var components = URLComponents() - components.scheme = "otpauth" - components.host = token.type.rawValue.lowercased() - - components.path = !token.account.isEmpty - ? (!token.issuer.isEmpty ? "/\(token.issuer):\(token.account)" : "/\(token.account)") - : "/" - - var queryItems: [URLQueryItem] = [ - URLQueryItem(name: "secret", value: token.secret), - URLQueryItem(name: "algorithm", value: token.algorithm.rawValue), - URLQueryItem(name: "digits", value: "\(token.digits)"), - ] - - if !token.issuer.isEmpty { - queryItems.append(URLQueryItem(name: "issuer", value: token.issuer)) - } - - switch token.type { - case .TOTP: - queryItems.append(URLQueryItem(name: "period", value: "\(token.period)")) - case .HOTP: - queryItems.append(URLQueryItem(name: "counter", value: "\(token.counter)")) - } - - components.queryItems = queryItems - - return components.string - } - - private func getTokenType(from host: String) throws -> TokenTypeEnum { - switch host.lowercased() { - case "totp": - return .TOTP - case "hotp": - return .HOTP - default: - throw OTPError.unsupportedTokenType - } - } -} diff --git a/ChronosTests/Token/ParseOtpAuthUrl.swift b/ChronosTests/Token/ParseOtpAuthUrl.swift index f0c2509..922e988 100644 --- a/ChronosTests/Token/ParseOtpAuthUrl.swift +++ b/ChronosTests/Token/ParseOtpAuthUrl.swift @@ -2,12 +2,37 @@ import XCTest final class ParseOtpAuthUrlTests: XCTestCase { - func testTotp() throws { - let otpService = OTPService() + // Test invalid URL format + func testInvalidUrlFormat() { + let invalidUrl = "invalid-url-format" + + XCTAssertThrowsError(try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: invalidUrl)) { error in + XCTAssertEqual(error as? OtpAuthUrlError, .invalidURL) + } + } + + // Test invalid token type + func testInvalidTokenType() { + let invalidTokenTypeUrl = "otpauth://invalidtype/user@example.com?secret=JBSWY3DPEHPK3PXP" + + XCTAssertThrowsError(try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: invalidTokenTypeUrl)) { error in + XCTAssertEqual(error as? OtpAuthUrlError, .invalidType) + } + } + + // Test missing query items (e.g., missing secret) + func testMissingSecretQueryItem() { + let missingSecretUrl = "otpauth://totp/user@example.com" + XCTAssertThrowsError(try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: missingSecretUrl)) { error in + XCTAssertEqual(error as? OtpAuthUrlError, .invalidQueryItem) + } + } + + func testTotp() throws { // Test case 1: Standard TOTP token do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/Apple:john@appleseed.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=Apple&period=30") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/Apple:john@appleseed.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=Apple&period=30") XCTAssertEqual(token.issuer, "Apple") XCTAssertEqual(token.account, "john@appleseed.com") XCTAssertEqual(token.period, 30) @@ -21,7 +46,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 2: TOTP token without account do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=Apple&period=30") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=Apple&period=30") XCTAssertEqual(token.issuer, "Apple") XCTAssertEqual(token.account, "") XCTAssertEqual(token.period, 30) @@ -35,7 +60,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 3: TOTP token with different period and algorithm do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/Apple:john@appleseed.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=7&issuer=Apple&period=45") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/Apple:john@appleseed.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=7&issuer=Apple&period=45") XCTAssertEqual(token.issuer, "Apple") XCTAssertEqual(token.account, "john@appleseed.com") XCTAssertEqual(token.period, 45) @@ -49,7 +74,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 4: TOTP token without issuer do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/john@doe.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&period=30") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/john@doe.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&period=30") XCTAssertEqual(token.issuer, "") XCTAssertEqual(token.account, "john@doe.com") XCTAssertEqual(token.period, 30) @@ -63,7 +88,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 5: TOTP token with special characters in account and issuer do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/My%20Company:john.doe+test@mycompany.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=My%20Company&period=30") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/My%20Company:john.doe+test@mycompany.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=My%20Company&period=30") XCTAssertEqual(token.issuer, "My Company") XCTAssertEqual(token.account, "john.doe+test@mycompany.com") XCTAssertEqual(token.period, 30) @@ -77,7 +102,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 6: TOTP token with no issuer and no account do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&period=30") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&period=30") XCTAssertEqual(token.issuer, "") XCTAssertEqual(token.account, "") XCTAssertEqual(token.period, 30) @@ -91,7 +116,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 7: TOTP token with different digits and SHA512 algorithm do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/Google:alice@google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA512&digits=8&issuer=Google&period=60") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/Google:alice@google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA512&digits=8&issuer=Google&period=60") XCTAssertEqual(token.issuer, "Google") XCTAssertEqual(token.account, "alice@google.com") XCTAssertEqual(token.period, 60) @@ -105,9 +130,9 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 8: TOTP token with empty secret do { - _ = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/Example:user@example.com?secret=&algorithm=SHA1&digits=6&issuer=Example&period=30") + _ = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/Example:user@example.com?secret=&algorithm=SHA1&digits=6&issuer=Example&period=30") XCTFail("Expected OTPError.invalidSecret but no error was thrown") - } catch OTPError.invalidSecret { + } catch TokenError.invalidSecret { // Success: expected error was thrown } catch { XCTFail("Expected OTPError.invalidSecret but a different error was thrown") @@ -115,7 +140,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 9: Standard TOTP token with lowercase algorithm do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/GitHub:user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=sha1&digits=6&issuer=GitHub&period=30") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/GitHub:user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=sha1&digits=6&issuer=GitHub&period=30") XCTAssertEqual(token.issuer, "GitHub") XCTAssertEqual(token.account, "user@github.com") XCTAssertEqual(token.period, 30) @@ -129,7 +154,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 10: Standard TOTP token with lowercase algorithm non default do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/GitHub:user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=sha256&digits=6&issuer=GitHub&period=30") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://totp/GitHub:user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=sha256&digits=6&issuer=GitHub&period=30") XCTAssertEqual(token.issuer, "GitHub") XCTAssertEqual(token.account, "user@github.com") XCTAssertEqual(token.period, 30) @@ -143,11 +168,9 @@ final class ParseOtpAuthUrlTests: XCTestCase { } func testHotp() throws { - let otpService = OTPService() - // Test case 1: Standard HOTP token do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/GitHub:user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=GitHub&counter=1") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/GitHub:user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=GitHub&counter=1") XCTAssertEqual(token.issuer, "GitHub") XCTAssertEqual(token.account, "user@github.com") XCTAssertEqual(token.counter, 1) @@ -161,7 +184,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 2: HOTP token without account do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/GitHub:?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=GitHub&counter=1") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/GitHub:?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=GitHub&counter=1") XCTAssertEqual(token.issuer, "GitHub") XCTAssertEqual(token.account, "") XCTAssertEqual(token.counter, 1) @@ -175,7 +198,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 3: HOTP token with different counter and algorithm do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/GitHub:user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=7&issuer=GitHub&counter=10") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/GitHub:user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=7&issuer=GitHub&counter=10") XCTAssertEqual(token.issuer, "GitHub") XCTAssertEqual(token.account, "user@github.com") XCTAssertEqual(token.counter, 10) @@ -189,7 +212,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 4: HOTP token without issuer do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&counter=1") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&counter=1") XCTAssertEqual(token.issuer, "") XCTAssertEqual(token.account, "user@github.com") XCTAssertEqual(token.counter, 1) @@ -203,7 +226,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 5: HOTP token with special characters in account and issuer do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/My%20Company:user+test@mycompany.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=My%20Company&counter=1") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/My%20Company:user+test@mycompany.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=My%20Company&counter=1") XCTAssertEqual(token.issuer, "My Company") XCTAssertEqual(token.account, "user+test@mycompany.com") XCTAssertEqual(token.counter, 1) @@ -217,7 +240,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 6: HOTP token with no issuer and no account do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&counter=1") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&counter=1") XCTAssertEqual(token.issuer, "") XCTAssertEqual(token.account, "") XCTAssertEqual(token.counter, 1) @@ -231,7 +254,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 7: HOTP token with different digits and SHA512 algorithm do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/Google:user@google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA512&digits=8&issuer=Google&counter=100") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/Google:user@google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA512&digits=8&issuer=Google&counter=100") XCTAssertEqual(token.issuer, "Google") XCTAssertEqual(token.account, "user@google.com") XCTAssertEqual(token.counter, 100) @@ -245,9 +268,9 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 8: HOTP token with empty secret do { - _ = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/Example:user@example.com?secret=&algorithm=SHA1&digits=6&issuer=Example&counter=1") + _ = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/Example:user@example.com?secret=&algorithm=SHA1&digits=6&issuer=Example&counter=1") XCTFail("Expected OTPError.invalidSecret but no error was thrown") - } catch OTPError.invalidSecret { + } catch TokenError.invalidSecret { // Success: expected error was thrown } catch { XCTFail("Expected OTPError.invalidSecret but a different error was thrown") @@ -255,7 +278,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 9: Standard HOTP token with lowercase algorithm do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/GitHub:user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=sha1&digits=6&issuer=GitHub&counter=1") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/GitHub:user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=sha1&digits=6&issuer=GitHub&counter=1") XCTAssertEqual(token.issuer, "GitHub") XCTAssertEqual(token.account, "user@github.com") XCTAssertEqual(token.counter, 1) @@ -269,7 +292,7 @@ final class ParseOtpAuthUrlTests: XCTestCase { // Test case 10: Standard HOTP token with lowercase algorithm non default do { - let token = try otpService.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/GitHub:user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=sha256&digits=6&issuer=GitHub&counter=1") + let token = try OtpAuthUrlParser.parseOtpAuthUrl(otpAuthStr: "otpauth://hotp/GitHub:user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=sha256&digits=6&issuer=GitHub&counter=1") XCTAssertEqual(token.issuer, "GitHub") XCTAssertEqual(token.account, "user@github.com") XCTAssertEqual(token.counter, 1) diff --git a/ChronosTests/Token/QrCodeGenerationAndParsing.swift b/ChronosTests/Token/QrCodeGenerationAndParsing.swift index c9f43a7..b490817 100644 --- a/ChronosTests/Token/QrCodeGenerationAndParsing.swift +++ b/ChronosTests/Token/QrCodeGenerationAndParsing.swift @@ -5,8 +5,6 @@ import XCTest final class QrCodeGenerationAndParsingTests: XCTestCase { func testTotp() throws { - let otpService = OTPService() - // Test case 1: Standard TOTP token let token1 = Token() token1.issuer = "Apple" @@ -17,7 +15,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token1.type = TokenTypeEnum.TOTP token1.algorithm = TokenAlgorithmEnum.SHA1 - var url = otpService.tokenToOtpAuthUrl(token: token1)! + var url = token1.otpAuthUrl()! let qr1 = try QRCode.build .text(url) .generate @@ -36,7 +34,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token2.type = TokenTypeEnum.TOTP token2.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token2)! + url = token2.otpAuthUrl()! let qr2 = try QRCode.build .text(url) .generate @@ -56,7 +54,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token3.type = TokenTypeEnum.TOTP token3.algorithm = TokenAlgorithmEnum.SHA256 - url = otpService.tokenToOtpAuthUrl(token: token3)! + url = token3.otpAuthUrl()! let qr3 = try QRCode.build .text(url) .generate @@ -75,7 +73,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token4.type = TokenTypeEnum.TOTP token4.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token4)! + url = token4.otpAuthUrl()! let qr4 = try QRCode.build .text(url) .generate @@ -95,7 +93,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token5.type = TokenTypeEnum.TOTP token5.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token5)! + url = token5.otpAuthUrl()! let qr5 = try QRCode.build .text(url) .generate @@ -112,7 +110,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token6.type = TokenTypeEnum.TOTP token6.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token6)! + url = token6.otpAuthUrl()! let qr6 = try QRCode.build .text(url) .generate @@ -131,7 +129,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token7.type = TokenTypeEnum.TOTP token7.algorithm = TokenAlgorithmEnum.SHA512 - url = otpService.tokenToOtpAuthUrl(token: token7)! + url = token7.otpAuthUrl()! let qr7 = try QRCode.build .text(url) .generate @@ -150,7 +148,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token8.type = TokenTypeEnum.TOTP token8.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token8)! + url = token8.otpAuthUrl()! let qr8 = try QRCode.build .text(url) .generate @@ -161,8 +159,6 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { } func testHotp() throws { - let otpService = OTPService() - // Test case 1: Standard HOTP token let token1 = Token() token1.issuer = "GitHub" @@ -173,7 +169,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token1.type = TokenTypeEnum.HOTP token1.algorithm = TokenAlgorithmEnum.SHA1 - var url = otpService.tokenToOtpAuthUrl(token: token1)! + var url = token1.otpAuthUrl()! let qr1 = try QRCode.build .text(url) .generate @@ -191,7 +187,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token2.type = TokenTypeEnum.HOTP token2.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token2)! + url = token2.otpAuthUrl()! let qr2 = try QRCode.build .text(url) .generate @@ -210,7 +206,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token3.type = TokenTypeEnum.HOTP token3.algorithm = TokenAlgorithmEnum.SHA256 - url = otpService.tokenToOtpAuthUrl(token: token3)! + url = token3.otpAuthUrl()! let qr3 = try QRCode.build .text(url) .generate @@ -228,7 +224,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token4.type = TokenTypeEnum.HOTP token4.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token4)! + url = token4.otpAuthUrl()! let qr4 = try QRCode.build .text(url) .generate @@ -247,7 +243,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token5.type = TokenTypeEnum.HOTP token5.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token5)! + url = token5.otpAuthUrl()! let qr5 = try QRCode.build .text(url) .generate @@ -264,7 +260,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token6.type = TokenTypeEnum.HOTP token6.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token6)! + url = token6.otpAuthUrl()! let qr6 = try QRCode.build .text(url) .generate @@ -283,7 +279,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token7.type = TokenTypeEnum.HOTP token7.algorithm = TokenAlgorithmEnum.SHA512 - url = otpService.tokenToOtpAuthUrl(token: token7)! + url = token7.otpAuthUrl()! let qr7 = try QRCode.build .text(url) .generate @@ -302,7 +298,7 @@ final class QrCodeGenerationAndParsingTests: XCTestCase { token8.type = TokenTypeEnum.HOTP token8.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token8)! + url = token8.otpAuthUrl()! let qr8 = try QRCode.build .text(url) .generate diff --git a/ChronosTests/Token/TokenToOtpAuthUrl.swift b/ChronosTests/Token/TokenToOtpAuthUrl.swift index bc1975d..8bac4fc 100644 --- a/ChronosTests/Token/TokenToOtpAuthUrl.swift +++ b/ChronosTests/Token/TokenToOtpAuthUrl.swift @@ -3,8 +3,6 @@ import XCTest final class TokenToOtpAuthUrlTests: XCTestCase { func testTotp() throws { - let otpService = OTPService() - // Test case 1: Standard TOTP token let token1 = Token() token1.issuer = "Apple" @@ -15,7 +13,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token1.type = TokenTypeEnum.TOTP token1.algorithm = TokenAlgorithmEnum.SHA1 - var url = otpService.tokenToOtpAuthUrl(token: token1)! + var url = token1.otpAuthUrl()! XCTAssertEqual(url, "otpauth://totp/Apple:john@appleseed.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=Apple&period=30") // Test case 2: TOTP token without account @@ -27,7 +25,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token2.type = TokenTypeEnum.TOTP token2.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token2)! + url = token2.otpAuthUrl()! XCTAssertEqual(url, "otpauth://totp/?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=Apple&period=30") // Test case 3: TOTP token with different period and algorithm @@ -40,7 +38,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token3.type = TokenTypeEnum.TOTP token3.algorithm = TokenAlgorithmEnum.SHA256 - url = otpService.tokenToOtpAuthUrl(token: token3)! + url = token3.otpAuthUrl()! XCTAssertEqual(url, "otpauth://totp/Apple:john@appleseed.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=7&issuer=Apple&period=45") // Test case 4: TOTP token without issuer @@ -52,7 +50,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token4.type = TokenTypeEnum.TOTP token4.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token4)! + url = token4.otpAuthUrl()! XCTAssertEqual(url, "otpauth://totp/john@doe.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&period=30") // Test case 5: TOTP token with special characters in account and issuer @@ -65,7 +63,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token5.type = TokenTypeEnum.TOTP token5.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token5)! + url = token5.otpAuthUrl()! XCTAssertEqual(url, "otpauth://totp/My%20Company:john.doe+test@mycompany.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=My%20Company&period=30") // Test case 6: TOTP token with no issuer and no account @@ -76,7 +74,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token6.type = TokenTypeEnum.TOTP token6.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token6)! + url = token6.otpAuthUrl()! XCTAssertEqual(url, "otpauth://totp/?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&period=30") // Test case 7: TOTP token with different digits and SHA512 algorithm @@ -89,7 +87,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token7.type = TokenTypeEnum.TOTP token7.algorithm = TokenAlgorithmEnum.SHA512 - url = otpService.tokenToOtpAuthUrl(token: token7)! + url = token7.otpAuthUrl()! XCTAssertEqual(url, "otpauth://totp/Google:alice@google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA512&digits=8&issuer=Google&period=60") // Test case 8: TOTP token with empty secret @@ -102,13 +100,11 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token8.type = TokenTypeEnum.TOTP token8.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token8)! + url = token8.otpAuthUrl()! XCTAssertEqual(url, "otpauth://totp/Example:user@example.com?secret=&algorithm=SHA1&digits=6&issuer=Example&period=30") } func testHotp() throws { - let otpService = OTPService() - // Test case 1: Standard HOTP token let token1 = Token() token1.issuer = "GitHub" @@ -119,7 +115,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token1.type = TokenTypeEnum.HOTP token1.algorithm = TokenAlgorithmEnum.SHA1 - var url = otpService.tokenToOtpAuthUrl(token: token1)! + var url = token1.otpAuthUrl()! XCTAssertEqual(url, "otpauth://hotp/GitHub:user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=GitHub&counter=1") // Test case 2: HOTP token without account @@ -131,7 +127,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token2.type = TokenTypeEnum.HOTP token2.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token2)! + url = token2.otpAuthUrl()! XCTAssertEqual(url, "otpauth://hotp/?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=GitHub&counter=1") // Test case 3: HOTP token with different counter and algorithm @@ -144,7 +140,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token3.type = TokenTypeEnum.HOTP token3.algorithm = TokenAlgorithmEnum.SHA256 - url = otpService.tokenToOtpAuthUrl(token: token3)! + url = token3.otpAuthUrl()! XCTAssertEqual(url, "otpauth://hotp/GitHub:user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=7&issuer=GitHub&counter=10") // Test case 4: HOTP token without issuer @@ -156,7 +152,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token4.type = TokenTypeEnum.HOTP token4.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token4)! + url = token4.otpAuthUrl()! XCTAssertEqual(url, "otpauth://hotp/user@github.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&counter=1") // Test case 5: HOTP token with special characters in account and issuer @@ -169,7 +165,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token5.type = TokenTypeEnum.HOTP token5.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token5)! + url = token5.otpAuthUrl()! XCTAssertEqual(url, "otpauth://hotp/My%20Company:user+test@mycompany.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&issuer=My%20Company&counter=1") // Test case 6: HOTP token with no issuer and no account @@ -180,7 +176,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token6.type = TokenTypeEnum.HOTP token6.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token6)! + url = token6.otpAuthUrl()! XCTAssertEqual(url, "otpauth://hotp/?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&counter=1") // Test case 7: HOTP token with different digits and SHA512 algorithm @@ -193,7 +189,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token7.type = TokenTypeEnum.HOTP token7.algorithm = TokenAlgorithmEnum.SHA512 - url = otpService.tokenToOtpAuthUrl(token: token7)! + url = token7.otpAuthUrl()! XCTAssertEqual(url, "otpauth://hotp/Google:user@google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA512&digits=8&issuer=Google&counter=100") // Test case 8: HOTP token with empty secret @@ -206,7 +202,7 @@ final class TokenToOtpAuthUrlTests: XCTestCase { token8.type = TokenTypeEnum.HOTP token8.algorithm = TokenAlgorithmEnum.SHA1 - url = otpService.tokenToOtpAuthUrl(token: token8)! + url = token8.otpAuthUrl()! XCTAssertEqual(url, "otpauth://hotp/Example:user@example.com?secret=&algorithm=SHA1&digits=6&issuer=Example&counter=1") } } diff --git a/ChronosTests/Token/TokenValidator.swift b/ChronosTests/Token/TokenValidator.swift new file mode 100644 index 0000000..eba1608 --- /dev/null +++ b/ChronosTests/Token/TokenValidator.swift @@ -0,0 +1,136 @@ +@testable import Chronos +import XCTest + +class TokenValidatorTests: XCTestCase { + // Helper function to create a valid base32 secret + private func validBase32Secret() -> String { + return "JBSWY3DPEHPK3PXP" // A valid base32 encoded string + } + + // Test valid TOTP token + func testValidTOTPToken() { + let token = Token() + token.secret = validBase32Secret() + token.type = .TOTP + token.digits = 6 + token.period = 30 + + XCTAssertNoThrow(try TokenValidator.validate(token: token)) + } + + // Test valid HOTP token + func testValidHOTPToken() { + let token = Token() + token.secret = validBase32Secret() + token.type = .HOTP + token.digits = 6 + token.counter = 1 + + XCTAssertNoThrow(try TokenValidator.validate(token: token)) + } + + // Test empty secret + func testEmptySecret() { + let token = Token() + token.secret = "" + token.type = .TOTP + + XCTAssertThrowsError(try TokenValidator.validate(token: token)) { error in + XCTAssertEqual(error as? TokenError, TokenError.invalidSecret("Secret cannot be empty.")) + } + } + + // Test invalid (non-base32) secret + func testInvalidSecret() { + let token = Token() + token.secret = "INVALID_SECRET" + token.type = .TOTP + + XCTAssertThrowsError(try TokenValidator.validate(token: token)) { error in + XCTAssertEqual(error as? TokenError, TokenError.invalidSecret("Secret is not base32 encoded.")) + } + } + + // Test invalid digits (not in the range of 6-8) + func testInvalidDigitsUpper() { + let token = Token() + token.secret = validBase32Secret() + token.type = .TOTP + token.digits = 9 + + XCTAssertThrowsError(try TokenValidator.validate(token: token)) { error in + XCTAssertEqual(error as? TokenError, TokenError.invalidDigits(9)) + } + } + + func testInvalidDigitsLower() { + let token = Token() + token.secret = validBase32Secret() + token.type = .TOTP + token.digits = 5 + + XCTAssertThrowsError(try TokenValidator.validate(token: token)) { error in + XCTAssertEqual(error as? TokenError, TokenError.invalidDigits(5)) + } + } + + func testInvalidDigitsZero() { + let token = Token() + token.secret = validBase32Secret() + token.type = .TOTP + token.digits = 0 + + XCTAssertThrowsError(try TokenValidator.validate(token: token)) { error in + XCTAssertEqual(error as? TokenError, TokenError.invalidDigits(0)) + } + } + + func testInvalidDigitsNegative() { + let token = Token() + token.secret = validBase32Secret() + token.type = .TOTP + token.digits = -10 + + XCTAssertThrowsError(try TokenValidator.validate(token: token)) { error in + XCTAssertEqual(error as? TokenError, TokenError.invalidDigits(-10)) + } + } + + // Test negative counter for HOTP token + func testNegativeCounterForHOTP() { + let token = Token() + token.secret = validBase32Secret() + token.type = .HOTP + token.digits = 6 + token.counter = -1 + + XCTAssertThrowsError(try TokenValidator.validate(token: token)) { error in + XCTAssertEqual(error as? TokenError, TokenError.invalidCounter(-1)) + } + } + + // Test invalid period for TOTP token (must be greater than 0) + func testInvalidPeriodForTOTP() { + let token = Token() + token.secret = validBase32Secret() + token.type = .TOTP + token.digits = 6 + token.period = 0 + + XCTAssertThrowsError(try TokenValidator.validate(token: token)) { error in + XCTAssertEqual(error as? TokenError, TokenError.invalidPeriod(0)) + } + } + + func testInvalidPeriodForTOTPNegative() { + let token = Token() + token.secret = validBase32Secret() + token.type = .TOTP + token.digits = 6 + token.period = -10 + + XCTAssertThrowsError(try TokenValidator.validate(token: token)) { error in + XCTAssertEqual(error as? TokenError, TokenError.invalidPeriod(-10)) + } + } +}