diff --git a/Chronos.xcodeproj/project.pbxproj b/Chronos.xcodeproj/project.pbxproj index afaae6c..b02c4df 100644 --- a/Chronos.xcodeproj/project.pbxproj +++ b/Chronos.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 6B27317A2B53E23800F30621 /* UpdateTokenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2731792B53E23800F30621 /* UpdateTokenView.swift */; }; 6B27317C2B53F0B200F30621 /* TokenRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B27317B2B53F0B200F30621 /* TokenRowView.swift */; }; 6B2F7A822C635B5A00DB1450 /* 2FAS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2F7A812C635B5A00DB1450 /* 2FAS.swift */; }; + 6B2F7A842C636CE500DB1450 /* Ente.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2F7A832C636CE500DB1450 /* Ente.swift */; }; 6B35A4C92B557EE50004D4C5 /* AlertKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6B35A4C82B557EE50004D4C5 /* AlertKit */; }; 6B39629A2BF5E935000410B0 /* MainAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B3962992BF5E935000410B0 /* MainAppView.swift */; }; 6B39629C2BF5EB3E000410B0 /* AuthenticationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B39629B2BF5EB3E000410B0 /* AuthenticationView.swift */; }; @@ -102,6 +103,7 @@ 6B2731792B53E23800F30621 /* UpdateTokenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateTokenView.swift; sourceTree = ""; }; 6B27317B2B53F0B200F30621 /* TokenRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenRowView.swift; sourceTree = ""; }; 6B2F7A812C635B5A00DB1450 /* 2FAS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = 2FAS.swift; sourceTree = ""; }; + 6B2F7A832C636CE500DB1450 /* Ente.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ente.swift; sourceTree = ""; }; 6B3962992BF5E935000410B0 /* MainAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainAppView.swift; sourceTree = ""; }; 6B39629B2BF5EB3E000410B0 /* AuthenticationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationView.swift; sourceTree = ""; }; 6B39629D2BF63F27000410B0 /* SwiftDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftDataService.swift; sourceTree = ""; }; @@ -328,6 +330,7 @@ 6BBF32022C562F20003CBA66 /* Aegis.swift */, 6B4987272C569A6E00A7D97A /* LastPass.swift */, 6B2F7A812C635B5A00DB1450 /* 2FAS.swift */, + 6B2F7A832C636CE500DB1450 /* Ente.swift */, ); path = Import; sourceTree = ""; @@ -694,6 +697,7 @@ 6B8132FD2C4E5F6B00DB367E /* QrCodeGenerationAndParsing.swift in Sources */, 6BBF32032C562F20003CBA66 /* Aegis.swift in Sources */, 6B4CBF2F2C490FB700983D44 /* Chronos.swift in Sources */, + 6B2F7A842C636CE500DB1450 /* Ente.swift in Sources */, 6BC5F0572C529A2A00BA106F /* GoogleAuthenticator.swift in Sources */, 6B4987282C569A6E00A7D97A /* LastPass.swift in Sources */, 6BC5F04A2C4FDE6E00BA106F /* ParseOtpAuthUrl.swift in Sources */, diff --git a/Chronos/App/Tabs/Settings/Import/ImportSourceDetailView.swift b/Chronos/App/Tabs/Settings/Import/ImportSourceDetailView.swift index 3df0e3b..9e01219 100644 --- a/Chronos/App/Tabs/Settings/Import/ImportSourceDetailView.swift +++ b/Chronos/App/Tabs/Settings/Import/ImportSourceDetailView.swift @@ -8,7 +8,13 @@ class ImportSourceDetailViewModel: ObservableObject { private let importService = Container.shared.importService() func importTokensFromFile(importSource: ImportSource, fileUrl: URL) { - tokens = importService.importTokensViaFile(importSource: importSource, url: fileUrl) + if importSource.importType == .JSON { + tokens = importService.importTokensViaJsonFile(importSource: importSource, url: fileUrl) + } + + if importSource.importType == .TEXT { + tokens = importService.importTokensViaTextFile(importSource: importSource, url: fileUrl) + } } func importTokensFromString(importSource: ImportSource, scannedStr: String) { @@ -54,6 +60,22 @@ struct ImportSourceDetailView: View { ) } + if importSource.importType == .TEXT { + Button(action: { showFileImporter = true }) { + Text("Select file") + .bold() + .frame(maxWidth: .infinity) + .frame(height: 32) + } + .buttonStyle(.bordered) + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.text], + allowsMultipleSelection: false, + onCompletion: handleFileImport + ) + } + if importSource.importType == .IMAGE { if !unableToAccessCamera { CodeScannerView( diff --git a/Chronos/App/Tabs/Settings/Import/ImportSourceListView.swift b/Chronos/App/Tabs/Settings/Import/ImportSourceListView.swift index 966b99e..8a91179 100644 --- a/Chronos/App/Tabs/Settings/Import/ImportSourceListView.swift +++ b/Chronos/App/Tabs/Settings/Import/ImportSourceListView.swift @@ -4,6 +4,7 @@ enum ImportSourceId { case CHRONOS case TWOFAS case AEGIS + case ENTE case RAIVO case GOOGLE_AUTHENTICATOR case LASTPASS @@ -11,6 +12,7 @@ enum ImportSourceId { enum ImportType { case JSON + case TEXT case IMAGE } @@ -26,6 +28,7 @@ struct ImportSourceListView: View { 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: .TWOFAS, name: "2FAS Authenticator", desc: "Export your tokens from 2FAS Authenticator using the \"Export\" option. Make sure \"Set a password for this backup file\" is unselected, then select the file below.", importType: .JSON), ImportSource(id: .AEGIS, name: "Aegis", desc: "Export your tokens from Aegis using \"Export\" option. Select \"JSON\" as the export format and unselect \"Encrypt the vault\", then select the file below.", importType: .JSON), + ImportSource(id: .ENTE, name: "Ente Authenticator", desc: "Export your tokens from Aegis using \"Export codes\" option. Select \"Plain text\" as the export format, then select the file below.", importType: .TEXT), 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), ImportSource(id: .LASTPASS, name: "LastPass Authenticator", desc: "Export your tokens from LastPass Authenticator using the \"Export accounts to file\" option, then select the file below.", importType: .JSON), diff --git a/Chronos/Services/Import/ImportService.swift b/Chronos/Services/Import/ImportService.swift index bfa1fb1..0131c24 100644 --- a/Chronos/Services/Import/ImportService.swift +++ b/Chronos/Services/Import/ImportService.swift @@ -6,22 +6,39 @@ import SwiftyJSON public class ImportService { private let logger = Logger(label: "ImportService") private let vaultService = Container.shared.vaultService() + private let otpService = Container.shared.otpService() - func importTokensViaFile(importSource: ImportSource, url: URL) -> [Token]? { - guard let json = readFile(url: url) else { + func importTokensViaJsonFile(importSource: ImportSource, url: URL) -> [Token]? { + guard let inputJson = readJsonFile(url: url) else { logger.error("Failed to read file at \(url)") return nil } switch importSource.id { case .CHRONOS: - return importFromChronos(json: json) + return importFromChronos(json: inputJson) + case .TWOFAS: + return importFrom2FAS(json: inputJson) case .AEGIS: - return importFromAegis(json: json) + return importFromAegis(json: inputJson) case .RAIVO: - return importFromRaivo(json: json) + return importFromRaivo(json: inputJson) case .LASTPASS: - return importFromLastpass(json: json) + return importFromLastpass(json: inputJson) + default: + return nil + } + } + + func importTokensViaTextFile(importSource: ImportSource, url: URL) -> [Token]? { + guard let inputText = readTextFile(url: url) else { + logger.error("Failed to read file at \(url)") + return nil + } + + switch importSource.id { + case .ENTE: + return importFromEnte(enteText: inputText) default: return nil } @@ -36,7 +53,7 @@ public class ImportService { } } - private func readFile(url: URL) -> JSON? { + private func readJsonFile(url: URL) -> JSON? { guard url.startAccessingSecurityScopedResource() else { logger.error("Failed to start accessing security scoped resource for \(url)") return nil @@ -52,6 +69,23 @@ public class ImportService { return nil } } + + private func readTextFile(url: URL) -> String? { + guard url.startAccessingSecurityScopedResource() else { + logger.error("Failed to start accessing security scoped resource for \(url)") + return nil + } + + defer { url.stopAccessingSecurityScopedResource() } + + do { + let strData = try String(contentsOf: url, encoding: .utf8) + return strData + } catch { + logger.error("Error reading file at \(url): \(error.localizedDescription)") + return nil + } + } } extension ImportService { @@ -106,6 +140,29 @@ extension ImportService { return tokens } + func importFromEnte(enteText: String) -> [Token]? { + var tokens: [Token] = [] + + let lines = enteText.split { $0.isNewline } + let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + let inputOtpAuthUrls = nonEmptyLines.map { String($0) } + + for otpAuthUrl in inputOtpAuthUrls { + do { + let token = try otpService.parseOtpAuthUrl(otpAuthStr: otpAuthUrl) + tokens.append(token) + } catch { + return nil + } + } + + if tokens.count != inputOtpAuthUrls.count { + return nil + } + + return tokens + } + func importFromRaivo(json: JSON) -> [Token]? { var tokens: [Token] = [] diff --git a/ChronosTests/Import/Ente.swift b/ChronosTests/Import/Ente.swift new file mode 100644 index 0000000..0dbd749 --- /dev/null +++ b/ChronosTests/Import/Ente.swift @@ -0,0 +1,73 @@ +@testable import Chronos +import XCTest + +final class EnteTests: XCTestCase { + func testValidImport() throws { + let inputData = """ + otpauth://totp/Apple:user1+totp@test.com?secret=AB6B7FAYHW2G42ZA4FJHLRWWHU&issuer=Apple&algorithm=SHA1&digits=6&period=30 + otpauth://totp/AWS:user2+totp@test.com?secret=U7WXBPTLK62EC6Y2X4ALCMWWHS&issuer=AWS&algorithm=SHA256&digits=7&period=60 + otpauth://totp/PG:user3+totp@test.com?secret=V27AJDJS4HZM3CTQNZXLVCHJYE&issuer=PG&algorithm=SHA512&digits=8&period=50 + otpauth://totp/:?secret=MGTMWSHCBRMOBRI2AXNJD4M332&issuer=&algorithm=SHA1&digits=6&period=30 + """ + + let importService = ImportService() + let tokens = importService.importFromEnte(enteText: inputData)! + + XCTAssertEqual(tokens.count, 4) + + XCTAssertEqual(tokens[0].digits, 6) + XCTAssertEqual(tokens[0].type, TokenTypeEnum.TOTP) + XCTAssertEqual(tokens[0].algorithm, TokenAlgorithmEnum.SHA1) + XCTAssertEqual(tokens[0].issuer, "Apple") + XCTAssertEqual(tokens[0].account, "user1+totp@test.com") + XCTAssertEqual(tokens[0].period, 30) + XCTAssertEqual(tokens[0].secret, "AB6B7FAYHW2G42ZA4FJHLRWWHU") + + XCTAssertEqual(tokens[1].digits, 7) + XCTAssertEqual(tokens[1].type, TokenTypeEnum.TOTP) + XCTAssertEqual(tokens[1].algorithm, TokenAlgorithmEnum.SHA256) + XCTAssertEqual(tokens[1].issuer, "AWS") + XCTAssertEqual(tokens[1].account, "user2+totp@test.com") + XCTAssertEqual(tokens[1].period, 60) + XCTAssertEqual(tokens[1].secret, "U7WXBPTLK62EC6Y2X4ALCMWWHS") + + XCTAssertEqual(tokens[2].digits, 8) + XCTAssertEqual(tokens[2].type, TokenTypeEnum.TOTP) + XCTAssertEqual(tokens[2].algorithm, TokenAlgorithmEnum.SHA512) + XCTAssertEqual(tokens[2].issuer, "PG") + XCTAssertEqual(tokens[2].account, "user3+totp@test.com") + XCTAssertEqual(tokens[2].period, 50) + XCTAssertEqual(tokens[2].secret, "V27AJDJS4HZM3CTQNZXLVCHJYE") + + XCTAssertEqual(tokens[3].digits, 6) + XCTAssertEqual(tokens[3].type, TokenTypeEnum.TOTP) + XCTAssertEqual(tokens[3].algorithm, TokenAlgorithmEnum.SHA1) + XCTAssertEqual(tokens[3].issuer, "") + XCTAssertEqual(tokens[3].account, "") + XCTAssertEqual(tokens[3].period, 30) + XCTAssertEqual(tokens[3].secret, "MGTMWSHCBRMOBRI2AXNJD4M332") + } + + func testValidImport_Whitepsaces() throws { + let inputData = """ + + + otpauth://totp/Apple:user1+totp@test.com?secret=AB6B7FAYHW2G42ZA4FJHLRWWHU&issuer=Apple&algorithm=SHA1&digits=6&period=30 + + otpauth://totp/AWS:user2+totp@test.com?secret=U7WXBPTLK62EC6Y2X4ALCMWWHS&issuer=AWS&algorithm=SHA256&digits=7&period=60 + + + otpauth://totp/PG:user3+totp@test.com?secret=V27AJDJS4HZM3CTQNZXLVCHJYE&issuer=PG&algorithm=SHA512&digits=8&period=50 + + otpauth://totp/:?secret=MGTMWSHCBRMOBRI2AXNJD4M332&issuer=&algorithm=SHA1&digits=6&period=30 + + + + """ + + let importService = ImportService() + let tokens = importService.importFromEnte(enteText: inputData)! + + XCTAssertEqual(tokens.count, 4) + } +}