Add the app

This commit is contained in:
Shadowfacts 2021-08-24 18:19:31 -04:00
parent 5da64ead41
commit 6d19c4b81d
36 changed files with 2824 additions and 72 deletions

78
.gitignore vendored Normal file
View File

@ -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

View File

@ -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 = "<group>"; };
D60E9D7126D1A863009A4537 /* KeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyView.swift; sourceTree = "<group>"; };
D60E9D7326D1ABF9009A4537 /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = "<group>"; };
D60E9D7926D1E985009A4537 /* EditKeyForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditKeyForm.swift; sourceTree = "<group>"; };
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 = "<group>"; };
D61479D826D0AF1C00710B79 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
D61479DA26D0AF1C00710B79 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; };
D61479DD26D0AF1C00710B79 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
D61479DF26D0AF1E00710B79 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
D61479E226D0AF1E00710B79 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
D61479E426D0AF1E00710B79 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D61479EA26D13DEF00710B79 /* AppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
D61479F426D141F700710B79 /* OTPKit.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = OTPKit.docc; sourceTree = "<group>"; };
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 = "<group>"; };
D6147A0F26D1421900710B79 /* TOTPKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPKey.swift; sourceTree = "<group>"; };
D6147A1226D1446E00710B79 /* Base32.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Base32.swift; sourceTree = "<group>"; };
D6147A1626D147B400710B79 /* TOTPKeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPKeyTests.swift; sourceTree = "<group>"; };
D6147A1826D14AAB00710B79 /* OTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPGenerator.swift; sourceTree = "<group>"; };
D6147A1A26D14ADF00710B79 /* OTPKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPKey.swift; sourceTree = "<group>"; };
D6147A1C26D14B5F00710B79 /* OTPAlgorithm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPAlgorithm.swift; sourceTree = "<group>"; };
D6147A1E26D14BA000710B79 /* TOTPCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPCode.swift; sourceTree = "<group>"; };
D68B3D9F26D2937000CB35A2 /* AddURLForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddURLForm.swift; sourceTree = "<group>"; };
D68B3DA126D293BE00CB35A2 /* DismissAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissAction.swift; sourceTree = "<group>"; };
D6A2BCAD26D29601004DC4E3 /* EditedKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditedKey.swift; sourceTree = "<group>"; };
D6A2BCAF26D313E7004DC4E3 /* AddQRView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddQRView.swift; sourceTree = "<group>"; };
D6A2BCCB26D3E670004DC4E3 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
D6A2BCCF26D3E888004DC4E3 /* BackupDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupDocument.swift; sourceTree = "<group>"; };
D6A2BCEB26D3F7F9004DC4E3 /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = "<group>"; };
D6A2BD0726D449FA004DC4E3 /* KeyData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyData.swift; sourceTree = "<group>"; };
D6A2BD0926D44AC3004DC4E3 /* KeyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyStore.swift; sourceTree = "<group>"; };
D6A2BD0B26D5332B004DC4E3 /* FolderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderView.swift; sourceTree = "<group>"; };
D6A2BD0F26D533EB004DC4E3 /* FoldersSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoldersSection.swift; sourceTree = "<group>"; };
D6A2BD1126D56C70004DC4E3 /* KeysSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeysSection.swift; sourceTree = "<group>"; };
D6A2BD1326D5707F004DC4E3 /* FolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderRow.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
};
D61479CA26D0AF1C00710B79 = {
isa = PBXGroup;
children = (
D61479D526D0AF1C00710B79 /* OTP */,
D61479F226D141F700710B79 /* OTPKit */,
D6147A0026D141F800710B79 /* OTPKitTests */,
D61479D426D0AF1C00710B79 /* Products */,
);
sourceTree = "<group>";
@ -49,6 +183,8 @@
isa = PBXGroup;
children = (
D61479D326D0AF1C00710B79 /* OTP.app */,
D61479F126D141F700710B79 /* OTPKit.framework */,
D61479FA26D141F800710B79 /* OTPKitTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@ -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 = "<group>";
};
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 = "<group>";
};
D6147A0026D141F800710B79 /* OTPKitTests */ = {
isa = PBXGroup;
children = (
D6147A0126D141F800710B79 /* OTPKitTests.swift */,
D60E9D6D26D1998B009A4537 /* OTPGeneratorTests.swift */,
D6147A1626D147B400710B79 /* TOTPKeyTests.swift */,
);
path = OTPKitTests;
sourceTree = "<group>";
};
D6147A1126D1446500710B79 /* Vendor */ = {
isa = PBXGroup;
children = (
D6147A1226D1446E00710B79 /* Base32.swift */,
);
path = Vendor;
sourceTree = "<group>";
};
/* 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 = "<group>";
/* 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 */;
}

View File

@ -5,6 +5,11 @@
<key>SchemeUserState</key>
<dict>
<key>OTP.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>OTPKit.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>

View File

@ -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
}

45
OTP/BackupDocument.swift Normal file
View File

@ -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)
}
}

View File

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="ViewController" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

14
OTP/DismissAction.swift Normal file
View File

@ -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)
}

74
OTP/EditedKey.swift Normal file
View File

@ -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
}
}
}
}

View File

@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save exported QR codes</string>
<key>NSCameraUsageDescription</key>
<string>Scan OTP keys from QR codes</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@ -15,8 +19,6 @@
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>

121
OTP/KeyData.swift Normal file
View File

@ -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
}
}

89
OTP/KeyStore.swift Normal file
View File

@ -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)
}
}

View File

@ -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) {

View File

@ -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.
}
}

95
OTP/Views/AddQRView.swift Normal file
View File

@ -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 }
}
}

View File

@ -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 }
}
}

210
OTP/Views/AppView.swift Normal file
View File

@ -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<AnyCancellable>()
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()
}
}

View File

@ -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)
}
}

113
OTP/Views/EditKeyForm.swift Normal file
View File

@ -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 }
}
}

80
OTP/Views/FolderRow.swift Normal file
View File

@ -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"))
}
}

View File

@ -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"))
}
}

View File

@ -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()
}
}

77
OTP/Views/KeyView.swift Normal file
View File

@ -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..<mid]) \(code[mid...])"
}
init(key: TOTPKey, currentCode: TOTPCode) {
self.key = key
self.currentCode = currentCode
}
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(key.issuer)
.font(.title3)
if let label = key.label, !label.isEmpty {
Text(label)
.font(.footnote)
}
}
Spacer()
Text(formattedCode)
.font(.system(.title2, design: .monospaced))
// Text("\(currentCode.validUntil, style: .relative)")
// .font(.body.monospacedDigit())
// I don't think this TimelineView should be necessary since the CodeHolder timer fires every .5 seconds
TimelineView(.animation) { (ctx) in
ZStack {
CircularProgressView(progress: progress(at: Date()), colorChangeThreshold: 5.0 / Double(key.period))
Text(Int(round(currentCode.validUntil.timeIntervalSinceNow)).description)
.font(.caption.monospacedDigit())
}
.frame(width: 30)
}
}
}
private func progress(at date: Date) -> 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)
}
}

141
OTP/Views/KeysSection.swift Normal file
View File

@ -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))
}
}

View File

@ -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()
}
}

169
OTP/Views/QRCodeView.swift Normal file
View File

@ -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<Bool>
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<Bool>!
private(set) var items: [Any]!
init(isPresented: Binding<Bool>, 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<Bool>, 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")!)!)
}
}

12
OTPKit/OTPAlgorithm.swift Normal file
View File

@ -0,0 +1,12 @@
//
// OTPAlgorithm.swift
// OTPKit
//
// Created by Shadowfacts on 8/21/21.
//
import Foundation
public enum OTPAlgorithm: Codable {
case SHA1
}

45
OTPKit/OTPGenerator.swift Normal file
View File

@ -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<Insecure.SHA1>.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)
}
}

15
OTPKit/OTPKey.swift Normal file
View File

@ -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 }
}

13
OTPKit/OTPKit.docc/OTPKit.md Executable file
View File

@ -0,0 +1,13 @@
# ``OTPKit``
<!--@START_MENU_TOKEN@-->Summary<!--@END_MENU_TOKEN@-->
## Overview
<!--@START_MENU_TOKEN@-->Text<!--@END_MENU_TOKEN@-->
## Topics
### <!--@START_MENU_TOKEN@-->Group<!--@END_MENU_TOKEN@-->
- <!--@START_MENU_TOKEN@-->``Symbol``<!--@END_MENU_TOKEN@-->

18
OTPKit/OTPKit.h Normal file
View File

@ -0,0 +1,18 @@
//
// OTPKit.h
// OTPKit
//
// Created by Shadowfacts on 8/21/21.
//
#import <Foundation/Foundation.h>
//! 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 <OTPKit/PublicHeader.h>

17
OTPKit/TOTPCode.swift Normal file
View File

@ -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))
}
}

110
OTPKit/TOTPKey.swift Normal file
View File

@ -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!
}
}

399
OTPKit/Vendor/Base32.swift vendored Normal file
View File

@ -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<Int8>.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<UInt8>
return string.utf8CString.withUnsafeBufferPointer {
(data: UnsafeBufferPointer<CChar>) -> [UInt8] in
var encoded = data.baseAddress!
var result = Array<UInt8>(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
}
}

View File

@ -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")
}
}

View File

@ -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.
}
}
}

View File

@ -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 }
)
}
}