diff --git a/Chronos.xcodeproj/project.pbxproj b/Chronos.xcodeproj/project.pbxproj index 211c7de..4b7c4e0 100644 --- a/Chronos.xcodeproj/project.pbxproj +++ b/Chronos.xcodeproj/project.pbxproj @@ -66,6 +66,9 @@ 6BC3C3B52BA6B91E00B181B9 /* BiometricsSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC3C3B42BA6B91E00B181B9 /* BiometricsSetupView.swift */; }; 6BC5F0482C4F8B1B00BA106F /* Html in Frameworks */ = {isa = PBXBuildFile; productRef = 6BC5F0472C4F8B1B00BA106F /* Html */; }; 6BC5F04A2C4FDE6E00BA106F /* ParseOtpAuthUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC5F0492C4FDE6E00BA106F /* ParseOtpAuthUrl.swift */; }; + 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 */; }; @@ -139,6 +142,8 @@ 6BB37D842C483066008DA122 /* ImportFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportFailureView.swift; sourceTree = ""; }; 6BC3C3B42BA6B91E00B181B9 /* BiometricsSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BiometricsSetupView.swift; sourceTree = ""; }; 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 = ""; }; @@ -162,6 +167,7 @@ 6B66D5ED2B526648006DB79D /* CodeScanner in Frameworks */, 6B7383E02B9C3962008E8867 /* Factory in Frameworks */, 6B3C7A3D2BE9E0600043FEBD /* Logging in Frameworks */, + 6BC5F0522C52429100BA106F /* SwiftProtobuf in Frameworks */, 6B193C8A2C27F03300E759B7 /* CloudKitSyncMonitor in Frameworks */, 6BB37D812C46C751008DA122 /* SwiftyJSON in Frameworks */, 6BF53E4F2C317AA400356461 /* ZipArchive in Frameworks */, @@ -312,6 +318,7 @@ children = ( 6B4CBF2E2C490FB700983D44 /* Chronos.swift */, 6B8132F12C4975DA00DB367E /* Raivo.swift */, + 6BC5F0562C529A2A00BA106F /* GoogleAuthenticator.swift */, ); path = Import; sourceTree = ""; @@ -337,6 +344,7 @@ 6B7383E12B9C3975008E8867 /* Services */ = { isa = PBXGroup; children = ( + 6BC5F04D2C5240D000BA106F /* Import */, 6B7383E42B9C4230008E8867 /* Container.swift */, 6BA5DA5B2B9E94F8009908E5 /* SecureEnclaveService.swift */, 6B39629D2BF63F27000410B0 /* SwiftDataService.swift */, @@ -345,7 +353,6 @@ 6B9D74642C14ADDC008E6582 /* StateService.swift */, 6B12B09F2C19DB7800E9ED2D /* ExportService.swift */, 6B3F92AA2C1C7987004125A8 /* VaultService.swift */, - 6BB37D7D2C466B60008DA122 /* ImportService.swift */, ); path = Services; sourceTree = ""; @@ -404,6 +411,23 @@ path = Import; sourceTree = ""; }; + 6BC5F04D2C5240D000BA106F /* Import */ = { + isa = PBXGroup; + children = ( + 6BC5F0532C5242A900BA106F /* Protos */, + 6BB37D7D2C466B60008DA122 /* ImportService.swift */, + ); + path = Import; + sourceTree = ""; + }; + 6BC5F0532C5242A900BA106F /* Protos */ = { + isa = PBXGroup; + children = ( + 6BC5F0542C5242B500BA106F /* GoogleAuth.pb.swift */, + ); + path = Protos; + sourceTree = ""; + }; 6BD0562A2BDF64D80099616B /* Row */ = { isa = PBXGroup; children = ( @@ -481,6 +505,7 @@ 6BB37D802C46C751008DA122 /* SwiftyJSON */, 6B8132F62C4BB2B200DB367E /* EFQRCode */, 6BC5F0472C4F8B1B00BA106F /* Html */, + 6BC5F0512C52429100BA106F /* SwiftProtobuf */, ); productName = Chronos; productReference = 6B3BB0AD2B4EA87C00DCEF0B /* Chronos.app */; @@ -545,6 +570,7 @@ 6BB37D7F2C46C751008DA122 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, 6B8132F52C4BB2B200DB367E /* XCRemoteSwiftPackageReference "EFQRCode" */, 6BC5F0462C4F8B1B00BA106F /* XCRemoteSwiftPackageReference "swift-html" */, + 6BC5F0502C52429100BA106F /* XCRemoteSwiftPackageReference "swift-protobuf" */, ); productRefGroup = 6B3BB0AE2B4EA87C00DCEF0B /* Products */; projectDirPath = ""; @@ -646,6 +672,7 @@ 6BB37D7A2C456B32008DA122 /* ImportSourceListView.swift in Sources */, 6B3962A02BF6423B000410B0 /* CryptoService.swift in Sources */, 6B27317A2B53E23800F30621 /* UpdateTokenView.swift in Sources */, + 6BC5F0552C52464600BA106F /* GoogleAuth.pb.swift in Sources */, 6B27317C2B53F0B200F30621 /* TokenRowView.swift in Sources */, 6B8133002C4E634100DB367E /* PasswordReminder.swift in Sources */, ); @@ -657,6 +684,7 @@ files = ( 6B8132FD2C4E5F6B00DB367E /* QrCodeGenerationAndParsing.swift in Sources */, 6B4CBF2F2C490FB700983D44 /* Chronos.swift in Sources */, + 6BC5F0572C529A2A00BA106F /* GoogleAuthenticator.swift in Sources */, 6BC5F04A2C4FDE6E00BA106F /* ParseOtpAuthUrl.swift in Sources */, 6B8132F22C4975DA00DB367E /* Raivo.swift in Sources */, 6B8132FA2C4C0F6300DB367E /* TokenToOtpAuthUrl.swift in Sources */, @@ -1338,6 +1366,14 @@ minimumVersion = 0.4.1; }; }; + 6BC5F0502C52429100BA106F /* XCRemoteSwiftPackageReference "swift-protobuf" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-protobuf.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.27.1; + }; + }; 6BF53E4B2C31793100356461 /* XCRemoteSwiftPackageReference "ZipArchive" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ZipArchive/ZipArchive"; @@ -1404,6 +1440,11 @@ package = 6BC5F0462C4F8B1B00BA106F /* XCRemoteSwiftPackageReference "swift-html" */; productName = Html; }; + 6BC5F0512C52429100BA106F /* SwiftProtobuf */ = { + isa = XCSwiftPackageProductDependency; + package = 6BC5F0502C52429100BA106F /* XCRemoteSwiftPackageReference "swift-protobuf" */; + productName = SwiftProtobuf; + }; 6BF53E4E2C317AA400356461 /* ZipArchive */ = { isa = XCSwiftPackageProductDependency; package = 6BF53E4B2C31793100356461 /* XCRemoteSwiftPackageReference "ZipArchive" */; diff --git a/Chronos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Chronos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5163def..ada1f7c 100644 --- a/Chronos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Chronos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6875bdc952d508b7fe3f1a37ca1cf2a25b17288f298b731f7dc79f78e0a0b874", + "originHash" : "19b35a59954c273e4fee0aed72f9d6d02d424ede42e3faace584d56d8e92d701", "pins" : [ { "identity" : "alertkit", @@ -82,6 +82,15 @@ "version" : "1.6.1" } }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "e17d61f26df0f0e06f58f6977ba05a097a720106", + "version" : "1.27.1" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", @@ -96,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c", - "version" : "600.0.0-prerelease-2024-06-12" + "revision" : "82a453c2dfa335c7e778695762438dfe72b328d2", + "version" : "600.0.0-prerelease-2024-07-24" } }, { diff --git a/Chronos/App/MainAppView.swift b/Chronos/App/MainAppView.swift index d9aca75..68136d5 100644 --- a/Chronos/App/MainAppView.swift +++ b/Chronos/App/MainAppView.swift @@ -55,7 +55,6 @@ struct MainAppView: View { } if biometricsEnabled && statePasswordReminderEnabled { - print("Fuck \(nextPasswordReminderTimestamp)") if Date().timeIntervalSince1970 >= nextPasswordReminderTimestamp { showPasswordReminder = true } diff --git a/Chronos/App/Tabs/Settings/Import/ImportSourceDetailView.swift b/Chronos/App/Tabs/Settings/Import/ImportSourceDetailView.swift index 48d6de0..3df0e3b 100644 --- a/Chronos/App/Tabs/Settings/Import/ImportSourceDetailView.swift +++ b/Chronos/App/Tabs/Settings/Import/ImportSourceDetailView.swift @@ -1,16 +1,31 @@ +import AlertKit +import CodeScanner import Factory import SwiftUI +class ImportSourceDetailViewModel: ObservableObject { + @Published var tokens: [Token]? + private let importService = Container.shared.importService() + + func importTokensFromFile(importSource: ImportSource, fileUrl: URL) { + tokens = importService.importTokensViaFile(importSource: importSource, url: fileUrl) + } + + func importTokensFromString(importSource: ImportSource, scannedStr: String) { + tokens = importService.importTokensViaString(importSource: importSource, scannedStr: scannedStr) + } +} + struct ImportSourceDetailView: View { @State var importSource: ImportSource @State private var showFileImporter = false @State private var showImportConfirmation = false - @State private var tokens: [Token]? + @State private var unableToAccessCamera = false @EnvironmentObject private var importNav: ExportNavigation - private let importService = Container.shared.importService() + @StateObject private var viewModel = ImportSourceDetailViewModel() var body: some View { VStack { @@ -23,26 +38,52 @@ struct ImportSourceDetailView: View { Spacer() - Button(action: { showFileImporter = true }) { - Text("Select file") - .bold() - .frame(maxWidth: .infinity) - .frame(height: 32) + if importSource.importType == .JSON { + Button(action: { showFileImporter = true }) { + Text("Select file") + .bold() + .frame(maxWidth: .infinity) + .frame(height: 32) + } + .buttonStyle(.bordered) + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.json], + allowsMultipleSelection: false, + onCompletion: handleFileImport + ) } - .buttonStyle(.bordered) - .fileImporter( - isPresented: $showFileImporter, - allowedContentTypes: [.json], - allowsMultipleSelection: false, - onCompletion: handleFileImport - ) - .sheet(isPresented: $showImportConfirmation) { - NavigationStack { - if let tokens = tokens { - ImportConfirmationView(tokens: tokens) - } else { - ImportFailureView() + + if importSource.importType == .IMAGE { + if !unableToAccessCamera { + CodeScannerView( + codeTypes: [.qr], + scanMode: .once, + scanInterval: 0.1, + shouldVibrateOnSuccess: false, + completion: handleScan + ) + .frame(height: 200, alignment: .center) + .frame(minWidth: 0, maxWidth: .infinity) + .cornerRadius(8) + } else { + VStack { + Spacer() + Text("Chronos requires camera access to scan 2FA QR codes") + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .bold() + .padding(.horizontal, 16) + Button("Open settings") { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + } + .padding(.top, 4) + Spacer() } + .frame(height: 200, alignment: .center) + .frame(minWidth: 0, maxWidth: .infinity) + .background(.black) + .cornerRadius(8) } } @@ -53,21 +94,43 @@ struct ImportSourceDetailView: View { .frame(height: 32) } .buttonStyle(.borderless) + .padding(.top, 8) } .padding(.horizontal, 24) .padding(.bottom, 32) .navigationTitle("Import from \(importSource.name)") + .sheet(isPresented: $showImportConfirmation) { + NavigationStack { + if let tokens = viewModel.tokens { + ImportConfirmationView(tokens: tokens) + } else { + ImportFailureView() + } + } + } } private func handleFileImport(result: Result<[URL], Error>) { switch result { case let .success(fileUrls): if let fileUrl = fileUrls.first { - tokens = importService.importTokens(importSource: importSource, url: fileUrl) + viewModel.importTokensFromFile(importSource: importSource, fileUrl: fileUrl) showImportConfirmation = true } case let .failure(error): print(error) } } + + private func handleScan(result: Result) { + switch result { + case let .success(result): + viewModel.importTokensFromString(importSource: importSource, scannedStr: result.string) + showImportConfirmation = true + case .failure: + DispatchQueue.main.async { + unableToAccessCamera = true + } + } + } } diff --git a/Chronos/App/Tabs/Settings/Import/ImportSourceListView.swift b/Chronos/App/Tabs/Settings/Import/ImportSourceListView.swift index ebdfbd6..8b37678 100644 --- a/Chronos/App/Tabs/Settings/Import/ImportSourceListView.swift +++ b/Chronos/App/Tabs/Settings/Import/ImportSourceListView.swift @@ -1,15 +1,28 @@ import SwiftUI +enum ImportSourceId { + case CHRONOS + case RAIVO + case GOOGLE_AUTHENTICATOR +} + +enum ImportType { + case JSON + case IMAGE +} + struct ImportSource: Identifiable { - var id: String + var id: ImportSourceId var name: String var desc: String + var importType: ImportType } struct ImportSourceListView: View { let importSources: [ImportSource] = [ - ImportSource(id: "chronos", name: "Chronos", desc: "Export your tokens from Chronos to an unencrypted JSON file, then select the file below."), - ImportSource(id: "raivo", name: "Raivo", desc: "Export your tokens from Raivo using \"Export OTPs to ZIP archive\" option. Extract the JSON file from the archive, then select the file below."), + ImportSource(id: .CHRONOS, name: "Chronos", desc: "Export your tokens from Chronos to an unencrypted JSON file, then select the file below.", importType: .JSON), + ImportSource(id: .RAIVO, name: "Raivo", desc: "Export your tokens from Raivo using \"Export OTPs to ZIP archive\" option. Extract the JSON file from the archive, then select the file below.", importType: .JSON), + ImportSource(id: .GOOGLE_AUTHENTICATOR, name: "Google Authenticator", desc: "Export your tokens from Google Authenticator using the \"Transfer accounts\" option. Scan the QR code.", importType: .IMAGE), ] @EnvironmentObject var importNav: ExportNavigation diff --git a/Chronos/Services/Import/ImportService.swift b/Chronos/Services/Import/ImportService.swift new file mode 100644 index 0000000..e515011 --- /dev/null +++ b/Chronos/Services/Import/ImportService.swift @@ -0,0 +1,184 @@ +import Factory +import Foundation +import Logging +import SwiftyJSON + +public class ImportService { + private let logger = Logger(label: "ImportService") + private let vaultService = Container.shared.vaultService() + + func importTokensViaFile(importSource: ImportSource, url: URL) -> [Token]? { + guard let json = readFile(url: url) else { + logger.error("Failed to read file at \(url)") + return nil + } + + switch importSource.id { + case .CHRONOS: + return importFromChronos(json: json) + case .RAIVO: + return importFromRaivo(json: json) + default: + return nil + } + } + + func importTokensViaString(importSource: ImportSource, scannedStr: String) -> [Token]? { + switch importSource.id { + case .GOOGLE_AUTHENTICATOR: + return importFromGoogleAuth(otpAuthMigration: scannedStr) + default: + return nil + } + } + + private func readFile(url: URL) -> JSON? { + guard url.startAccessingSecurityScopedResource() else { + logger.error("Failed to start accessing security scoped resource for \(url)") + return nil + } + + defer { url.stopAccessingSecurityScopedResource() } + + do { + let jsonData = try String(contentsOf: url, encoding: .utf8) + return JSON(parseJSON: jsonData) + } catch { + logger.error("Error reading file at \(url): \(error.localizedDescription)") + return nil + } + } +} + +extension ImportService { + func importFromChronos(json: JSON) -> [Token]? { + do { + let decoder = JSONDecoder() + let tokens = try decoder.decode([Token].self, from: json["tokens"].rawData()) + return tokens + } catch { + logger.error("Error decoding tokens from JSON: \(error.localizedDescription)") + return nil + } + } + + func importFromRaivo(json: JSON) -> [Token]? { + var tokens: [Token] = [] + + for (key, subJson) in json { + guard + let issuer = subJson["issuer"].string, + let account = subJson["account"].string, + let secret = subJson["secret"].string, + + let digitsString = subJson["digits"].string, + let digits = Int(digitsString), + + let periodString = subJson["timer"].string, + let period = Int(periodString), + + let counterString = subJson["counter"].string, + let counter = Int(counterString), + + let kind = subJson["kind"].string, + let algorithm = subJson["algorithm"].string, + let tokenType = TokenTypeEnum(rawValue: kind), + let tokenAlgorithm = TokenAlgorithmEnum(rawValue: algorithm) + else { + logger.error("Error parsing token data for key: \(key)") + continue + } + + let token = Token() + token.issuer = issuer + token.account = account + token.secret = secret + token.digits = digits + token.period = period + token.counter = counter + token.type = tokenType + token.algorithm = tokenAlgorithm + + tokens.append(token) + } + + return tokens + } + + func importFromGoogleAuth(otpAuthMigration: String) -> [Token]? { + guard let otpAuthMigrationUrl = URL(string: otpAuthMigration), + let components = URLComponents(url: otpAuthMigrationUrl, resolvingAgainstBaseURL: false), + let scheme = components.scheme, scheme == "otpauth-migration", + let host = components.host, host == "offline", + let query = components.queryItems, + let dataItem = query.first(where: { $0.name == "data" }), + let encodedData = dataItem.value?.removingPercentEncoding, + let decodedData = Data(base64Encoded: encodedData), + let gaTokens = try? MigrationPayload(serializedBytes: decodedData) + else { + return nil + } + + var tokens: [Token] = [] + + for gaToken in gaTokens.otpParameters { + var tokenDigits = 6 + switch gaToken.digits { + case .six: + tokenDigits = 6 + case .eight: + tokenDigits = 8 + default: + tokenDigits = 6 + } + + var tokenType = TokenTypeEnum.TOTP + switch gaToken.type { + case .hotp: + tokenType = TokenTypeEnum.HOTP + case .totp: + tokenType = TokenTypeEnum.TOTP + default: + tokenType = TokenTypeEnum.TOTP + } + + var tokenAlgo = TokenAlgorithmEnum.SHA1 + switch gaToken.algorithm { + case .sha1: + tokenAlgo = TokenAlgorithmEnum.SHA1 + case .sha256: + tokenAlgo = TokenAlgorithmEnum.SHA256 + case .sha512: + tokenAlgo = TokenAlgorithmEnum.SHA512 + case .md5: + return nil + default: + tokenAlgo = TokenAlgorithmEnum.SHA1 + } + + let token = Token() + token.issuer = gaToken.issuer + token.account = gaToken.name + token.digits = tokenDigits + token.type = tokenType + token.algorithm = tokenAlgo + token.secret = gaToken.secret.base32EncodedString + + if tokenType == .TOTP { + token.period = 30 // GA only allows 30 secs + } + + if tokenType == .HOTP { + token.counter = Int(gaToken.counter) + } + + tokens.append(token) + } + + if tokens.count != gaTokens.otpParameters.count { + return nil + } + + return tokens + } +} diff --git a/Chronos/Services/Import/Protos/GoogleAuth.pb.swift b/Chronos/Services/Import/Protos/GoogleAuth.pb.swift new file mode 100644 index 0000000..e990be5 --- /dev/null +++ b/Chronos/Services/Import/Protos/GoogleAuth.pb.swift @@ -0,0 +1,336 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: google_auth.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +import Foundation +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +private struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +struct MigrationPayload: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var otpParameters: [MigrationPayload.OtpParameters] = [] + + var version: Int32 = 0 + + var batchSize: Int32 = 0 + + var batchIndex: Int32 = 0 + + var batchID: Int32 = 0 + + var unknownFields = SwiftProtobuf.UnknownStorage() + + enum Algorithm: SwiftProtobuf.Enum, Swift.CaseIterable { + typealias RawValue = Int + case unspecified // = 0 + case sha1 // = 1 + case sha256 // = 2 + case sha512 // = 3 + case md5 // = 4 + case UNRECOGNIZED(Int) + + init() { + self = .unspecified + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .sha1 + case 2: self = .sha256 + case 3: self = .sha512 + case 4: self = .md5 + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .unspecified: return 0 + case .sha1: return 1 + case .sha256: return 2 + case .sha512: return 3 + case .md5: return 4 + case let .UNRECOGNIZED(i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + static let allCases: [MigrationPayload.Algorithm] = [ + .unspecified, + .sha1, + .sha256, + .sha512, + .md5, + ] + } + + enum DigitCount: SwiftProtobuf.Enum, Swift.CaseIterable { + typealias RawValue = Int + case unspecified // = 0 + case six // = 1 + case eight // = 2 + case UNRECOGNIZED(Int) + + init() { + self = .unspecified + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .six + case 2: self = .eight + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .unspecified: return 0 + case .six: return 1 + case .eight: return 2 + case let .UNRECOGNIZED(i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + static let allCases: [MigrationPayload.DigitCount] = [ + .unspecified, + .six, + .eight, + ] + } + + enum OtpType: SwiftProtobuf.Enum, Swift.CaseIterable { + typealias RawValue = Int + case unspecified // = 0 + case hotp // = 1 + case totp // = 2 + case UNRECOGNIZED(Int) + + init() { + self = .unspecified + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .hotp + case 2: self = .totp + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .unspecified: return 0 + case .hotp: return 1 + case .totp: return 2 + case let .UNRECOGNIZED(i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + static let allCases: [MigrationPayload.OtpType] = [ + .unspecified, + .hotp, + .totp, + ] + } + + struct OtpParameters: @unchecked Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var secret: Data = .init() + + var name: String = .init() + + var issuer: String = .init() + + var algorithm: MigrationPayload.Algorithm = .unspecified + + var digits: MigrationPayload.DigitCount = .unspecified + + var type: MigrationPayload.OtpType = .unspecified + + var counter: Int64 = 0 + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + } + + init() {} +} + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +extension MigrationPayload: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = "MigrationPayload" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .standard(proto: "otp_parameters"), + 2: .same(proto: "version"), + 3: .standard(proto: "batch_size"), + 4: .standard(proto: "batch_index"), + 5: .standard(proto: "batch_id"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try decoder.decodeRepeatedMessageField(value: &otpParameters) + case 2: try decoder.decodeSingularInt32Field(value: &version) + case 3: try decoder.decodeSingularInt32Field(value: &batchSize) + case 4: try decoder.decodeSingularInt32Field(value: &batchIndex) + case 5: try decoder.decodeSingularInt32Field(value: &batchID) + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !otpParameters.isEmpty { + try visitor.visitRepeatedMessageField(value: otpParameters, fieldNumber: 1) + } + if version != 0 { + try visitor.visitSingularInt32Field(value: version, fieldNumber: 2) + } + if batchSize != 0 { + try visitor.visitSingularInt32Field(value: batchSize, fieldNumber: 3) + } + if batchIndex != 0 { + try visitor.visitSingularInt32Field(value: batchIndex, fieldNumber: 4) + } + if batchID != 0 { + try visitor.visitSingularInt32Field(value: batchID, fieldNumber: 5) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func == (lhs: MigrationPayload, rhs: MigrationPayload) -> Bool { + if lhs.otpParameters != rhs.otpParameters { return false } + if lhs.version != rhs.version { return false } + if lhs.batchSize != rhs.batchSize { return false } + if lhs.batchIndex != rhs.batchIndex { return false } + if lhs.batchID != rhs.batchID { return false } + if lhs.unknownFields != rhs.unknownFields { return false } + return true + } +} + +extension MigrationPayload.Algorithm: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "ALGORITHM_UNSPECIFIED"), + 1: .same(proto: "ALGORITHM_SHA1"), + 2: .same(proto: "ALGORITHM_SHA256"), + 3: .same(proto: "ALGORITHM_SHA512"), + 4: .same(proto: "ALGORITHM_MD5"), + ] +} + +extension MigrationPayload.DigitCount: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "DIGIT_COUNT_UNSPECIFIED"), + 1: .same(proto: "DIGIT_COUNT_SIX"), + 2: .same(proto: "DIGIT_COUNT_EIGHT"), + ] +} + +extension MigrationPayload.OtpType: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "OTP_TYPE_UNSPECIFIED"), + 1: .same(proto: "OTP_TYPE_HOTP"), + 2: .same(proto: "OTP_TYPE_TOTP"), + ] +} + +extension MigrationPayload.OtpParameters: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = MigrationPayload.protoMessageName + ".OtpParameters" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "secret"), + 2: .same(proto: "name"), + 3: .same(proto: "issuer"), + 4: .same(proto: "algorithm"), + 5: .same(proto: "digits"), + 6: .same(proto: "type"), + 7: .same(proto: "counter"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try decoder.decodeSingularBytesField(value: &secret) + case 2: try decoder.decodeSingularStringField(value: &name) + case 3: try decoder.decodeSingularStringField(value: &issuer) + case 4: try decoder.decodeSingularEnumField(value: &algorithm) + case 5: try decoder.decodeSingularEnumField(value: &digits) + case 6: try decoder.decodeSingularEnumField(value: &type) + case 7: try decoder.decodeSingularInt64Field(value: &counter) + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !secret.isEmpty { + try visitor.visitSingularBytesField(value: secret, fieldNumber: 1) + } + if !name.isEmpty { + try visitor.visitSingularStringField(value: name, fieldNumber: 2) + } + if !issuer.isEmpty { + try visitor.visitSingularStringField(value: issuer, fieldNumber: 3) + } + if algorithm != .unspecified { + try visitor.visitSingularEnumField(value: algorithm, fieldNumber: 4) + } + if digits != .unspecified { + try visitor.visitSingularEnumField(value: digits, fieldNumber: 5) + } + if type != .unspecified { + try visitor.visitSingularEnumField(value: type, fieldNumber: 6) + } + if counter != 0 { + try visitor.visitSingularInt64Field(value: counter, fieldNumber: 7) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func == (lhs: MigrationPayload.OtpParameters, rhs: MigrationPayload.OtpParameters) -> Bool { + if lhs.secret != rhs.secret { return false } + if lhs.name != rhs.name { return false } + if lhs.issuer != rhs.issuer { return false } + if lhs.algorithm != rhs.algorithm { return false } + if lhs.digits != rhs.digits { return false } + if lhs.type != rhs.type { return false } + if lhs.counter != rhs.counter { return false } + if lhs.unknownFields != rhs.unknownFields { return false } + return true + } +} diff --git a/Chronos/Services/ImportService.swift b/Chronos/Services/ImportService.swift deleted file mode 100644 index 2c3570a..0000000 --- a/Chronos/Services/ImportService.swift +++ /dev/null @@ -1,99 +0,0 @@ -import Factory -import Foundation -import Logging -import SwiftyJSON - -public class ImportService { - private let logger = Logger(label: "ImportService") - private let vaultService = Container.shared.vaultService() - - func importTokens(importSource: ImportSource, url: URL) -> [Token]? { - guard let json = readFile(url: url) else { - logger.error("Failed to read file at \(url)") - return nil - } - - switch importSource.id { - case "chronos": - return importFromChronos(json: json) - case "raivo": - return importFromRaivo(json: json) - default: - logger.error("Unsupported import source: \(importSource.id)") - return nil - } - } - - private func readFile(url: URL) -> JSON? { - guard url.startAccessingSecurityScopedResource() else { - logger.error("Failed to start accessing security scoped resource for \(url)") - return nil - } - - defer { url.stopAccessingSecurityScopedResource() } - - do { - let jsonData = try String(contentsOf: url, encoding: .utf8) - return JSON(parseJSON: jsonData) - } catch { - logger.error("Error reading file at \(url): \(error.localizedDescription)") - return nil - } - } -} - -extension ImportService { - func importFromChronos(json: JSON) -> [Token]? { - do { - let decoder = JSONDecoder() - let tokens = try decoder.decode([Token].self, from: json["tokens"].rawData()) - return tokens - } catch { - logger.error("Error decoding tokens from JSON: \(error.localizedDescription)") - return nil - } - } - - func importFromRaivo(json: JSON) -> [Token]? { - var tokens: [Token] = [] - - for (key, subJson) in json { - guard - let issuer = subJson["issuer"].string, - let account = subJson["account"].string, - let secret = subJson["secret"].string, - - let digitsString = subJson["digits"].string, - let digits = Int(digitsString), - - let periodString = subJson["timer"].string, - let period = Int(periodString), - - let counterString = subJson["counter"].string, - let counter = Int(counterString), - - let kind = subJson["kind"].string, - let algorithm = subJson["algorithm"].string, - let tokenType = TokenTypeEnum(rawValue: kind), - let tokenAlgorithm = TokenAlgorithmEnum(rawValue: algorithm) - else { - logger.error("Error parsing token data for key: \(key)") - continue - } - - let token = Token() - token.issuer = issuer - token.account = account - token.secret = secret - token.digits = digits - token.period = period - token.counter = counter - token.type = tokenType - token.algorithm = tokenAlgorithm - - tokens.append(token) - } - - return tokens - } -} diff --git a/ChronosTests/Import/GoogleAuthenticator.swift b/ChronosTests/Import/GoogleAuthenticator.swift new file mode 100644 index 0000000..febceee --- /dev/null +++ b/ChronosTests/Import/GoogleAuthenticator.swift @@ -0,0 +1,59 @@ +@testable import Chronos +import SwiftyJSON +import XCTest + +final class GoogleAuthenticatorTests: XCTestCase { + func testValidImport() throws { + let authOtpMigratation = "otpauth-migration://offline?data=Ci0KCkhlbGxvId6tvu8SEmpvaG5AYXBwbGVzZWVkLmNvbRoFQXBwbGUgASgBMAIKLgoKSGVsbG8h3q2%2B7xITam9objJAYXBwbGVzZWVkLmNvbRoFQXBwbGUgAigCMAIKNAoKSGVsbG8h3q2%2B7xIXam9obitob3RwQGFwcGxlc2VlZC5jb20aBUFwcGxlIAEoATABOAAKNQoKSGVsbG8h3q2%2B7xIYam9obitob3RwMkBhcHBsZXNlZWQuY29tGgVBcHBsZSABKAIwATgAEAIYASAA" + + let importService = ImportService() + let tokens = importService.importFromGoogleAuth(otpAuthMigration: authOtpMigratation)! + + XCTAssertEqual(tokens.count, 4) + + XCTAssertEqual(tokens[0].digits, 6) + XCTAssertEqual(tokens[0].type, TokenTypeEnum.TOTP) + XCTAssertEqual(tokens[0].counter, 0) + XCTAssertEqual(tokens[0].algorithm, TokenAlgorithmEnum.SHA1) + XCTAssertEqual(tokens[0].issuer, "Apple") + XCTAssertEqual(tokens[0].account, "john@appleseed.com") + XCTAssertEqual(tokens[0].period, 30) + XCTAssertEqual(tokens[0].secret, "JBSWY3DPEHPK3PXP") + + XCTAssertEqual(tokens[1].digits, 8) + XCTAssertEqual(tokens[1].type, TokenTypeEnum.TOTP) + XCTAssertEqual(tokens[1].counter, 0) + XCTAssertEqual(tokens[1].algorithm, TokenAlgorithmEnum.SHA256) + XCTAssertEqual(tokens[1].issuer, "Apple") + XCTAssertEqual(tokens[1].account, "john2@appleseed.com") + XCTAssertEqual(tokens[1].period, 30) + XCTAssertEqual(tokens[1].secret, "JBSWY3DPEHPK3PXP") + + XCTAssertEqual(tokens[2].digits, 6) + XCTAssertEqual(tokens[2].type, TokenTypeEnum.HOTP) + XCTAssertEqual(tokens[2].counter, 0) + XCTAssertEqual(tokens[2].algorithm, TokenAlgorithmEnum.SHA1) + XCTAssertEqual(tokens[2].issuer, "Apple") + XCTAssertEqual(tokens[2].account, "john+hotp@appleseed.com") + XCTAssertEqual(tokens[2].counter, 0) + XCTAssertEqual(tokens[2].secret, "JBSWY3DPEHPK3PXP") + + XCTAssertEqual(tokens[3].digits, 8) + XCTAssertEqual(tokens[3].type, TokenTypeEnum.HOTP) + XCTAssertEqual(tokens[3].counter, 0) + XCTAssertEqual(tokens[3].algorithm, TokenAlgorithmEnum.SHA1) + XCTAssertEqual(tokens[3].issuer, "Apple") + XCTAssertEqual(tokens[3].account, "john+hotp2@appleseed.com") + XCTAssertEqual(tokens[3].counter, 0) + XCTAssertEqual(tokens[3].secret, "JBSWY3DPEHPK3PXP") + } + + func testInvalidImport_AlgoMD5() throws { + let authOtpMigratation = "otpauth-migration://offline?data=Ci0KCkhlbGxvId6tvu8SEmpvaG5AYXBwbGVzZWVkLmNvbRoFQXBwbGUgASgBMAIKLgoKSGVsbG8h3q2%2B6RIRbWQ1QGFwcGxlc2VlZC5jb20aBUFwcGxlIAQoATABOAAQAhgBIAA%3D" + + let importService = ImportService() + let tokens = importService.importFromGoogleAuth(otpAuthMigration: authOtpMigratation) + + XCTAssertNil(tokens, "Should fail fast if any tokens contains md5 algo") + } +}