diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc6431b --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +.DS_Store +MyPlayground.playground/ + +### Swift ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +build/ +DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +## Other +*.moved-aside +*.xccheckout +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +.build/ + +# CocoaPods - Refactored to standalone file + +# Carthage - Refactored to standalone file + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output + +### Xcode ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated + +## Various settings + +## Other + +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno diff --git a/OTP.xcodeproj/project.pbxproj b/OTP.xcodeproj/project.pbxproj index 4ca3674..ae88d3a 100644 --- a/OTP.xcodeproj/project.pbxproj +++ b/OTP.xcodeproj/project.pbxproj @@ -7,23 +7,119 @@ objects = { /* Begin PBXBuildFile section */ + D60E9D6E26D1998B009A4537 /* OTPGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E9D6D26D1998B009A4537 /* OTPGeneratorTests.swift */; }; + D60E9D7226D1A863009A4537 /* KeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E9D7126D1A863009A4537 /* KeyView.swift */; }; + D60E9D7426D1ABF9009A4537 /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E9D7326D1ABF9009A4537 /* CircularProgressView.swift */; }; + D60E9D7726D1B160009A4537 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = D60E9D7626D1B160009A4537 /* CodeScanner */; }; + D60E9D7A26D1E985009A4537 /* EditKeyForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E9D7926D1E985009A4537 /* EditKeyForm.swift */; }; D61479D726D0AF1C00710B79 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61479D626D0AF1C00710B79 /* AppDelegate.swift */; }; D61479D926D0AF1C00710B79 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61479D826D0AF1C00710B79 /* SceneDelegate.swift */; }; - D61479DB26D0AF1C00710B79 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61479DA26D0AF1C00710B79 /* ViewController.swift */; }; - D61479DE26D0AF1C00710B79 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D61479DC26D0AF1C00710B79 /* Main.storyboard */; }; D61479E026D0AF1E00710B79 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D61479DF26D0AF1E00710B79 /* Assets.xcassets */; }; D61479E326D0AF1E00710B79 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D61479E126D0AF1E00710B79 /* LaunchScreen.storyboard */; }; + D61479EB26D13DEF00710B79 /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61479EA26D13DEF00710B79 /* AppView.swift */; }; + D61479F526D141F700710B79 /* OTPKit.docc in Sources */ = {isa = PBXBuildFile; fileRef = D61479F426D141F700710B79 /* OTPKit.docc */; }; + D61479FB26D141F800710B79 /* OTPKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61479F126D141F700710B79 /* OTPKit.framework */; }; + D6147A0226D141F800710B79 /* OTPKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6147A0126D141F800710B79 /* OTPKitTests.swift */; }; + D6147A0326D141F800710B79 /* OTPKit.h in Headers */ = {isa = PBXBuildFile; fileRef = D61479F326D141F700710B79 /* OTPKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; + D6147A0626D141F800710B79 /* OTPKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61479F126D141F700710B79 /* OTPKit.framework */; }; + D6147A0726D141F800710B79 /* OTPKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D61479F126D141F700710B79 /* OTPKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D6147A1026D1421900710B79 /* TOTPKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6147A0F26D1421900710B79 /* TOTPKey.swift */; }; + D6147A1326D1446E00710B79 /* Base32.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6147A1226D1446E00710B79 /* Base32.swift */; }; + D6147A1726D147B400710B79 /* TOTPKeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6147A1626D147B400710B79 /* TOTPKeyTests.swift */; }; + D6147A1926D14AAB00710B79 /* OTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6147A1826D14AAB00710B79 /* OTPGenerator.swift */; }; + D6147A1B26D14ADF00710B79 /* OTPKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6147A1A26D14ADF00710B79 /* OTPKey.swift */; }; + D6147A1D26D14B5F00710B79 /* OTPAlgorithm.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6147A1C26D14B5F00710B79 /* OTPAlgorithm.swift */; }; + D6147A1F26D14BA000710B79 /* TOTPCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6147A1E26D14BA000710B79 /* TOTPCode.swift */; }; + D68B3DA026D2937000CB35A2 /* AddURLForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68B3D9F26D2937000CB35A2 /* AddURLForm.swift */; }; + D68B3DA226D293BE00CB35A2 /* DismissAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68B3DA126D293BE00CB35A2 /* DismissAction.swift */; }; + D6A2BCAE26D29601004DC4E3 /* EditedKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A2BCAD26D29601004DC4E3 /* EditedKey.swift */; }; + D6A2BCB026D313E7004DC4E3 /* AddQRView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A2BCAF26D313E7004DC4E3 /* AddQRView.swift */; }; + D6A2BCCC26D3E670004DC4E3 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A2BCCB26D3E670004DC4E3 /* PreferencesView.swift */; }; + D6A2BCD026D3E888004DC4E3 /* BackupDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A2BCCF26D3E888004DC4E3 /* BackupDocument.swift */; }; + D6A2BCEC26D3F7F9004DC4E3 /* QRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A2BCEB26D3F7F9004DC4E3 /* QRCodeView.swift */; }; + D6A2BD0826D449FA004DC4E3 /* KeyData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A2BD0726D449FA004DC4E3 /* KeyData.swift */; }; + D6A2BD0A26D44AC3004DC4E3 /* KeyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A2BD0926D44AC3004DC4E3 /* KeyStore.swift */; }; + D6A2BD0C26D5332B004DC4E3 /* FolderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A2BD0B26D5332B004DC4E3 /* FolderView.swift */; }; + D6A2BD1026D533EB004DC4E3 /* FoldersSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A2BD0F26D533EB004DC4E3 /* FoldersSection.swift */; }; + D6A2BD1226D56C70004DC4E3 /* KeysSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A2BD1126D56C70004DC4E3 /* KeysSection.swift */; }; + D6A2BD1426D5707F004DC4E3 /* FolderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A2BD1326D5707F004DC4E3 /* FolderRow.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + D61479FC26D141F800710B79 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D61479CB26D0AF1C00710B79 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D61479F026D141F700710B79; + remoteInfo = OTPKit; + }; + D61479FE26D141F800710B79 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D61479CB26D0AF1C00710B79 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D61479D226D0AF1C00710B79; + remoteInfo = OTP; + }; + D6147A0426D141F800710B79 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D61479CB26D0AF1C00710B79 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D61479F026D141F700710B79; + remoteInfo = OTPKit; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + D6147A0B26D141F800710B79 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + D6147A0726D141F800710B79 /* OTPKit.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ + D60E9D6D26D1998B009A4537 /* OTPGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPGeneratorTests.swift; sourceTree = ""; }; + D60E9D7126D1A863009A4537 /* KeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyView.swift; sourceTree = ""; }; + D60E9D7326D1ABF9009A4537 /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; + D60E9D7926D1E985009A4537 /* EditKeyForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditKeyForm.swift; sourceTree = ""; }; D61479D326D0AF1C00710B79 /* OTP.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OTP.app; sourceTree = BUILT_PRODUCTS_DIR; }; D61479D626D0AF1C00710B79 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; D61479D826D0AF1C00710B79 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - D61479DA26D0AF1C00710B79 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; - D61479DD26D0AF1C00710B79 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; D61479DF26D0AF1E00710B79 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D61479E226D0AF1E00710B79 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; D61479E426D0AF1E00710B79 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D61479EA26D13DEF00710B79 /* AppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = ""; }; + D61479F126D141F700710B79 /* OTPKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OTPKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D61479F326D141F700710B79 /* OTPKit.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OTPKit.h; sourceTree = ""; }; + D61479F426D141F700710B79 /* OTPKit.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = OTPKit.docc; sourceTree = ""; }; + D61479FA26D141F800710B79 /* OTPKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OTPKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + D6147A0126D141F800710B79 /* OTPKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPKitTests.swift; sourceTree = ""; }; + D6147A0F26D1421900710B79 /* TOTPKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPKey.swift; sourceTree = ""; }; + D6147A1226D1446E00710B79 /* Base32.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Base32.swift; sourceTree = ""; }; + D6147A1626D147B400710B79 /* TOTPKeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPKeyTests.swift; sourceTree = ""; }; + D6147A1826D14AAB00710B79 /* OTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPGenerator.swift; sourceTree = ""; }; + D6147A1A26D14ADF00710B79 /* OTPKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPKey.swift; sourceTree = ""; }; + D6147A1C26D14B5F00710B79 /* OTPAlgorithm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPAlgorithm.swift; sourceTree = ""; }; + D6147A1E26D14BA000710B79 /* TOTPCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPCode.swift; sourceTree = ""; }; + D68B3D9F26D2937000CB35A2 /* AddURLForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddURLForm.swift; sourceTree = ""; }; + D68B3DA126D293BE00CB35A2 /* DismissAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissAction.swift; sourceTree = ""; }; + D6A2BCAD26D29601004DC4E3 /* EditedKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditedKey.swift; sourceTree = ""; }; + D6A2BCAF26D313E7004DC4E3 /* AddQRView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddQRView.swift; sourceTree = ""; }; + D6A2BCCB26D3E670004DC4E3 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; + D6A2BCCF26D3E888004DC4E3 /* BackupDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupDocument.swift; sourceTree = ""; }; + D6A2BCEB26D3F7F9004DC4E3 /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = ""; }; + D6A2BD0726D449FA004DC4E3 /* KeyData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyData.swift; sourceTree = ""; }; + D6A2BD0926D44AC3004DC4E3 /* KeyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyStore.swift; sourceTree = ""; }; + D6A2BD0B26D5332B004DC4E3 /* FolderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderView.swift; sourceTree = ""; }; + D6A2BD0F26D533EB004DC4E3 /* FoldersSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoldersSection.swift; sourceTree = ""; }; + D6A2BD1126D56C70004DC4E3 /* KeysSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeysSection.swift; sourceTree = ""; }; + D6A2BD1326D5707F004DC4E3 /* FolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderRow.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -31,16 +127,54 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D60E9D7726D1B160009A4537 /* CodeScanner in Frameworks */, + D6147A0626D141F800710B79 /* OTPKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D61479EE26D141F700710B79 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D61479F726D141F800710B79 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D61479FB26D141F800710B79 /* OTPKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + D60E9D7826D1E976009A4537 /* Views */ = { + isa = PBXGroup; + children = ( + D61479EA26D13DEF00710B79 /* AppView.swift */, + D6A2BD1126D56C70004DC4E3 /* KeysSection.swift */, + D6A2BD0F26D533EB004DC4E3 /* FoldersSection.swift */, + D6A2BD1326D5707F004DC4E3 /* FolderRow.swift */, + D60E9D7126D1A863009A4537 /* KeyView.swift */, + D60E9D7326D1ABF9009A4537 /* CircularProgressView.swift */, + D6A2BD0B26D5332B004DC4E3 /* FolderView.swift */, + D6A2BCAF26D313E7004DC4E3 /* AddQRView.swift */, + D68B3D9F26D2937000CB35A2 /* AddURLForm.swift */, + D60E9D7926D1E985009A4537 /* EditKeyForm.swift */, + D6A2BCCB26D3E670004DC4E3 /* PreferencesView.swift */, + D6A2BCEB26D3F7F9004DC4E3 /* QRCodeView.swift */, + ); + path = Views; + sourceTree = ""; + }; D61479CA26D0AF1C00710B79 = { isa = PBXGroup; children = ( D61479D526D0AF1C00710B79 /* OTP */, + D61479F226D141F700710B79 /* OTPKit */, + D6147A0026D141F800710B79 /* OTPKitTests */, D61479D426D0AF1C00710B79 /* Products */, ); sourceTree = ""; @@ -49,6 +183,8 @@ isa = PBXGroup; children = ( D61479D326D0AF1C00710B79 /* OTP.app */, + D61479F126D141F700710B79 /* OTPKit.framework */, + D61479FA26D141F800710B79 /* OTPKitTests.xctest */, ); name = Products; sourceTree = ""; @@ -56,19 +192,67 @@ D61479D526D0AF1C00710B79 /* OTP */ = { isa = PBXGroup; children = ( + D61479E426D0AF1E00710B79 /* Info.plist */, D61479D626D0AF1C00710B79 /* AppDelegate.swift */, + D6A2BCCF26D3E888004DC4E3 /* BackupDocument.swift */, + D68B3DA126D293BE00CB35A2 /* DismissAction.swift */, + D6A2BCAD26D29601004DC4E3 /* EditedKey.swift */, + D6A2BD0726D449FA004DC4E3 /* KeyData.swift */, + D6A2BD0926D44AC3004DC4E3 /* KeyStore.swift */, D61479D826D0AF1C00710B79 /* SceneDelegate.swift */, - D61479DA26D0AF1C00710B79 /* ViewController.swift */, - D61479DC26D0AF1C00710B79 /* Main.storyboard */, D61479DF26D0AF1E00710B79 /* Assets.xcassets */, D61479E126D0AF1E00710B79 /* LaunchScreen.storyboard */, - D61479E426D0AF1E00710B79 /* Info.plist */, + D60E9D7826D1E976009A4537 /* Views */, ); path = OTP; sourceTree = ""; }; + D61479F226D141F700710B79 /* OTPKit */ = { + isa = PBXGroup; + children = ( + D61479F426D141F700710B79 /* OTPKit.docc */, + D61479F326D141F700710B79 /* OTPKit.h */, + D6147A1C26D14B5F00710B79 /* OTPAlgorithm.swift */, + D6147A1826D14AAB00710B79 /* OTPGenerator.swift */, + D6147A1A26D14ADF00710B79 /* OTPKey.swift */, + D6147A1E26D14BA000710B79 /* TOTPCode.swift */, + D6147A0F26D1421900710B79 /* TOTPKey.swift */, + D6147A1126D1446500710B79 /* Vendor */, + ); + path = OTPKit; + sourceTree = ""; + }; + D6147A0026D141F800710B79 /* OTPKitTests */ = { + isa = PBXGroup; + children = ( + D6147A0126D141F800710B79 /* OTPKitTests.swift */, + D60E9D6D26D1998B009A4537 /* OTPGeneratorTests.swift */, + D6147A1626D147B400710B79 /* TOTPKeyTests.swift */, + ); + path = OTPKitTests; + sourceTree = ""; + }; + D6147A1126D1446500710B79 /* Vendor */ = { + isa = PBXGroup; + children = ( + D6147A1226D1446E00710B79 /* Base32.swift */, + ); + path = Vendor; + sourceTree = ""; + }; /* End PBXGroup section */ +/* Begin PBXHeadersBuildPhase section */ + D61479EC26D141F700710B79 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + D6147A0326D141F800710B79 /* OTPKit.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + /* Begin PBXNativeTarget section */ D61479D226D0AF1C00710B79 /* OTP */ = { isa = PBXNativeTarget; @@ -77,15 +261,57 @@ D61479CF26D0AF1C00710B79 /* Sources */, D61479D026D0AF1C00710B79 /* Frameworks */, D61479D126D0AF1C00710B79 /* Resources */, + D6147A0B26D141F800710B79 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + D6147A0526D141F800710B79 /* PBXTargetDependency */, + ); + name = OTP; + packageProductDependencies = ( + D60E9D7626D1B160009A4537 /* CodeScanner */, + ); + productName = OTP; + productReference = D61479D326D0AF1C00710B79 /* OTP.app */; + productType = "com.apple.product-type.application"; + }; + D61479F026D141F700710B79 /* OTPKit */ = { + isa = PBXNativeTarget; + buildConfigurationList = D6147A0826D141F800710B79 /* Build configuration list for PBXNativeTarget "OTPKit" */; + buildPhases = ( + D61479EC26D141F700710B79 /* Headers */, + D61479ED26D141F700710B79 /* Sources */, + D61479EE26D141F700710B79 /* Frameworks */, + D61479EF26D141F700710B79 /* Resources */, ); buildRules = ( ); dependencies = ( ); - name = OTP; - productName = OTP; - productReference = D61479D326D0AF1C00710B79 /* OTP.app */; - productType = "com.apple.product-type.application"; + name = OTPKit; + productName = OTPKit; + productReference = D61479F126D141F700710B79 /* OTPKit.framework */; + productType = "com.apple.product-type.framework"; + }; + D61479F926D141F800710B79 /* OTPKitTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D6147A0C26D141F800710B79 /* Build configuration list for PBXNativeTarget "OTPKitTests" */; + buildPhases = ( + D61479F626D141F800710B79 /* Sources */, + D61479F726D141F800710B79 /* Frameworks */, + D61479F826D141F800710B79 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D61479FD26D141F800710B79 /* PBXTargetDependency */, + D61479FF26D141F800710B79 /* PBXTargetDependency */, + ); + name = OTPKitTests; + productName = OTPKitTests; + productReference = D61479FA26D141F800710B79 /* OTPKitTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ @@ -100,6 +326,13 @@ D61479D226D0AF1C00710B79 = { CreatedOnToolsVersion = 13.0; }; + D61479F026D141F700710B79 = { + CreatedOnToolsVersion = 13.0; + }; + D61479F926D141F800710B79 = { + CreatedOnToolsVersion = 13.0; + TestTargetID = D61479D226D0AF1C00710B79; + }; }; }; buildConfigurationList = D61479CE26D0AF1C00710B79 /* Build configuration list for PBXProject "OTP" */; @@ -111,11 +344,16 @@ Base, ); mainGroup = D61479CA26D0AF1C00710B79; + packageReferences = ( + D60E9D7526D1B160009A4537 /* XCRemoteSwiftPackageReference "CodeScanner" */, + ); productRefGroup = D61479D426D0AF1C00710B79 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( D61479D226D0AF1C00710B79 /* OTP */, + D61479F026D141F700710B79 /* OTPKit */, + D61479F926D141F800710B79 /* OTPKitTests */, ); }; /* End PBXProject section */ @@ -127,7 +365,20 @@ files = ( D61479E326D0AF1E00710B79 /* LaunchScreen.storyboard in Resources */, D61479E026D0AF1E00710B79 /* Assets.xcassets in Resources */, - D61479DE26D0AF1C00710B79 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D61479EF26D141F700710B79 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D61479F826D141F800710B79 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( ); runOnlyForDeploymentPostprocessing = 0; }; @@ -138,23 +389,73 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D61479DB26D0AF1C00710B79 /* ViewController.swift in Sources */, D61479D726D0AF1C00710B79 /* AppDelegate.swift in Sources */, + D6A2BCCC26D3E670004DC4E3 /* PreferencesView.swift in Sources */, + D6A2BD0C26D5332B004DC4E3 /* FolderView.swift in Sources */, D61479D926D0AF1C00710B79 /* SceneDelegate.swift in Sources */, + D6A2BD1026D533EB004DC4E3 /* FoldersSection.swift in Sources */, + D6A2BCAE26D29601004DC4E3 /* EditedKey.swift in Sources */, + D6A2BD1426D5707F004DC4E3 /* FolderRow.swift in Sources */, + D6A2BCD026D3E888004DC4E3 /* BackupDocument.swift in Sources */, + D6A2BD1226D56C70004DC4E3 /* KeysSection.swift in Sources */, + D60E9D7226D1A863009A4537 /* KeyView.swift in Sources */, + D68B3DA026D2937000CB35A2 /* AddURLForm.swift in Sources */, + D61479EB26D13DEF00710B79 /* AppView.swift in Sources */, + D68B3DA226D293BE00CB35A2 /* DismissAction.swift in Sources */, + D60E9D7426D1ABF9009A4537 /* CircularProgressView.swift in Sources */, + D6A2BCEC26D3F7F9004DC4E3 /* QRCodeView.swift in Sources */, + D60E9D7A26D1E985009A4537 /* EditKeyForm.swift in Sources */, + D6A2BD0A26D44AC3004DC4E3 /* KeyStore.swift in Sources */, + D6A2BCB026D313E7004DC4E3 /* AddQRView.swift in Sources */, + D6A2BD0826D449FA004DC4E3 /* KeyData.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D61479ED26D141F700710B79 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D61479F526D141F700710B79 /* OTPKit.docc in Sources */, + D6147A1F26D14BA000710B79 /* TOTPCode.swift in Sources */, + D6147A1326D1446E00710B79 /* Base32.swift in Sources */, + D6147A1026D1421900710B79 /* TOTPKey.swift in Sources */, + D6147A1D26D14B5F00710B79 /* OTPAlgorithm.swift in Sources */, + D6147A1926D14AAB00710B79 /* OTPGenerator.swift in Sources */, + D6147A1B26D14ADF00710B79 /* OTPKey.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D61479F626D141F800710B79 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D6147A1726D147B400710B79 /* TOTPKeyTests.swift in Sources */, + D6147A0226D141F800710B79 /* OTPKitTests.swift in Sources */, + D60E9D6E26D1998B009A4537 /* OTPGeneratorTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXVariantGroup section */ - D61479DC26D0AF1C00710B79 /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - D61479DD26D0AF1C00710B79 /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; +/* Begin PBXTargetDependency section */ + D61479FD26D141F800710B79 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D61479F026D141F700710B79 /* OTPKit */; + targetProxy = D61479FC26D141F800710B79 /* PBXContainerItemProxy */; }; + D61479FF26D141F800710B79 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D61479D226D0AF1C00710B79 /* OTP */; + targetProxy = D61479FE26D141F800710B79 /* PBXContainerItemProxy */; + }; + D6147A0526D141F800710B79 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D61479F026D141F700710B79 /* OTPKit */; + targetProxy = D6147A0426D141F800710B79 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ D61479E126D0AF1E00710B79 /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -285,6 +586,7 @@ D61479E826D0AF1E00710B79 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; @@ -294,9 +596,9 @@ INFOPLIST_FILE = OTP/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -313,6 +615,7 @@ D61479E926D0AF1E00710B79 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; @@ -322,9 +625,9 @@ INFOPLIST_FILE = OTP/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -338,6 +641,112 @@ }; name = Release; }; + D6147A0926D141F800710B79 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = V4WK9KR9U2; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.OTPKit; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + D6147A0A26D141F800710B79 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = V4WK9KR9U2; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.OTPKit; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + D6147A0D26D141F800710B79 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = V4WK9KR9U2; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.OTPKitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/OTP.app/OTP"; + }; + name = Debug; + }; + D6147A0E26D141F800710B79 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = V4WK9KR9U2; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.OTPKitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/OTP.app/OTP"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -359,7 +768,44 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D6147A0826D141F800710B79 /* Build configuration list for PBXNativeTarget "OTPKit" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D6147A0926D141F800710B79 /* Debug */, + D6147A0A26D141F800710B79 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D6147A0C26D141F800710B79 /* Build configuration list for PBXNativeTarget "OTPKitTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D6147A0D26D141F800710B79 /* Debug */, + D6147A0E26D141F800710B79 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + D60E9D7526D1B160009A4537 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/twostraws/CodeScanner"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + D60E9D7626D1B160009A4537 /* CodeScanner */ = { + isa = XCSwiftPackageProductDependency; + package = D60E9D7526D1B160009A4537 /* XCRemoteSwiftPackageReference "CodeScanner" */; + productName = CodeScanner; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = D61479CB26D0AF1C00710B79 /* Project object */; } diff --git a/OTP.xcodeproj/xcuserdata/shadowfacts.xcuserdatad/xcschemes/xcschememanagement.plist b/OTP.xcodeproj/xcuserdata/shadowfacts.xcuserdatad/xcschemes/xcschememanagement.plist index 7d5aadc..bdba5c9 100644 --- a/OTP.xcodeproj/xcuserdata/shadowfacts.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/OTP.xcodeproj/xcuserdata/shadowfacts.xcuserdatad/xcschemes/xcschememanagement.plist @@ -5,6 +5,11 @@ SchemeUserState OTP.xcscheme_^#shared#^_ + + orderHint + 1 + + OTPKit.xcscheme_^#shared#^_ orderHint 0 diff --git a/OTP/AppDelegate.swift b/OTP/AppDelegate.swift index afa2eb4..be6b01b 100644 --- a/OTP/AppDelegate.swift +++ b/OTP/AppDelegate.swift @@ -11,9 +11,7 @@ import UIKit class AppDelegate: UIResponder, UIApplicationDelegate { - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. return true } diff --git a/OTP/BackupDocument.swift b/OTP/BackupDocument.swift new file mode 100644 index 0000000..c542e48 --- /dev/null +++ b/OTP/BackupDocument.swift @@ -0,0 +1,45 @@ +// +// BackupDocument.swift +// OTP +// +// Created by Shadowfacts on 8/23/21. +// + +import SwiftUI +import UniformTypeIdentifiers +import OTPKit + +struct BackupDocument: FileDocument { + + private static let encoder = PropertyListEncoder() + private static let decoder = PropertyListDecoder() + + static var readableContentTypes: [UTType] { [.propertyList] } + + private(set) var data: KeyData + + init(data: KeyData) { + self.data = data + } + + init(configuration: ReadConfiguration) throws { + if let data = configuration.file.regularFileContents { + let store = try BackupDocument.decoder.decode(KeyData.self, from: data) + self.data = store + } else { + self.data = KeyData() + } + } + + init(url: URL) throws { + let data = try Data(contentsOf: url) + let store = try BackupDocument.decoder.decode(KeyData.self, from: data) + self.data = store + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + let data = try! BackupDocument.encoder.encode(data) + return FileWrapper(regularFileWithContents: data) + } + +} diff --git a/OTP/Base.lproj/Main.storyboard b/OTP/Base.lproj/Main.storyboard deleted file mode 100644 index 25a7638..0000000 --- a/OTP/Base.lproj/Main.storyboard +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/OTP/DismissAction.swift b/OTP/DismissAction.swift new file mode 100644 index 0000000..a09f7d8 --- /dev/null +++ b/OTP/DismissAction.swift @@ -0,0 +1,14 @@ +// +// DismissAction.swift +// OTP +// +// Created by Shadowfacts on 8/22/21. +// + +import Foundation +import OTPKit + +enum DismissAction { + case cancel + case save(TOTPKey) +} diff --git a/OTP/EditedKey.swift b/OTP/EditedKey.swift new file mode 100644 index 0000000..281e9e1 --- /dev/null +++ b/OTP/EditedKey.swift @@ -0,0 +1,74 @@ +// +// EditedKey.swift +// OTP +// +// Created by Shadowfacts on 8/22/21. +// + +import Foundation +import OTPKit + +struct EditedKey { + var secret: String + var period: Period + var digits: Digits + var issuer: String + var label: String + + init() { + self.secret = "" + self.period = .thirty + self.digits = .six + self.issuer = "" + self.label = "" + } + + init?(totpKey: TOTPKey) { + guard totpKey.period == 30 || totpKey.period == 60, + totpKey.digits == 6 || totpKey.digits == 8 else { + return nil + } + + self.secret = totpKey.secret.base32EncodedString + self.period = totpKey.period == 30 ? .thirty : .sixty + self.digits = totpKey.digits == 6 ? .six : .eight + self.issuer = totpKey.issuer + self.label = totpKey.label ?? "" + } + + func toTOTPKey() -> TOTPKey? { + let secretStr = self.secret.replacingOccurrences(of: " ", with: "") + guard secretStr.count > 0, + let secret = secretStr.base32DecodedData, + !issuer.isEmpty else { + return nil + } + return TOTPKey(secret: secret, period: period.value, digits: digits.value, label: label.trimmingCharacters(in: .whitespacesAndNewlines), issuer: issuer) + } + + enum Period: Hashable { + case thirty, sixty + + var value: Int { + switch self { + case .thirty: + return 30 + case .sixty: + return 60 + } + } + } + + enum Digits: Hashable { + case six, eight + + var value: Int { + switch self { + case .six: + return 6 + case .eight: + return 8 + } + } + } +} diff --git a/OTP/Info.plist b/OTP/Info.plist index dd3c9af..e73c8fb 100644 --- a/OTP/Info.plist +++ b/OTP/Info.plist @@ -2,6 +2,10 @@ + NSPhotoLibraryAddUsageDescription + Save exported QR codes + NSCameraUsageDescription + Scan OTP keys from QR codes UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -15,8 +19,6 @@ Default Configuration UISceneDelegateClassName $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main diff --git a/OTP/KeyData.swift b/OTP/KeyData.swift new file mode 100644 index 0000000..2a7d26f --- /dev/null +++ b/OTP/KeyData.swift @@ -0,0 +1,121 @@ +// +// KeyData.swift +// OTP +// +// Created by Shadowfacts on 8/23/21. +// + +import Foundation +import OTPKit + +struct KeyData: Codable { + + private(set) var entries: [Entry] = [] + private(set) var folders: [Folder] = [] + + init(entries: [Entry] = []) { + self.entries = entries + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.entries = try container.decode([Entry].self, forKey: .entries) + self.folders = try container.decode([Folder].self, forKey: .folders) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(entries, forKey: .entries) + try container.encode(folders, forKey: .folders) + } + + mutating func addKey(_ key: TOTPKey) { + entries.append(Entry(key: key)) + } + + mutating func addOrUpdateEntries(_ entries: [Entry]) { + for e in entries { + if let index = self.entries.firstIndex(where: { $0.id == e.id }) { + self.entries[index] = e + } else { + self.entries.append(e) + } + } + } + + mutating func updateKey(entryID id: UUID, newKey: TOTPKey) { + guard let index = entries.firstIndex(where: { $0.id == id }) else { + return + } + entries[index].key = newKey + } + + mutating func removeKey(entryID id: UUID) { + entries.removeAll(where: { $0.id == id }) + } + + mutating func clearEntries() { + entries.removeAll() + } + + mutating func addFolder() { + let count = folders.filter { $0.name.starts(with: "New Folder") }.count + if count > 0 { + folders.append(Folder(name: "New Folder \(count + 1)")) + } else { + folders.append(Folder(name: "New Folder")) + } + } + + mutating func updateFolder(_ newFolder: KeyData.Folder) { + guard let index = folders.firstIndex(where: { $0.id == newFolder.id }) else { + return + } + folders[index] = newFolder + } + + mutating func removeFolder(id: UUID) { + folders.removeAll(where: { $0.id == id }) + for (index, e) in entries.enumerated() where e.folderID == id { + entries[index].folderID = nil + } + } + + mutating func moveEntryToFolder(entryID: UUID, folderID: UUID?) { + guard let index = entries.firstIndex(where: { $0.id == entryID }), + folderID == nil || folders.contains(where: { $0.id == folderID }) else { + return + } + entries[index].folderID = folderID + } + + struct Entry: Identifiable, Hashable, Codable { + let id: UUID + var key: TOTPKey + var folderID: UUID? + let image: Data? + + init(id: UUID = UUID(), key: TOTPKey, folderID: UUID? = nil, image: Data? = nil) { + self.id = id + self.key = key + self.folderID = folderID + self.image = image + } + } + + struct Folder: Identifiable, Codable { + let id: UUID + var name: String + + init(id: UUID = UUID(), name: String) { + self.id = id + self.name = name + } + } + + private enum CodingKeys: CodingKey { + case entries + case folders + } + +} diff --git a/OTP/KeyStore.swift b/OTP/KeyStore.swift new file mode 100644 index 0000000..e10c553 --- /dev/null +++ b/OTP/KeyStore.swift @@ -0,0 +1,89 @@ +// +// KeyStore.swift +// OTP +// +// Created by Shadowfacts on 8/23/21. +// + +import Foundation +import OTPKit +import Security +import Combine + +class KeyStore: ObservableObject { + static let shared = try! KeyStore() + + private lazy var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + private lazy var storeArchiveURL = documentsDirectory.appendingPathComponent("KeyStore").appendingPathExtension("plist") + + private let encoder = PropertyListEncoder() + private let decoder = PropertyListDecoder() + + @Published private(set) var data: KeyData! { + didSet { + let data = try! encoder.encode(data) + try! data.write(to: storeArchiveURL, options: .completeFileProtectionUntilFirstUserAuthentication) + } + } + + var entries: [KeyData.Entry] { + data.entries + } + + var folders: [KeyData.Folder] { + data.folders + } + + var sortedFolders: [KeyData.Folder] { + data.folders.sorted(by: { (a, b) in a.name < b.name }) + } + + private init() throws { + if let data = try? Data(contentsOf: storeArchiveURL) { + self.data = try decoder.decode(KeyData.self, from: data) + } else { + self.data = KeyData() + } + } + + func updateFromStore(_ newStore: KeyData, replaceExisting: Bool) { + if replaceExisting { + data = newStore + } else { + data.addOrUpdateEntries(newStore.entries) + } + } + + func addKey(_ key: TOTPKey) { + data.addKey(key) + } + + func updateKey(entryID id: UUID, newKey: TOTPKey) { + data.updateKey(entryID: id, newKey: newKey) + } + + func removeKey(entryID id: UUID) { + data.removeKey(entryID: id) + } + + func clearEntries() { + data.clearEntries() + } + + func addFolder() { + data.addFolder() + } + + func updateFolder(_ newFolder: KeyData.Folder) { + data.updateFolder(newFolder) + } + + func removeFolder(id: UUID) { + data.removeFolder(id: id) + } + + func moveEntryToFolder(entryID: UUID, folderID: UUID?) { + data.moveEntryToFolder(entryID: entryID, folderID: folderID) + } + +} diff --git a/OTP/SceneDelegate.swift b/OTP/SceneDelegate.swift index bf1a125..c9f14c8 100644 --- a/OTP/SceneDelegate.swift +++ b/OTP/SceneDelegate.swift @@ -6,17 +6,23 @@ // import UIKit +import SwiftUI class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let _ = (scene as? UIWindowScene) else { return } + guard let windowScene = scene as? UIWindowScene else { return } + + window = UIWindow(windowScene: windowScene) + + window!.rootViewController = UIHostingController(rootView: AppView()) + + window!.makeKeyAndVisible() } func sceneDidDisconnect(_ scene: UIScene) { diff --git a/OTP/ViewController.swift b/OTP/ViewController.swift deleted file mode 100644 index 55a1443..0000000 --- a/OTP/ViewController.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// ViewController.swift -// OTP -// -// Created by Shadowfacts on 8/20/21. -// - -import UIKit - -class ViewController: UIViewController { - - override func viewDidLoad() { - super.viewDidLoad() - // Do any additional setup after loading the view. - } - - -} - diff --git a/OTP/Views/AddQRView.swift b/OTP/Views/AddQRView.swift new file mode 100644 index 0000000..50e0b36 --- /dev/null +++ b/OTP/Views/AddQRView.swift @@ -0,0 +1,95 @@ +// +// AddQRView.swift +// OTP +// +// Created by Shadowfacts on 8/22/21. +// + +import SwiftUI +import CodeScanner +import OTPKit + +struct AddQRView: View { + let dismiss: (DismissAction) -> Void + @State private var isPresentingScanFailedAlert = false + @State private var scanError: ScanError? + @State private var scannedKey: TOTPKey? + @State private var isShowingConfirmView = false + + var body: some View { + NavigationView { + CodeScannerView(codeTypes: [.qr], scanMode: .once) { (result) in + switch result { + case .success(let code): + if let components = URLComponents(string: code), + let key = TOTPKey(urlComponents: components) { + self.scannedKey = key + isShowingConfirmView = true + } else { + isPresentingScanFailedAlert = true + scanError = .invalidCode(code) + } + case .failure(let error): + isPresentingScanFailedAlert = true + scanError = .scanner(error) + } + } + .edgesIgnoringSafeArea(.bottom) + .navigationBarTitle("Scan QR Code", displayMode: .inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss(.cancel) + } + } + } + .overlay { + NavigationLink(isActive: $isShowingConfirmView) { + EditKeyForm(editingKey: scannedKey, showCancelButton: false, dismiss: dismiss) + .navigationTitle("Add Key") + } label: { + // EmptyView because this is only used to trigger programatic navigation + EmptyView() + } + + } + } + .alert("Unable to get OTP key from QR code", isPresented: $isPresentingScanFailedAlert) { + Button("Cancel", role: .cancel) { + dismiss(.cancel) + } + Button("Try Again") { + isPresentingScanFailedAlert = false + } + } message: { + if let error = scanError { + Text(error.localizedDescription) + } + } + } + +} + +extension AddQRView { + enum ScanError: LocalizedError { + case scanner(CodeScannerView.ScanError) + case invalidCode(String) + + var errorDescription: String? { + switch self { + case .invalidCode(let code): + return "Invalid Code: '\(code)'" + case .scanner(.badInput): + return "Scanner: Bad Input" + case .scanner(.badOutput): + return "Scanner: Bad Output" + } + } + } +} + +struct AddQRView_Previews: PreviewProvider { + static var previews: some View { + AddQRView() { (_) in } + } +} diff --git a/OTP/Views/AddURLForm.swift b/OTP/Views/AddURLForm.swift new file mode 100644 index 0000000..b8a836d --- /dev/null +++ b/OTP/Views/AddURLForm.swift @@ -0,0 +1,90 @@ +// +// AddURLForm.swift +// OTP +// +// Created by Shadowfacts on 8/22/21. +// + +import SwiftUI +import OTPKit + +struct AddURLForm: View { + let dismiss: (DismissAction) -> Void + @State private var inputURL = "" + @State private var extractedKey = EditedKey() + @FocusState private var urlFocused: Bool + + private var isValid: Bool { + extractedKey.toTOTPKey() != nil + } + + var body: some View { + Form { + HStack { + TextField("URL", text: $inputURL) + .focused($urlFocused) + + Button { + self.inputURL = UIPasteboard.general.string ?? "" + } label: { + Label("Paste", systemImage: "doc.on.clipboard") + .labelStyle(.iconOnly) + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + .disabled(!UIPasteboard.general.hasStrings) + } + + Section { + TextField("Issuer", text: $extractedKey.issuer) + TextField("Label", text: $extractedKey.label) + TextField("Secret", text: $extractedKey.secret) + } header: { + Text("Extracted Key") + } + .disabled(true) + } + .onSubmit { + if isValid { + dismiss(.save(extractedKey.toTOTPKey()!)) + } + } + .onAppear { + // doesn't work, see FB9551099 + urlFocused = true + } + .navigationTitle("Add from URL") + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss(.cancel) + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + dismiss(.save(extractedKey.toTOTPKey()!)) + } + .disabled(!isValid) + } + } + .onChange(of: inputURL, perform: self.updateExtractedKey(inputURL:)) + } + + private func updateExtractedKey(inputURL: String) { + let text = inputURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty, + let components = URLComponents(string: inputURL), + let totpKey = TOTPKey(urlComponents: components), + let edited = EditedKey(totpKey: totpKey) { + extractedKey = edited + } else { + extractedKey = EditedKey() + } + } +} + +struct AddURLForm_Previews: PreviewProvider { + static var previews: some View { + AddURLForm() { (_) in } + } +} diff --git a/OTP/Views/AppView.swift b/OTP/Views/AppView.swift new file mode 100644 index 0000000..a5dfc15 --- /dev/null +++ b/OTP/Views/AppView.swift @@ -0,0 +1,210 @@ +// +// AppView.swift +// OTP +// +// Created by Shadowfacts on 8/21/21. +// + +import SwiftUI +import OTPKit +import Combine + +struct AppView: View { + @ObservedObject private var store: KeyStore + @ObservedObject private var entryHolder: CodeHolder + @State private var isPresentingScanner = false + @State private var isPresentingScanFailedAlert = false + @State private var isPresentingAddURLSheet = false + @State private var isPresentingManualAddFormSheet = false + @State private var isPresentingPreferences = false + + init() { + self.store = .shared + self.entryHolder = CodeHolder(store: .shared) { (entry) in entry.folderID == nil } + } + + var body: some View { + NavigationView { + List { + KeysSection(codeHolder: entryHolder) + + FoldersSection() + } + .listStyle(.insetGrouped) + .navigationTitle("OTP") + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + isPresentingPreferences = true + } label: { + Label("Preferences", systemImage: "gear") + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Section { + Button { + isPresentingScanner = true + } label: { + Label("Scan QR", systemImage: "qrcode.viewfinder") + } + Button { + isPresentingAddURLSheet = true + } label: { + Label("From URL", systemImage: "link") + } + Button { + isPresentingManualAddFormSheet = true + } label: { + Label("Enter Manually", systemImage: "textbox") + } + } + + Section { + Button { + store.addFolder() + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + } + } label: { + Label("Add Key", systemImage: "plus.circle") + } + } + } + } + .tint(.blue) + .sheet(isPresented: $isPresentingPreferences, content: self.preferencesSheet) + .sheet(isPresented: $isPresentingScanner, content: self.scannerSheet) + .sheet(isPresented: $isPresentingManualAddFormSheet, content: self.manualAddFormSheet) + .sheet(isPresented: $isPresentingAddURLSheet, content: self.addURLSheet) + } + + private func preferencesSheet() -> some View { + NavigationView { + PreferencesView() + } + } + + private func scannerSheet() -> some View { + AddQRView() { (action) in + self.isPresentingScanner = false + switch action { + case .cancel: + break + case .save(let key): + store.addKey(key) + } + } + } + + private func addURLSheet() -> some View { + NavigationView { + AddURLForm { (action) in + self.isPresentingAddURLSheet = false + switch action { + case .cancel: + break + case .save(let key): + store.addKey(key) + } + } + } + } + + private func manualAddFormSheet() -> some View { + NavigationView { + EditKeyForm(editingKey: nil, focusOnAppear: true) { (action) in + self.isPresentingManualAddFormSheet = false + switch action { + case .cancel: + break + case .save(let key): + store.addKey(key) + } + } + .navigationTitle("Add Key") + } + } + + struct CodeEntry: Identifiable, Equatable, Hashable { + let entry: KeyData.Entry + let code: TOTPCode + var key: TOTPKey { entry.key } + var id: UUID { entry.id } + + init(_ entry: KeyData.Entry) { + self.entry = entry + self.code = OTPGenerator.generate(key: entry.key) + } + } + + class CodeHolder: ObservableObject { + private let store: KeyStore + private let entryFilter: ((KeyData.Entry) -> Bool)? + private var timer: Timer! + private var cancellables = Set() + + init(store: KeyStore, entryFilter: ((KeyData.Entry) -> Bool)? = nil) { + self.store = store + self.entryFilter = entryFilter + + updateTimer(entries: filterEntries(from: store.data)) + + store.$data + .sink { [unowned self] (newData) in + self.objectWillChange.send() + self.updateTimer(entries: filterEntries(from: newData!)) + } + .store(in: &cancellables) + } + + var entries: [CodeEntry] { + return filterEntries(from: store.data).map { CodeEntry($0) } + } + + var sortedEntries: [CodeEntry] { + return entries.sorted(by: { (a, b) in + if a.key.issuer == b.key.issuer, + let aLabel = a.key.label, + let bLabel = b.key.label { + return aLabel < bLabel + } else { + return a.key.issuer < b.key.issuer + } + }) + } + + private func filterEntries(from data: KeyData) -> [KeyData.Entry] { + if let filter = entryFilter { + return data.entries.filter(filter) + } else { + return data.entries + } + } + + private func updateTimer(entries: [KeyData.Entry]) { + if entries.isEmpty { + timer?.invalidate() + return + } else if timer == nil || !timer.isValid { + timer = .scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] (timer) in + guard let self = self else { + timer.invalidate() + return + } + self.objectWillChange.send() + } + timer.tolerance = 0.01 + } + } + } + +} + +struct AppView_Previews: PreviewProvider { + static var previews: some View { + AppView() + } +} diff --git a/OTP/Views/CircularProgressView.swift b/OTP/Views/CircularProgressView.swift new file mode 100644 index 0000000..ba29824 --- /dev/null +++ b/OTP/Views/CircularProgressView.swift @@ -0,0 +1,39 @@ +// +// CircularProgressView.swift +// OTP +// +// Created by Shadowfacts on 8/21/21. +// + +import SwiftUI + +struct CircularProgressView: View { + let progress: Double + let colorChangeThreshold: Double + + var body: some View { + ZStack { + Circle() + .stroke(lineWidth: 4) + .foregroundColor(progress <= colorChangeThreshold ? .red : .accentColor) + .opacity(0.4) + .animation(.default, value: progress) + + Circle() + .trim(from: 0, to: progress) + .stroke(lineWidth: 4) + .foregroundColor(progress <= colorChangeThreshold ? .red : .accentColor) + .rotationEffect(.degrees(270)) + .animation(.default, value: progress) + + } + .aspectRatio(1, contentMode: .fit) + } +} + +struct CircularProgressView_Previews: PreviewProvider { + static var previews: some View { + CircularProgressView(progress: 0.6, colorChangeThreshold: 0) + .frame(width: 30) + } +} diff --git a/OTP/Views/EditKeyForm.swift b/OTP/Views/EditKeyForm.swift new file mode 100644 index 0000000..e996eb5 --- /dev/null +++ b/OTP/Views/EditKeyForm.swift @@ -0,0 +1,113 @@ +// +// EditKeyForm.swift +// OTP +// +// Created by Shadowfacts on 8/21/21. +// + +import SwiftUI +import OTPKit + +struct EditKeyForm: View { + let dismiss: (DismissAction) -> Void + let showCancelButton: Bool + let focusOnAppear: Bool + @State private var editedKey: EditedKey + @FocusState private var issuerFocused: Bool + + init(editingKey: TOTPKey?, showCancelButton: Bool = true, focusOnAppear: Bool = false, dismiss: @escaping (DismissAction) -> Void) { + self.dismiss = dismiss + self.showCancelButton = showCancelButton + self.focusOnAppear = focusOnAppear + if let totpKey = editingKey, + let edited = EditedKey(totpKey: totpKey) { + self.editedKey = edited + } else { + self.editedKey = EditedKey() + } + } + + var isValid: Bool { + editedKey.toTOTPKey() != nil + } + + var body: some View { + Form { + TextField("Issuer", text: $editedKey.issuer) + .focused($issuerFocused) + TextField("Account", text: $editedKey.label) + .disableAutocorrection(true) + .autocapitalization(.none) + + HStack { + TextField("Secret", text: $editedKey.secret) + Button { + self.editedKey.secret = UIPasteboard.general.string ?? "" + } label: { + Label("Paste", systemImage: "doc.on.clipboard") + .labelStyle(.iconOnly) + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + .disabled(!UIPasteboard.general.hasStrings) + } + + HStack { + Text("Period") + Spacer() + Picker("Period", selection: $editedKey.period) { + Text("30 sec").tag(EditedKey.Period.thirty) + Text("60 sec").tag(EditedKey.Period.sixty) + } + .pickerStyle(.segmented) + .frame(maxWidth: 200) + } + + HStack { + Text("Digits") + Spacer() + Picker("Digits", selection: $editedKey.digits) { + Text("6").tag(EditedKey.Digits.six) + Text("8").tag(EditedKey.Digits.eight) + } + .pickerStyle(.segmented) + .frame(maxWidth: 200) + } + } + .onAppear { + if focusOnAppear { + // todo: this just doesn't work :/ + // https://developer.apple.com/forums/thread/681962 + // FB9551099 + issuerFocused = true + } + } + .onSubmit { + if isValid { + dismiss(.save(editedKey.toTOTPKey()!)) + } + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + // ToolbarContentBuilder doesn't support conditionals, so this has to go inside the ToolbarItem + if showCancelButton { + Button("Cancel") { + dismiss(.cancel) + } + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + dismiss(.save(editedKey.toTOTPKey()!)) + } + .disabled(!isValid) + } + } + } +} + +struct EditKeyForm_Previews: PreviewProvider { + static var previews: some View { + EditKeyForm(editingKey: nil) { (_) in } + } +} diff --git a/OTP/Views/FolderRow.swift b/OTP/Views/FolderRow.swift new file mode 100644 index 0000000..b9f5d48 --- /dev/null +++ b/OTP/Views/FolderRow.swift @@ -0,0 +1,80 @@ +// +// FolderRow.swift +// OTP +// +// Created by Shadowfacts on 8/24/21. +// + +import SwiftUI + +struct FolderRow: View { + @ObservedObject private var store: KeyStore + @State private var folder: KeyData.Folder + @State private var editing = false + @FocusState private var focused: Bool + @State private var isTargetedForDrop = false + + init(store: KeyStore, folder: KeyData.Folder) { + self.store = store + self._folder = State(initialValue: folder) + } + + var body: some View { + NavigationLink { + FolderView(folder: folder) + } label: { + HStack { + Image(systemName: "folder.fill") + .foregroundColor(.accentColor) + + if editing { + TextField("Name", text: $folder.name) + .focused($focused) + .submitLabel(.done) + .onSubmit { + withAnimation { + store.updateFolder(folder) + } + editing = false + focused = false + } + } else { + Text(folder.name) + } + } +// .background(isTargetedForDrop ? Color.red : nil) +// .onDrop(of: [.text], isTargeted: $isTargetedForDrop) { (itemProviders) in +// guard let provider = itemProviders.first, +// provider.canLoadObject(ofClass: NSString.self) else { +// return false +// } +// provider.loadObject(ofClass: NSString.self) { (idString, error) in +// guard error == nil, +// let idString = idString as? NSString, +// let id = UUID(uuidString: idString as String) else { +// return +// } +// store.moveEntryToFolder(entryID: id, folderID: folder.id) +// } +// return true +// } + } + .contextMenu { + Button { + editing = true + // just waiting 1 runloop iteration does not work for some reason :/ + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + focused = true + } + } label: { + Label("Rename Folder", systemImage: "pencil") + } + } + } +} + +struct FolderRow_Previews: PreviewProvider { + static var previews: some View { + FolderRow(store: .shared, folder: .init(name: "Test")) + } +} diff --git a/OTP/Views/FolderView.swift b/OTP/Views/FolderView.swift new file mode 100644 index 0000000..026e225 --- /dev/null +++ b/OTP/Views/FolderView.swift @@ -0,0 +1,37 @@ +// +// FolderView.swift +// OTP +// +// Created by Shadowfacts on 8/24/21. +// + +import SwiftUI + +struct FolderView: View { + @ObservedObject private var store: KeyStore + private let folder: KeyData.Folder + @ObservedObject private var codeHolder: AppView.CodeHolder + + init(folder: KeyData.Folder) { + let store = KeyStore.shared + self.store = store + self.folder = folder + self.codeHolder = AppView.CodeHolder(store: store) { (entry) in + entry.folderID == folder.id + } + } + + var body: some View { + List { + KeysSection(codeHolder: codeHolder) + } + .listStyle(.insetGrouped) + .navigationTitle(folder.name) + } +} + +struct FolderView_Previews: PreviewProvider { + static var previews: some View { + FolderView(folder: .init(name: "Test")) + } +} diff --git a/OTP/Views/FoldersSection.swift b/OTP/Views/FoldersSection.swift new file mode 100644 index 0000000..2697563 --- /dev/null +++ b/OTP/Views/FoldersSection.swift @@ -0,0 +1,33 @@ +// +// FoldersSection.swift +// OTP +// +// Created by Shadowfacts on 8/24/21. +// + +import SwiftUI + +struct FoldersSection: View { + @ObservedObject private var store: KeyStore = .shared + @FocusState private var focusedEdited: Bool + + var body: some View { + Section { + ForEach(store.sortedFolders) { (folder) in + FolderRow(store: store, folder: folder) + } + .onDelete { (indices) in + let folderIDs = indices.map { store.sortedFolders[$0].id } + withAnimation { + folderIDs.forEach(store.removeFolder(id:)) + } + } + } + } +} + +struct FoldersSectionView_Previews: PreviewProvider { + static var previews: some View { + FoldersSection() + } +} diff --git a/OTP/Views/KeyView.swift b/OTP/Views/KeyView.swift new file mode 100644 index 0000000..26db0e1 --- /dev/null +++ b/OTP/Views/KeyView.swift @@ -0,0 +1,77 @@ +// +// KeyView.swift +// OTP +// +// Created by Shadowfacts on 8/21/21. +// + +import SwiftUI +import OTPKit + +struct KeyView: View { + let key: TOTPKey + let currentCode: TOTPCode + + var formattedCode: String { + let code = currentCode.code + let mid = code.index(code.startIndex, offsetBy: code.count / 2) + return "\(code[code.startIndex.. Double { + let seconds = round(date.timeIntervalSince(currentCode.validFrom)) + let progress = 1 - seconds / Double(key.period) + return progress + } +} + +struct KeyView_Previews: PreviewProvider { + static var key: TOTPKey { + TOTPKey(urlComponents: URLComponents(string: "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example")!)! + } + static var code: TOTPCode { + OTPGenerator.generate(key: key) + } + static var previews: some View { + KeyView(key: key, currentCode: code) + } +} diff --git a/OTP/Views/KeysSection.swift b/OTP/Views/KeysSection.swift new file mode 100644 index 0000000..cf65364 --- /dev/null +++ b/OTP/Views/KeysSection.swift @@ -0,0 +1,141 @@ +// +// KeysSection.swift +// OTP +// +// Created by Shadowfacts on 8/24/21. +// + +import SwiftUI +import UniformTypeIdentifiers + +struct KeysSection: View { + @ObservedObject private var store: KeyStore = .shared + @ObservedObject private var entryHolder: AppView.CodeHolder + @State private var editedEntry: AppView.CodeEntry? = nil + @State private var presentedQRCode: AppView.CodeEntry? = nil + + init(codeHolder: AppView.CodeHolder) { + self.entryHolder = codeHolder + } + + var body: some View { + Section { + ForEach(entryHolder.sortedEntries) { (entry) in + KeyView(key: entry.key, currentCode: entry.code) + // disabled because dropping onto list rows does not work :/ +// .onDrag { +// NSItemProvider(object: entry.id.uuidString as NSString) +// } + .contextMenu { + self.keyMenu(entry: entry) + } + } + .onDelete { (indices) in + withAnimation(.default) { + for index in indices { + store.removeKey(entryID: entryHolder.sortedEntries[index].id) + } + } + } + } + .sheet(item: $editedEntry, content: self.editFormSheet) + .sheet(item: $presentedQRCode, content: self.qrCodeSheet) + } + + @ViewBuilder + private func keyMenu(entry: AppView.CodeEntry) -> some View { + Section { + Menu { + Button("No Folder") { + withAnimation { + store.moveEntryToFolder(entryID: entry.id, folderID: nil) + } + } + .disabled(entry.entry.folderID == nil) + + Section { + ForEach(store.sortedFolders) { (folder) in + Button { + withAnimation { + store.moveEntryToFolder(entryID: entry.id, folderID: folder.id) + } + } label: { + Text(folder.name) + } + .disabled(entry.entry.folderID == folder.id) + } + } + } label: { + Label("Move to Folder", systemImage: "folder") + } + + Button { + // even the .contextMenu closure is called again, SwiftUI seems not to update the actual context menu buttons + // so when this closure is called, it's copy of entry may be stale if the entry's since been edited + // so we lookup the known current one by ID and edit that + let realEntry = entryHolder.entries.first { $0.id == entry.id } + editedEntry = realEntry + } label: { + Label("Edit Key", systemImage: "pencil") + } + + // todo: can't mark menu as destructive + Menu { + Button("Cancel", role: .cancel) {} + + Button("Delete Key", role: .destructive) { + // todo: why doesn't this animation work? + withAnimation(.default) { + store.removeKey(entryID: entry.id) + } + } + } label: { + Label("Delete Key", systemImage: "trash") + } + } + + Section { + Button { + let realEntry = entryHolder.entries.first { $0.id == entry.id } + presentedQRCode = realEntry + } label: { + Label("Export as QR", systemImage: "qrcode") + } + + Button { + UIPasteboard.general.url = entry.key.url + } label: { + Label("Copy as URL", systemImage: "link") + } + } + } + + private func editFormSheet(editedEntry: AppView.CodeEntry) -> some View { + NavigationView { + EditKeyForm(editingKey: editedEntry.key) { (action) in + self.editedEntry = nil + switch action { + case .cancel: + break + case .save(let key): + store.updateKey(entryID: editedEntry.id, newKey: key) + } + } + .navigationTitle("Edit Key") + } + } + + private func qrCodeSheet(entry: AppView.CodeEntry) -> some View { + NavigationView { + QRCodeView(key: entry.key) + .id(entry.id) + } + } + +} + +struct KeysSection_Previews: PreviewProvider { + static var previews: some View { + KeysSection(codeHolder: .init(store: .shared)) + } +} diff --git a/OTP/Views/PreferencesView.swift b/OTP/Views/PreferencesView.swift new file mode 100644 index 0000000..bdd255a --- /dev/null +++ b/OTP/Views/PreferencesView.swift @@ -0,0 +1,79 @@ +// +// PreferencesView.swift +// OTP +// +// Created by Shadowfacts on 8/23/21. +// + +import SwiftUI + +struct PreferencesView: View { + @ObservedObject private var store = KeyStore.shared + @Environment(\.dismiss) private var dismiss + @State private var clearBeforeImport = false + @State private var isPresentingExport = false + @State private var isPresentingImportMode = false + @State private var isPresentingImport = false + @State private var isPresentingImportFailedAlert = false + @State private var importFailedError: Error? = nil + + var body: some View { + List { + Section("Backup") { + Button("Export to File...") { + isPresentingExport = true + } + Menu("Import from File...") { + Button("Keep Existing Keys") { + clearBeforeImport = false + isPresentingImport = true + } + Button("Replace Existing Keys", role: .destructive) { + clearBeforeImport = true + isPresentingImport = true + } + } + } + } + .navigationTitle("Preferences") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + .fileExporter(isPresented: $isPresentingExport, document: BackupDocument(data: store.data), contentType: .propertyList, defaultFilename: "OTPBackup") { (_) in + } + .fileImporter(isPresented: $isPresentingImport, allowedContentTypes: [.propertyList], allowsMultipleSelection: false) { (result) in + switch result { + case let .failure(error): + self.importFailedError = error + self.isPresentingImportFailedAlert = true + case let .success(urls): + do { + let backup = try BackupDocument(url: urls.first!) + store.updateFromStore(backup.data, replaceExisting: clearBeforeImport) + dismiss() + } catch { + self.importFailedError = error + self.isPresentingImportFailedAlert = true + } + } + } + .alert("Import Failed", isPresented: $isPresentingImportFailedAlert) { + Button("OK", role: .cancel) {} + } message: { + if let error = importFailedError { + Text(error.localizedDescription) + } + } + } + +} + +struct PreferencesView_Previews: PreviewProvider { + static var previews: some View { + PreferencesView() + } +} diff --git a/OTP/Views/QRCodeView.swift b/OTP/Views/QRCodeView.swift new file mode 100644 index 0000000..85995e6 --- /dev/null +++ b/OTP/Views/QRCodeView.swift @@ -0,0 +1,169 @@ +// +// QRCodeView.swift +// OTP +// +// Created by Shadowfacts on 8/23/21. +// + +import SwiftUI +import OTPKit +import CoreImage.CIFilterBuiltins + +struct QRCodeView: View { + let key: TOTPKey + @State private var image: UIImage? + @Environment(\.dismiss) private var dismiss + @State private var isPresentingShareSheet = false + + init(key: TOTPKey) { + self.key = key +// self._image = State(initialValue: createImage()) + } + + private func createImage() -> UIImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = key.url.absoluteString.data(using: .utf8)! + let transform = CGAffineTransform(scaleX: 5, y: 5) + + let context = CIContext() + guard let output = filter.outputImage?.transformed(by: transform), + let cgImage = context.createCGImage(output, from: output.extent) else { + return nil + } + + let issuerFont = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .largeTitle).withSymbolicTraits(.traitBold)!, size: 0) + let labelFont = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body), size: 0) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byWordWrapping + + let issuer = key.issuer as NSString + let size = CGSize(width: CGFloat(cgImage.width - 8), height: .greatestFiniteMagnitude) + + var issuerRect = issuer.boundingRect(with: size, options: [.usesFontLeading, .usesLineFragmentOrigin], attributes: [.font: issuerFont, .paragraphStyle: paragraphStyle], context: nil) + issuerRect.origin.y += 4 + issuerRect.origin.x += 4 + + var labelRect = CGRect.zero + if let label = key.label, !label.isEmpty { + labelRect = (label as NSString).boundingRect(with: size, options: [.usesFontLeading, .usesLineFragmentOrigin], attributes: [.font: labelFont, .paragraphStyle: paragraphStyle], context: nil) + labelRect.origin.x += 4 + labelRect.origin.y += ceil(issuerRect.maxY) + } + + let imageSize = CGSize(width: CGFloat(cgImage.width), height: CGFloat(cgImage.height) + ceil(issuerRect.height) + ceil(labelRect.height) + 4) + let renderer = UIGraphicsImageRenderer(size: imageSize) + return renderer.image { (ctx) in + ctx.cgContext.setFillColor(UIColor.white.cgColor) + ctx.cgContext.fill(CGRect(origin: .zero, size: imageSize)) + + issuer.draw(in: issuerRect, withAttributes: [.font: issuerFont, .paragraphStyle: paragraphStyle]) + + if let label = key.label { + (label as NSString).draw(in: labelRect, withAttributes: [.font: labelFont, .paragraphStyle: paragraphStyle]) + } + + let imageRect = CGRect(x: 0, y: imageSize.height - CGFloat(cgImage.height), width: CGFloat(cgImage.width), height: CGFloat(cgImage.height)) + ctx.cgContext.draw(cgImage, in: imageRect) + } + } + + var body: some View { + return mainView + .navigationTitle("Export QR Code") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + .background { + ActivityView(isPresented: $isPresentingShareSheet, items: [image as Any, key.url]) + } + .task { + self.image = createImage() + } + } + + @ViewBuilder + private var mainView: some View { + if let image = image { + VStack(alignment: .center) { + Image(uiImage: image) + .cornerRadius(5) + .shadow(radius: 3) + + HStack { + Button { + isPresentingShareSheet = true + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + Spacer() + Button(action: save) { + Label("Save", systemImage: "square.and.arrow.down") + } + } + } + // fix the size so that the HStack doesn't grow beyond with width of the image + .fixedSize() + } else { + ProgressView() + .progressViewStyle(.circular) + } + } + + private func save() { + guard let image = image else { return } + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + } +} + +struct ActivityView: UIViewControllerRepresentable { + var isPresented: Binding + let items: [Any] + + func makeUIViewController(context: Context) -> Wrapper { + return Wrapper(isPresented: isPresented, items: items) + } + + func updateUIViewController(_ uiViewController: Wrapper, context: Context) { + uiViewController.update(isPresented: isPresented, items: items) + } + + class Wrapper: UIViewController { + private(set) var isPresented: Binding! + private(set) var items: [Any]! + + init(isPresented: Binding, items: [Any]) { + self.isPresented = isPresented + self.items = items + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(isPresented: Binding, items: [Any]) { + self.isPresented = isPresented + self.items = items + + if isPresented.wrappedValue, presentedViewController == nil { + let vc = UIActivityViewController(activityItems: items, applicationActivities: nil) + vc.completionWithItemsHandler = { (_, _, _, _) in + isPresented.wrappedValue = false + } + present(vc, animated: true) + } + } + } +} + +struct QRCodeView_Previews: PreviewProvider { + static var previews: some View { + QRCodeView(key: TOTPKey(urlComponents: URLComponents(string: "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example")!)!) + } +} diff --git a/OTPKit/OTPAlgorithm.swift b/OTPKit/OTPAlgorithm.swift new file mode 100644 index 0000000..5add7cd --- /dev/null +++ b/OTPKit/OTPAlgorithm.swift @@ -0,0 +1,12 @@ +// +// OTPAlgorithm.swift +// OTPKit +// +// Created by Shadowfacts on 8/21/21. +// + +import Foundation + +public enum OTPAlgorithm: Codable { + case SHA1 +} diff --git a/OTPKit/OTPGenerator.swift b/OTPKit/OTPGenerator.swift new file mode 100644 index 0000000..9da1d25 --- /dev/null +++ b/OTPKit/OTPGenerator.swift @@ -0,0 +1,45 @@ +// +// OTPGenerator.swift +// OTPKit +// +// Created by Shadowfacts on 8/21/21. +// + +import Foundation +import CryptoKit + +public struct OTPGenerator { + + private init() {} + + public static func generate(key: OTPKey, counter: Int64) -> String { + let data = withUnsafeBytes(of: counter.bigEndian) { + Data($0) + } + + let mac = HMAC.authenticationCode(for: data, using: SymmetricKey(data: key.secret)) + + let uint32Code = mac.withUnsafeBytes { (ptr) -> UInt32 in + let offset = Int(ptr.last! & 0x0F) + let offsetPtr = ptr.baseAddress!.advanced(by: offset) + return offsetPtr.assumingMemoryBound(to: UInt32.self).pointee.bigEndian & 0x7FFFFFFF + } + + let truncated = uint32Code % UInt32(pow(10, Double(key.digits))) + + var s = truncated.description + if s.count < key.digits { + s = String(repeating: "0", count: key.digits - s.count) + s + } + return s + } + + public static func generate(key: TOTPKey) -> TOTPCode { + let now = Date() + let counter = Int64(now.timeIntervalSince1970 / Double(key.period)) + let start = Date(timeIntervalSince1970: Double(counter * Int64(key.period))) + let code = generate(key: key, counter: counter) + return TOTPCode(code: code, validFrom: start, validInterval: key.period) + } + +} diff --git a/OTPKit/OTPKey.swift b/OTPKit/OTPKey.swift new file mode 100644 index 0000000..a784103 --- /dev/null +++ b/OTPKit/OTPKey.swift @@ -0,0 +1,15 @@ +// +// OTPKey.swift +// OTPKit +// +// Created by Shadowfacts on 8/21/21. +// + +import Foundation + +public protocol OTPKey { + var secret: Data { get } + var digits: Int { get } + var algorithm: OTPAlgorithm { get } + var period: Int { get } +} diff --git a/OTPKit/OTPKit.docc/OTPKit.md b/OTPKit/OTPKit.docc/OTPKit.md new file mode 100755 index 0000000..a801f71 --- /dev/null +++ b/OTPKit/OTPKit.docc/OTPKit.md @@ -0,0 +1,13 @@ +# ``OTPKit`` + +Summary + +## Overview + +Text + +## Topics + +### Group + +- ``Symbol`` \ No newline at end of file diff --git a/OTPKit/OTPKit.h b/OTPKit/OTPKit.h new file mode 100644 index 0000000..c5aa08d --- /dev/null +++ b/OTPKit/OTPKit.h @@ -0,0 +1,18 @@ +// +// OTPKit.h +// OTPKit +// +// Created by Shadowfacts on 8/21/21. +// + +#import + +//! Project version number for OTPKit. +FOUNDATION_EXPORT double OTPKitVersionNumber; + +//! Project version string for OTPKit. +FOUNDATION_EXPORT const unsigned char OTPKitVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/OTPKit/TOTPCode.swift b/OTPKit/TOTPCode.swift new file mode 100644 index 0000000..d3d1f50 --- /dev/null +++ b/OTPKit/TOTPCode.swift @@ -0,0 +1,17 @@ +// +// TOTPCode.swift +// OTPKit +// +// Created by Shadowfacts on 8/21/21. +// + +import Foundation + +public struct TOTPCode: Equatable, Hashable { + public let code: String + public let validFrom: Date + public let validInterval: Int // seconds + public var validUntil: Date { + validFrom.addingTimeInterval(TimeInterval(validInterval)) + } +} diff --git a/OTPKit/TOTPKey.swift b/OTPKit/TOTPKey.swift new file mode 100644 index 0000000..2ff4c5e --- /dev/null +++ b/OTPKit/TOTPKey.swift @@ -0,0 +1,110 @@ +// +// TOTPKey.swift +// OTPKit +// +// Created by Shadowfacts on 8/21/21. +// + +import Foundation + +public struct TOTPKey: OTPKey, Equatable, Hashable, Codable { + public let secret: Data + public let period: Int + public let digits: Int + public let algorithm: OTPAlgorithm + public let label: String? + public let issuer: String +// let image: Data? + + public init(secret: Data, period: Int, digits: Int, label: String?, issuer: String) { + self.secret = secret + self.period = period + self.digits = digits + self.algorithm = .SHA1 + self.label = label + self.issuer = issuer + } +} + +public extension TOTPKey { + + init?(urlComponents: URLComponents) { + guard urlComponents.scheme == "otpauth", + urlComponents.host == "totp", + let queryItems = urlComponents.queryItems else { + return nil + } + + var label: String? + var issuer: String + var secret: Data? + var period: Int = 30 + var digits = 6 + + let path = urlComponents.path.drop(while: { $0 == "/" }) + let components = path.components(separatedBy: ":") + if components.count > 1 { + issuer = components[0] + label = components[1] + } else { + issuer = components[0] + } + + for item in queryItems { + guard let value = item.value else { continue } + switch item.name.lowercased() { + case "algorithm": + if value.lowercased() != "sha1" { + return nil + } + case "digits": + if let newDigits = Int(value) { + digits = newDigits + } + case "issuer": + issuer = value + case "period": + if let newPeriod = Int(value) { + period = newPeriod + } + case "secret": + if let data = value.base32DecodedData { + secret = data + } + default: + continue + } + } + + guard let secret = secret else { return nil } + self.issuer = issuer + self.label = label + self.secret = secret + self.period = period + self.digits = digits + self.algorithm = .SHA1 + } + + var url: URL { + var components = URLComponents() + + components.scheme = "otpauth" + components.host = "totp" + + if let label = label, !label.isEmpty { + components.path = "/\(issuer):\(label)" + } else { + components.path = "/\(issuer)" + } + components.queryItems = [ + URLQueryItem(name: "algorithm", value: "SHA1"), + URLQueryItem(name: "digits", value: digits.description), + URLQueryItem(name: "issuer", value: issuer), + URLQueryItem(name: "period", value: period.description), + URLQueryItem(name: "secret", value: secret.base32EncodedString), + ] + + return components.url! + } + +} diff --git a/OTPKit/Vendor/Base32.swift b/OTPKit/Vendor/Base32.swift new file mode 100644 index 0000000..670bd5a --- /dev/null +++ b/OTPKit/Vendor/Base32.swift @@ -0,0 +1,399 @@ +// +// Base32.swift +// TOTP +// +// Created by 野村 憲男 on 1/24/15. +// +// Copyright (c) 2015 Norio Nomura +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Foundation + +// https://tools.ietf.org/html/rfc4648 + +// MARK: - Base32 Data <-> String + +public func base32Encode(_ data: Data) -> String { + return data.withUnsafeBytes { + base32encode($0.baseAddress!, $0.count, alphabetEncodeTable) + } +} + +public func base32HexEncode(_ data: Data) -> String { + return data.withUnsafeBytes { + base32encode($0.baseAddress!, $0.count, extendedHexAlphabetEncodeTable) + } +} + +public func base32DecodeToData(_ string: String) -> Data? { + return base32decode(string, alphabetDecodeTable).flatMap(Data.init(_:)) +} + +public func base32HexDecodeToData(_ string: String) -> Data? { + return base32decode(string, extendedHexAlphabetDecodeTable).flatMap(Data.init(_:)) +} + +// MARK: - Base32 [UInt8] <-> String + +public func base32Encode(_ array: [UInt8]) -> String { + return base32encode(array, array.count, alphabetEncodeTable) +} + +public func base32HexEncode(_ array: [UInt8]) -> String { + return base32encode(array, array.count, extendedHexAlphabetEncodeTable) +} + +public func base32Decode(_ string: String) -> [UInt8]? { + return base32decode(string, alphabetDecodeTable) +} + +public func base32HexDecode(_ string: String) -> [UInt8]? { + return base32decode(string, extendedHexAlphabetDecodeTable) +} + +// MARK: extensions + +extension String { + // base32 + public var base32DecodedData: Data? { + return base32DecodeToData(self) + } + + public var base32EncodedString: String { + return utf8CString.withUnsafeBufferPointer { + base32encode($0.baseAddress!, $0.count - 1, alphabetEncodeTable) + } + } + + public func base32DecodedString(_ encoding: String.Encoding = .utf8) -> String? { + return base32DecodedData.flatMap { + String(data: $0, encoding: .utf8) + } + } + + // base32Hex + public var base32HexDecodedData: Data? { + return base32HexDecodeToData(self) + } + + public var base32HexEncodedString: String { + return utf8CString.withUnsafeBufferPointer { + base32encode($0.baseAddress!, $0.count - 1, extendedHexAlphabetEncodeTable) + } + } + + public func base32HexDecodedString(_ encoding: String.Encoding = .utf8) -> String? { + return base32HexDecodedData.flatMap { + String(data: $0, encoding: .utf8) + } + } +} + +extension Data { + // base32 + public var base32EncodedString: String { + return base32Encode(self) + } + + public var base32EncodedData: Data { + return base32EncodedString.dataUsingUTF8StringEncoding + } + + public var base32DecodedData: Data? { + return String(data: self, encoding: .utf8).flatMap(base32DecodeToData) + } + + // base32Hex + public var base32HexEncodedString: String { + return base32HexEncode(self) + } + + public var base32HexEncodedData: Data { + return base32HexEncodedString.dataUsingUTF8StringEncoding + } + + public var base32HexDecodedData: Data? { + return String(data: self, encoding: .utf8).flatMap(base32HexDecodeToData) + } +} + +// MARK: - private + +private extension String { + var dataUsingUTF8StringEncoding: Data { + return utf8CString.withUnsafeBufferPointer { + Data($0.dropLast().map(UInt8.init)) + } + } +} + +// MARK: encode + +private let alphabetEncodeTable: [Int8] = ["A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","2","3","4","5","6","7"].map { (c: UnicodeScalar) -> Int8 in Int8(c.value) } + +private let extendedHexAlphabetEncodeTable: [Int8] = ["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V"].map { (c: UnicodeScalar) -> Int8 in Int8(c.value) } + +private func base32encode(_ data: UnsafeRawPointer, _ length: Int, _ table: [Int8]) -> String { + if length == 0 { + return "" + } + var length = length + + var bytes = data.assumingMemoryBound(to: UInt8.self) + + let resultBufferSize = Int(ceil(Double(length) / 5)) * 8 + 1 // need null termination + let resultBuffer = UnsafeMutablePointer.allocate(capacity: resultBufferSize) + var encoded = resultBuffer + + // encode regular blocks + while length >= 5 { + encoded[0] = table[Int(bytes[0] >> 3)] + encoded[1] = table[Int((bytes[0] & 0b00000111) << 2 | bytes[1] >> 6)] + encoded[2] = table[Int((bytes[1] & 0b00111110) >> 1)] + encoded[3] = table[Int((bytes[1] & 0b00000001) << 4 | bytes[2] >> 4)] + encoded[4] = table[Int((bytes[2] & 0b00001111) << 1 | bytes[3] >> 7)] + encoded[5] = table[Int((bytes[3] & 0b01111100) >> 2)] + encoded[6] = table[Int((bytes[3] & 0b00000011) << 3 | bytes[4] >> 5)] + encoded[7] = table[Int((bytes[4] & 0b00011111))] + length -= 5 + encoded = encoded.advanced(by: 8) + bytes = bytes.advanced(by: 5) + } + + // encode last block + var byte0, byte1, byte2, byte3, byte4: UInt8 + (byte0, byte1, byte2, byte3, byte4) = (0,0,0,0,0) + switch length { + case 4: + byte3 = bytes[3] + encoded[6] = table[Int((byte3 & 0b00000011) << 3 | byte4 >> 5)] + encoded[5] = table[Int((byte3 & 0b01111100) >> 2)] + fallthrough + case 3: + byte2 = bytes[2] + encoded[4] = table[Int((byte2 & 0b00001111) << 1 | byte3 >> 7)] + fallthrough + case 2: + byte1 = bytes[1] + encoded[3] = table[Int((byte1 & 0b00000001) << 4 | byte2 >> 4)] + encoded[2] = table[Int((byte1 & 0b00111110) >> 1)] + fallthrough + case 1: + byte0 = bytes[0] + encoded[1] = table[Int((byte0 & 0b00000111) << 2 | byte1 >> 6)] + encoded[0] = table[Int(byte0 >> 3)] + default: break + } + + // padding + let pad = Int8(UnicodeScalar("=").value) + switch length { + case 0: + encoded[0] = 0 + case 1: + encoded[2] = pad + encoded[3] = pad + fallthrough + case 2: + encoded[4] = pad + fallthrough + case 3: + encoded[5] = pad + encoded[6] = pad + fallthrough + case 4: + encoded[7] = pad + fallthrough + default: + encoded[8] = 0 + break + } + + // return + if let base32Encoded = String(validatingUTF8: resultBuffer) { + resultBuffer.deallocate() + return base32Encoded + } else { + resultBuffer.deallocate() + fatalError("internal error") + } +} + +// MARK: decode + +private let __: UInt8 = 255 +private let alphabetDecodeTable: [UInt8] = [ + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x00 - 0x0F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x10 - 0x1F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x20 - 0x2F + __,__,26,27, 28,29,30,31, __,__,__,__, __,__,__,__, // 0x30 - 0x3F + __, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11,12,13,14, // 0x40 - 0x4F + 15,16,17,18, 19,20,21,22, 23,24,25,__, __,__,__,__, // 0x50 - 0x5F + __, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11,12,13,14, // 0x60 - 0x6F + 15,16,17,18, 19,20,21,22, 23,24,25,__, __,__,__,__, // 0x70 - 0x7F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x80 - 0x8F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x90 - 0x9F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xA0 - 0xAF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xB0 - 0xBF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xC0 - 0xCF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xD0 - 0xDF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xE0 - 0xEF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xF0 - 0xFF +] + +private let extendedHexAlphabetDecodeTable: [UInt8] = [ + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x00 - 0x0F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x10 - 0x1F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x20 - 0x2F + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,__,__, __,__,__,__, // 0x30 - 0x3F + __,10,11,12, 13,14,15,16, 17,18,19,20, 21,22,23,24, // 0x40 - 0x4F + 25,26,27,28, 29,30,31,__, __,__,__,__, __,__,__,__, // 0x50 - 0x5F + __,10,11,12, 13,14,15,16, 17,18,19,20, 21,22,23,24, // 0x60 - 0x6F + 25,26,27,28, 29,30,31,__, __,__,__,__, __,__,__,__, // 0x70 - 0x7F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x80 - 0x8F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x90 - 0x9F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xA0 - 0xAF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xB0 - 0xBF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xC0 - 0xCF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xD0 - 0xDF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xE0 - 0xEF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xF0 - 0xFF +] + + +private func base32decode(_ string: String, _ table: [UInt8]) -> [UInt8]? { + let length = string.unicodeScalars.count + if length == 0 { + return [] + } + + // calc padding length + func getLeastPaddingLength(_ string: String) -> Int { + if string.hasSuffix("======") { + return 6 + } else if string.hasSuffix("====") { + return 4 + } else if string.hasSuffix("===") { + return 3 + } else if string.hasSuffix("=") { + return 1 + } else { + return 0 + } + } + + // validate string + let leastPaddingLength = getLeastPaddingLength(string) + if let index = string.unicodeScalars.firstIndex(where: {$0.value > 0xff || table[Int($0.value)] > 31}) { + // index points padding "=" or invalid character that table does not contain. + let pos = string.unicodeScalars.distance(from: string.unicodeScalars.startIndex, to: index) + // if pos points padding "=", it's valid. + if pos != length - leastPaddingLength { + print("string contains some invalid characters.") + return nil + } + } + + var remainEncodedLength = length - leastPaddingLength + var additionalBytes = 0 + switch remainEncodedLength % 8 { + // valid + case 0: break + case 2: additionalBytes = 1 + case 4: additionalBytes = 2 + case 5: additionalBytes = 3 + case 7: additionalBytes = 4 + default: + print("string length is invalid.") + return nil + } + + // validated + let dataSize = remainEncodedLength / 8 * 5 + additionalBytes + + // Use UnsafePointer + return string.utf8CString.withUnsafeBufferPointer { + (data: UnsafeBufferPointer) -> [UInt8] in + var encoded = data.baseAddress! + + var result = Array(repeating: 0, count: dataSize) + var decodedOffset = 0 + + // decode regular blocks + var value0, value1, value2, value3, value4, value5, value6, value7: UInt8 + (value0, value1, value2, value3, value4, value5, value6, value7) = (0,0,0,0,0,0,0,0) + while remainEncodedLength >= 8 { + value0 = table[Int(encoded[0])] + value1 = table[Int(encoded[1])] + value2 = table[Int(encoded[2])] + value3 = table[Int(encoded[3])] + value4 = table[Int(encoded[4])] + value5 = table[Int(encoded[5])] + value6 = table[Int(encoded[6])] + value7 = table[Int(encoded[7])] + + result[decodedOffset] = value0 << 3 | value1 >> 2 + result[decodedOffset + 1] = value1 << 6 | value2 << 1 | value3 >> 4 + result[decodedOffset + 2] = value3 << 4 | value4 >> 1 + result[decodedOffset + 3] = value4 << 7 | value5 << 2 | value6 >> 3 + result[decodedOffset + 4] = value6 << 5 | value7 + + remainEncodedLength -= 8 + decodedOffset += 5 + encoded = encoded.advanced(by: 8) + } + + // decode last block + (value0, value1, value2, value3, value4, value5, value6, value7) = (0,0,0,0,0,0,0,0) + switch remainEncodedLength { + case 7: + value6 = table[Int(encoded[6])] + value5 = table[Int(encoded[5])] + fallthrough + case 5: + value4 = table[Int(encoded[4])] + fallthrough + case 4: + value3 = table[Int(encoded[3])] + value2 = table[Int(encoded[2])] + fallthrough + case 2: + value1 = table[Int(encoded[1])] + value0 = table[Int(encoded[0])] + default: break + } + switch remainEncodedLength { + case 7: + result[decodedOffset + 3] = value4 << 7 | value5 << 2 | value6 >> 3 + fallthrough + case 5: + result[decodedOffset + 2] = value3 << 4 | value4 >> 1 + fallthrough + case 4: + result[decodedOffset + 1] = value1 << 6 | value2 << 1 | value3 >> 4 + fallthrough + case 2: + result[decodedOffset] = value0 << 3 | value1 >> 2 + default: break + } + + return result + } +} diff --git a/OTPKitTests/OTPGeneratorTests.swift b/OTPKitTests/OTPGeneratorTests.swift new file mode 100644 index 0000000..7f5416b --- /dev/null +++ b/OTPKitTests/OTPGeneratorTests.swift @@ -0,0 +1,25 @@ +// +// OTPGeneratorTests.swift +// OTPKitTests +// +// Created by Shadowfacts on 8/21/21. +// + +import XCTest +@testable import OTPKit + +class OTPGeneratorTests: XCTestCase { + + func testGenerateTOTP() { + let key = TOTPKey(urlComponents: URLComponents(string: "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example")!)! + let result = OTPGenerator.generate(key: key, counter: 54319307) + XCTAssertEqual(result, "155456") + } + + func testPadWithZeroes() { + let key = TOTPKey(urlComponents: URLComponents(string: "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example")!)! + let result = OTPGenerator.generate(key: key, counter: 54319378) + XCTAssertEqual(result, "095891") + } + +} diff --git a/OTPKitTests/OTPKitTests.swift b/OTPKitTests/OTPKitTests.swift new file mode 100644 index 0000000..9e71aad --- /dev/null +++ b/OTPKitTests/OTPKitTests.swift @@ -0,0 +1,33 @@ +// +// OTPKitTests.swift +// OTPKitTests +// +// Created by Shadowfacts on 8/21/21. +// + +import XCTest +@testable import OTPKit + +class OTPKitTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/OTPKitTests/TOTPKeyTests.swift b/OTPKitTests/TOTPKeyTests.swift new file mode 100644 index 0000000..b86c370 --- /dev/null +++ b/OTPKitTests/TOTPKeyTests.swift @@ -0,0 +1,67 @@ +// +// TOTPKeyTests.swift +// OTPKitTests +// +// Created by Shadowfacts on 8/21/21. +// + +import XCTest +@testable import OTPKit + +class TOTPKeyTests: XCTestCase { + + func testDecodeSimpleKey() throws { + let decoded = TOTPKey(urlComponents: URLComponents(string: "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example")!) + XCTAssertNotNil(decoded) + let key = decoded! + XCTAssertEqual(key.secret, Data([ + UInt8(ascii: "H"), + UInt8(ascii: "e"), + UInt8(ascii: "l"), + UInt8(ascii: "l"), + UInt8(ascii: "o"), + UInt8(ascii: "!"), + 0xDE, 0xAD, 0xBE, 0xEF + ])) + XCTAssertEqual(key.issuer, "Example") + XCTAssertEqual(key.label, "alice@google.com") + } + + func testDecodeOptionalParameters() throws { + let decoded = TOTPKey(urlComponents: URLComponents(string: "otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=8&period=60")!) + XCTAssertNotNil(decoded) + let key = decoded! + XCTAssertEqual(key.secret, Data([ + 0x3D, 0xC6, 0xCA, 0xA4, + 0x82, 0x4A, 0x6D, 0x28, + 0x87, 0x67, 0xB2, 0x33, + 0x1E, 0x20, 0xB4, 0x31, + 0x66, 0xCB, 0x85, 0xD9, + ])) + XCTAssertEqual(key.issuer, "ACME Co") + XCTAssertEqual(key.label, "john.doe@email.com") + XCTAssertEqual(key.period, 60) + XCTAssertEqual(key.digits, 8) + } + + func testDecodeInvalidAlgorithm() { + let components = URLComponents(string: "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256")! + XCTAssertNil(TOTPKey(urlComponents: components)) + } + + func testRoundtripURL() { + let components = URLComponents(string: "otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=8&period=60")! + let key = TOTPKey(urlComponents: components) + XCTAssertNotNil(key) + let outputComponents = URLComponents(url: key!.url, resolvingAgainstBaseURL: false)! + XCTAssertEqual(outputComponents.scheme, components.scheme) + XCTAssertEqual(outputComponents.host, components.host) + XCTAssertEqual(outputComponents.path, components.path) + XCTAssertEqual( + outputComponents.queryItems!.sorted { (a, b) in a.name < b.name }, + components.queryItems!.sorted { (a, b) in a.name < b.name } + ) + + } + +}