├── .codecov.yml ├── .github ├── actions │ ├── prepare-simulator │ │ └── action.yml │ └── xcode-select │ │ └── action.yml └── workflows │ ├── carthage.yml │ ├── spm.yml │ └── xcode.yml ├── .gitignore ├── .gitmodules ├── .hound.yml ├── .mailmap ├── .swiftlint.yml ├── AUTHORS.txt ├── CHANGELOG.md ├── CONDUCT.md ├── CONTRIBUTING.md ├── Cartfile.private ├── Cartfile.resolved ├── Configuration ├── OneTimePassword-iOS.xcconfig ├── OneTimePassword-watchOS.xcconfig ├── OneTimePassword.xcconfig ├── OneTimePasswordTestApp.xcconfig ├── OneTimePasswordTests-iOS.xcconfig └── OneTimePasswordTests.xcconfig ├── LICENSE.md ├── OneTimePassword.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── OneTimePassword (iOS).xcscheme │ └── OneTimePassword (watchOS).xcscheme ├── Package.swift ├── README.md ├── Sources ├── Generator.swift ├── Info.plist ├── Keychain.swift ├── PersistentToken.swift ├── Token+URL.swift └── Token.swift └── Tests ├── App ├── AppDelegate.swift ├── Info.plist └── Launch Screen.storyboard ├── EquatableTests.swift ├── GeneratorTests.swift ├── Info.plist ├── KeychainTests.swift ├── TokenSerializationTests.swift └── TokenTests.swift /.codecov.yml: -------------------------------------------------------------------------------- 1 | # Configuration for Codecov (https://codecov.io) 2 | 3 | codecov: 4 | # Use `develop` as the default branch 5 | branch: develop 6 | 7 | ignore: 8 | - Tests 9 | 10 | coverage: 11 | status: 12 | patch: 13 | default: 14 | # Allow patch to be 0% covered without marking a PR with a failing status. 15 | target: 0 16 | -------------------------------------------------------------------------------- /.github/actions/prepare-simulator/action.yml: -------------------------------------------------------------------------------- 1 | name: "Prepare simulator" 2 | description: "Creates and boots a custom simulator" 3 | inputs: 4 | runtime: 5 | description: "Runtime name" 6 | required: true 7 | device: 8 | description: "Device name" 9 | required: true 10 | outputs: 11 | destination-id: 12 | description: "Destination simulator ID" 13 | value: ${{ steps.simulator.outputs.destination-id }} 14 | runs: 15 | using: composite 16 | steps: 17 | - name: "Install runtime" 18 | shell: bash 19 | run: | 20 | RUNTIME="${{ inputs.runtime }}" 21 | if xcrun simctl list | grep "$RUNTIME" 22 | then 23 | echo "$RUNTIME is already installed."; 24 | else 25 | echo "::group::Available runtimes:" 26 | xcodes runtimes 27 | echo "::endgroup::" 28 | sudo xcodes runtimes install "$RUNTIME" --keep-archive; 29 | fi 30 | 31 | - name: "Create and boot simulator" 32 | id: simulator 33 | shell: bash 34 | run: | 35 | RUNTIME="${{ inputs.runtime }}" 36 | DEVICE="${{ inputs.device }}" 37 | DEVICE_ID=com.apple.CoreSimulator.SimDeviceType.$(echo $DEVICE | sed -E -e "s/[ \-]+/ /g" -e "s/[\(\)]//g" -e "s/[^[:alnum:]]/-/g") 38 | RUNTIME_ID=com.apple.CoreSimulator.SimRuntime.$(echo $RUNTIME | sed -E -e "s/[ \-]+/ /g" -e "s/[\(\)]//g" -e "s/[^[:alnum:]]/-/g") 39 | DESTINATION_ID=$(xcrun simctl create "Custom: $DEVICE, $RUNTIME" $DEVICE_ID $RUNTIME_ID) 40 | xcrun simctl boot $DESTINATION_ID 41 | echo "destination-id=$(echo $DESTINATION_ID)" >> $GITHUB_OUTPUT 42 | -------------------------------------------------------------------------------- /.github/actions/xcode-select/action.yml: -------------------------------------------------------------------------------- 1 | name: "Select Xcode version" 2 | description: "Selects the specified version of Xcode" 3 | inputs: 4 | version: 5 | description: "Version number" 6 | required: true 7 | runs: 8 | using: composite 9 | steps: 10 | - run: | 11 | echo "::group::Selecting Xcode ${{ inputs.version }}…" 12 | sudo xcode-select -s /Applications/Xcode_${{ inputs.version }}.app 13 | xcode-select -p 14 | echo "::endgroup::" 15 | shell: bash 16 | - run: | 17 | echo "::group::xcodebuild -version -sdk" 18 | xcodebuild -version -sdk 19 | echo "::endgroup::" 20 | shell: bash 21 | - run: | 22 | echo "::group::xcrun simctl list" 23 | xcrun simctl list 24 | echo "::endgroup::" 25 | shell: bash 26 | -------------------------------------------------------------------------------- /.github/workflows/carthage.yml: -------------------------------------------------------------------------------- 1 | name: Carthage 2 | 3 | on: [push] 4 | 5 | jobs: 6 | carthage: 7 | name: "Xcode ${{ matrix.env.xcode }}" 8 | runs-on: macOS-14 9 | strategy: 10 | matrix: 11 | env: 12 | - xcode: 15.4 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | submodules: recursive 17 | - name: "Upgrade Carthage" 18 | run: brew upgrade carthage 19 | - name: "Select Xcode ${{ matrix.env.xcode }}" 20 | uses: ./.github/actions/xcode-select 21 | with: 22 | version: ${{ matrix.env.xcode }} 23 | - name: "Build" 24 | run: carthage build --no-skip-current --use-xcframeworks --no-use-binaries 25 | -------------------------------------------------------------------------------- /.github/workflows/spm.yml: -------------------------------------------------------------------------------- 1 | name: SPM 2 | 3 | on: [push] 4 | 5 | jobs: 6 | spm: 7 | name: "Xcode ${{ matrix.env.xcode }}" 8 | runs-on: macOS-14 9 | strategy: 10 | matrix: 11 | env: 12 | - xcode: 15.4 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: "Select Xcode ${{ matrix.env.xcode }}" 16 | uses: ./.github/actions/xcode-select 17 | with: 18 | version: ${{ matrix.env.xcode }} 19 | - run: swift test 20 | -------------------------------------------------------------------------------- /.github/workflows/xcode.yml: -------------------------------------------------------------------------------- 1 | name: Xcode 2 | 3 | on: [push] 4 | 5 | jobs: 6 | ios: 7 | name: "Xcode ${{ matrix.env.xcode }}, ${{ matrix.env.runtime }}, ${{ matrix.env.device }}" 8 | runs-on: macOS-14 9 | strategy: 10 | matrix: 11 | env: 12 | - xcode: 15.4 13 | runtime: "iOS 17.5" 14 | device: "iPhone 15 Pro Max" 15 | - xcode: 15.4 16 | runtime: "iOS 16.4" 17 | device: "iPhone 8" 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | submodules: recursive 22 | - name: "Select Xcode ${{ matrix.env.xcode }}" 23 | uses: ./.github/actions/xcode-select 24 | with: 25 | version: ${{ matrix.env.xcode }} 26 | - name: "Cache downloaded simulator runtimes" 27 | uses: actions/cache@v4 28 | with: 29 | path: ~/Downloads/*.dmg 30 | key: Xcode ${{ matrix.env.xcode }}+${{ matrix.env.runtime }} 31 | - name: "Prepare simulator" 32 | id: prepare-simulator 33 | uses: ./.github/actions/prepare-simulator 34 | with: 35 | runtime: ${{ matrix.env.runtime }} 36 | device: ${{ matrix.env.device }} 37 | - name: "Build and test" 38 | run: | 39 | set -o pipefail 40 | xcodebuild test -project "OneTimePassword.xcodeproj" -scheme "OneTimePassword (iOS)" -destination "id=${{ steps.prepare-simulator.outputs.destination-id }}" | xcpretty -c 41 | - uses: sersoft-gmbh/swift-coverage-action@v4 42 | with: 43 | target-name-filter: ^OneTimePassword$ 44 | - uses: codecov/codecov-action@v4 45 | with: 46 | token: ${{ secrets.CODECOV_TOKEN }} 47 | fail_ci_if_error: true 48 | 49 | watchos: 50 | name: "Xcode ${{ matrix.env.xcode }}, ${{ matrix.env.runtime }}, ${{ matrix.env.device }}" 51 | runs-on: macOS-14 52 | strategy: 53 | matrix: 54 | env: 55 | - xcode: 15.4 56 | runtime: "watchOS 10.5" 57 | device: "Apple Watch Ultra 2 (49mm)" 58 | - xcode: 15.4 59 | runtime: "watchOS 9.4" 60 | device: "Apple Watch Series 4 (40mm)" 61 | steps: 62 | - uses: actions/checkout@v4 63 | with: 64 | submodules: recursive 65 | - name: "Select Xcode ${{ matrix.env.xcode }}" 66 | uses: ./.github/actions/xcode-select 67 | with: 68 | version: ${{ matrix.env.xcode }} 69 | - name: "Cache downloaded simulator runtimes" 70 | uses: actions/cache@v4 71 | with: 72 | path: ~/Downloads/*.dmg 73 | key: Xcode ${{ matrix.env.xcode }}+${{ matrix.env.runtime }} 74 | - name: "Prepare simulator" 75 | id: prepare-simulator 76 | uses: ./.github/actions/prepare-simulator 77 | with: 78 | runtime: ${{ matrix.env.runtime }} 79 | device: ${{ matrix.env.device }} 80 | - name: "Build" 81 | run: | 82 | set -o pipefail 83 | xcodebuild build -project "OneTimePassword.xcodeproj" -scheme "OneTimePassword (watchOS)" -destination "id=${{ steps.prepare-simulator.outputs.destination-id }}" | xcpretty -c 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "Carthage/Checkouts/xcconfigs"] 2 | path = Carthage/Checkouts/xcconfigs 3 | url = https://github.com/xcconfigs/xcconfigs.git 4 | -------------------------------------------------------------------------------- /.hound.yml: -------------------------------------------------------------------------------- 1 | # Configuration for Hound (https://houndci.com) 2 | 3 | swiftlint: 4 | config_file: .swiftlint.yml 5 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # Configuration for SwiftLint (https://github.com/realm/SwiftLint) 2 | 3 | excluded: 4 | - .build 5 | - Carthage 6 | - Package.swift 7 | 8 | analyzer_rules: 9 | - capture_variable 10 | - unused_declaration 11 | - unused_import 12 | - typesafe_array_init 13 | 14 | # TODO: Re-enable these rules. 15 | disabled_rules: 16 | - cyclomatic_complexity 17 | - implicit_return 18 | - todo 19 | 20 | opt_in_rules: 21 | - array_init 22 | - attributes 23 | - closure_body_length 24 | - closure_end_indentation 25 | - closure_spacing 26 | - collection_alignment 27 | - comma_inheritance 28 | - conditional_returns_on_newline 29 | - contains_over_filter_count 30 | - contains_over_filter_is_empty 31 | - contains_over_first_not_nil 32 | - contains_over_range_nil_comparison 33 | - convenience_type 34 | - discouraged_assert 35 | - discouraged_none_name 36 | - discouraged_object_literal 37 | - discouraged_optional_boolean 38 | - discouraged_optional_collection 39 | - empty_collection_literal 40 | - empty_count 41 | - empty_string 42 | - empty_xctest_method 43 | - enum_case_associated_values_count 44 | - explicit_enum_raw_value 45 | - explicit_init 46 | - extension_access_modifier 47 | - fallthrough 48 | - fatal_error_message 49 | - file_header 50 | - file_name 51 | - file_name_no_space 52 | - first_where 53 | - flatmap_over_map_reduce 54 | - identical_operands 55 | - implicit_return 56 | - implicitly_unwrapped_optional 57 | - joined_default_parameter 58 | - last_where 59 | - legacy_multiple 60 | - let_var_whitespace 61 | - literal_expression_end_indentation 62 | - local_doc_comment 63 | - lower_acl_than_parent 64 | - modifier_order 65 | - multiline_function_chains 66 | - multiline_literal_brackets 67 | - multiline_parameters 68 | - nimble_operator 69 | - nslocalizedstring_key 70 | - nslocalizedstring_require_bundle 71 | - operator_usage_whitespace 72 | - optional_enum_case_matching 73 | - overridden_super_call 74 | - override_in_extension 75 | - pattern_matching_keywords 76 | - prefer_self_type_over_type_of_self 77 | - prefer_zero_over_explicit_init 78 | - private_action 79 | - private_outlet 80 | - prohibited_interface_builder 81 | - prohibited_super_call 82 | - raw_value_for_camel_cased_codable_enum 83 | - reduce_into 84 | - redundant_nil_coalescing 85 | - return_value_from_void_function 86 | - self_binding 87 | - shorthand_optional_binding 88 | - single_test_class 89 | - sorted_first_last 90 | - static_operator 91 | - switch_case_on_newline 92 | - toggle_bool 93 | - unavailable_function 94 | - unneeded_parentheses_in_closure_argument 95 | - unowned_variable_capture 96 | - untyped_error_in_catch 97 | - vertical_parameter_alignment_on_call 98 | - vertical_whitespace_closing_braces 99 | - vertical_whitespace_opening_braces 100 | - weak_delegate 101 | - xct_specific_matcher 102 | - yoda_condition 103 | 104 | colon: 105 | flexible_right_spacing: true 106 | 107 | trailing_comma: 108 | mandatory_comma: true 109 | 110 | line_length: 111 | ignores_function_declarations: true 112 | 113 | file_header: 114 | required_pattern: | 115 | \/\/ 116 | \/\/ (.+).swift 117 | \/\/ OneTimePassword 118 | \/\/ 119 | \/\/ Copyright \(c\) (\d{4}|\d{4}-\d{4}) Matt Rubin and the OneTimePassword authors 120 | \/\/ 121 | \/\/ Permission is hereby granted, free of charge, to any person obtaining a copy 122 | \/\/ of this software and associated documentation files \(the "Software"\), to deal 123 | \/\/ in the Software without restriction, including without limitation the rights 124 | \/\/ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 125 | \/\/ copies of the Software, and to permit persons to whom the Software is 126 | \/\/ furnished to do so, subject to the following conditions: 127 | \/\/ 128 | \/\/ The above copyright notice and this permission notice shall be included in all 129 | \/\/ copies or substantial portions of the Software. 130 | \/\/ 131 | \/\/ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 132 | \/\/ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 133 | \/\/ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 134 | \/\/ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 135 | \/\/ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 136 | \/\/ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 137 | \/\/ SOFTWARE. 138 | \/\/ 139 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | Matt Rubin 2 | Google Inc. <*@google.com> 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # OneTimePassword Changelog 2 | 3 | ## [In development][develop] 4 | 5 | - Drop support for Swift 4.2 6 | ([#229](https://github.com/mattrubin/OneTimePassword/pull/229)) 7 | - Drop CocoaPods support 8 | ([#249](https://github.com/mattrubin/OneTimePassword/pull/249)) 9 | - Add SPM support 10 | ([#221](https://github.com/mattrubin/OneTimePassword/pull/221)) 11 | - Bump deployment targets to iOS 13.0, macOS 10.15, and watchOS 6.0 12 | ([#245](https://github.com/mattrubin/OneTimePassword/pull/245)) 13 | - Replace optional initializers with throwing initializers 14 | ([#254](https://github.com/mattrubin/OneTimePassword/pull/254)) 15 | - Use CryptoKit instead of CommonCrypto for HMAC generation 16 | ([#214](https://github.com/mattrubin/OneTimePassword/pull/214), 17 | [#230](https://github.com/mattrubin/OneTimePassword/pull/230), 18 | [#244](https://github.com/mattrubin/OneTimePassword/pull/244), 19 | [#245](https://github.com/mattrubin/OneTimePassword/pull/245), 20 | [#256](https://github.com/mattrubin/OneTimePassword/pull/256)) 21 | - Modernize project and tooling configurations 22 | ([#228](https://github.com/mattrubin/OneTimePassword/pull/228), 23 | [#252](https://github.com/mattrubin/OneTimePassword/pull/252), 24 | [#253](https://github.com/mattrubin/OneTimePassword/pull/253)) 25 | - Migrate CI from Travis to GitHub Actions 26 | ([#231](https://github.com/mattrubin/OneTimePassword/pull/231), 27 | [#232](https://github.com/mattrubin/OneTimePassword/pull/232), 28 | [#243](https://github.com/mattrubin/OneTimePassword/pull/243), 29 | [#246](https://github.com/mattrubin/OneTimePassword/pull/246), 30 | [#251](https://github.com/mattrubin/OneTimePassword/pull/251)) 31 | - Upgrade dependencies 32 | ([#241](https://github.com/mattrubin/OneTimePassword/pull/241), 33 | [#233](https://github.com/mattrubin/OneTimePassword/pull/233), 34 | [#248](https://github.com/mattrubin/OneTimePassword/pull/248)) 35 | 36 | ## [3.2.0][] (2019-09-20) 37 | 38 | - Upgrade the source to compile with both Swift 4.2 and Swift 5. 39 | ([#201](https://github.com/mattrubin/OneTimePassword/pull/201), 40 | [#202](https://github.com/mattrubin/OneTimePassword/pull/202), 41 | [#204](https://github.com/mattrubin/OneTimePassword/pull/204), 42 | [#209](https://github.com/mattrubin/OneTimePassword/pull/209), 43 | [#215](https://github.com/mattrubin/OneTimePassword/pull/215), 44 | [#216](https://github.com/mattrubin/OneTimePassword/pull/216)) 45 | - Update the SwiftLint configuration, and move the SwiftLint build phase to a separate dedicated target so that new lint errors do not interfere with consumers of the framework. 46 | ([#212](https://github.com/mattrubin/OneTimePassword/pull/212), 47 | [#206](https://github.com/mattrubin/OneTimePassword/pull/206)) 48 | - Upgrade xcconfigs to enable new warnings introduced in Xcode 10.2 49 | ([#203](https://github.com/mattrubin/OneTimePassword/pull/203)) 50 | 51 | ## [3.1.5][] (2019-04-11) 52 | - Enable additional linting and CI testing. 53 | ([#196](https://github.com/mattrubin/OneTimePassword/pull/196), 54 | [#192](https://github.com/mattrubin/OneTimePassword/pull/192)) 55 | - Satisfy various project-level warnings introduced in Xcode 10.2. 56 | ([#198](https://github.com/mattrubin/OneTimePassword/pull/198)) 57 | 58 | ## [3.1.4][] (2018-09-15) 59 | - Fix compilation errors and add CI testing for Xcode 10. 60 | ([#182](https://github.com/mattrubin/OneTimePassword/pull/182), 61 | [#186](https://github.com/mattrubin/OneTimePassword/pull/186)) 62 | - Enable several new SwiftLint opt-in rules. ([#187](https://github.com/mattrubin/OneTimePassword/pull/187)) 63 | 64 | 65 | ## [3.1.3][] (2018-04-29) 66 | - Ignore un-deserializable tokens in `allPersistentTokens()`. ([#179](https://github.com/mattrubin/OneTimePassword/pull/179)) 67 | 68 | 69 | ## [3.1.2][] (2018-04-23) 70 | - Synthesize Equatable conformance when compiling with Swift 4.1. ([#173](https://github.com/mattrubin/OneTimePassword/pull/173)) 71 | - Fix a warning about deprecation of cross-module struct initializers by simplifying test cases for impossible-to-create invalid Generators. ([#174](https://github.com/mattrubin/OneTimePassword/pull/174)) 72 | - Upgrade xcconfigs for Xcode 9.3. ([#172](https://github.com/mattrubin/OneTimePassword/pull/172)) 73 | - Enable several new SwiftLint opt-in rules. ([#175](https://github.com/mattrubin/OneTimePassword/pull/175)) 74 | 75 | 76 | ## [3.1.1][] (2018-03-31) 77 | - Add support for Swift 4.1. ([#168](https://github.com/mattrubin/OneTimePassword/pull/168)) 78 | - Update build and linter settings for Xcode 9.3. ([#167](https://github.com/mattrubin/OneTimePassword/pull/167)) 79 | 80 | 81 | ## [3.1][] (2018-03-27) 82 | - Upgrade to Swift 4 and Xcode 9. 83 | ([#147](https://github.com/mattrubin/OneTimePassword/pull/147), 84 | [#149](https://github.com/mattrubin/OneTimePassword/pull/149), 85 | [#151](https://github.com/mattrubin/OneTimePassword/pull/151), 86 | [#153](https://github.com/mattrubin/OneTimePassword/pull/153), 87 | [#160](https://github.com/mattrubin/OneTimePassword/pull/160)) 88 | - Handle keychain deserialization errors. 89 | ([#161](https://github.com/mattrubin/OneTimePassword/pull/161)) 90 | - Refactor token URL parsing. 91 | ([#150](https://github.com/mattrubin/OneTimePassword/pull/150)) 92 | - Refactor Generator validation. 93 | ([#155](https://github.com/mattrubin/OneTimePassword/pull/155)) 94 | - Update SwiftLint configuration and improve code formatting. 95 | ([#148](https://github.com/mattrubin/OneTimePassword/pull/148), 96 | [#154](https://github.com/mattrubin/OneTimePassword/pull/154), 97 | [#159](https://github.com/mattrubin/OneTimePassword/pull/159)) 98 | - Update CodeCov configuration. 99 | ([#162](https://github.com/mattrubin/OneTimePassword/pull/162)) 100 | 101 | 102 | ## [3.0.1][] (2018-03-08) 103 | - Fix an issue where CocoaPods was trying to build OneTimePassword with Swift 4. ([#157](https://github.com/mattrubin/OneTimePassword/pull/157)) 104 | - Fix the Base32-decoding function in the token creation example code. ([#134](https://github.com/mattrubin/OneTimePassword/pull/134)) 105 | - Tweak the Travis CI configuration to work around test timeout flakiness. ([#131](https://github.com/mattrubin/OneTimePassword/pull/131)) 106 | - Clean up some old Keychain code which was used to provide Xcode 7 backwards compatibility. ([#133](https://github.com/mattrubin/OneTimePassword/pull/133)) 107 | 108 | 109 | ## [3.0][] (2017-02-07) 110 | - Convert to Swift 3 and update the library API to follow the Swift [API Design Guidelines](https://swift.org/documentation/api-design-guidelines/). 111 | ([#74](https://github.com/mattrubin/OneTimePassword/pull/74), 112 | [#78](https://github.com/mattrubin/OneTimePassword/pull/78), 113 | [#80](https://github.com/mattrubin/OneTimePassword/pull/80), 114 | [#91](https://github.com/mattrubin/OneTimePassword/pull/91), 115 | [#100](https://github.com/mattrubin/OneTimePassword/pull/100), 116 | [#111](https://github.com/mattrubin/OneTimePassword/pull/111), 117 | [#113](https://github.com/mattrubin/OneTimePassword/pull/113), 118 | [#122](https://github.com/mattrubin/OneTimePassword/pull/122), 119 | [#123](https://github.com/mattrubin/OneTimePassword/pull/123), 120 | [#125](https://github.com/mattrubin/OneTimePassword/pull/125)) 121 | - Convert `password(at:)` to take a `Date` instead of a `TimeInterval`. ([#124](https://github.com/mattrubin/OneTimePassword/pull/124)) 122 | - Update the SwiftLint configuration. ([#120](https://github.com/mattrubin/OneTimePassword/pull/120)) 123 | 124 | 125 | ## [2.1.1][] (2016-12-28) 126 | - Configure Travis to build and test with Xcode 8.2. ([#115](https://github.com/mattrubin/OneTimePassword/pull/115)) 127 | - Add a test host app to enable keychain tests on iOS 10. ([#116](https://github.com/mattrubin/OneTimePassword/pull/116)) 128 | - Update the SwiftLint configuration for SwiftLint 0.15. ([#117](https://github.com/mattrubin/OneTimePassword/pull/117)) 129 | 130 | 131 | ## [2.1][] (2016-11-16) 132 | #### Enhancements 133 | - Add watchOS support. ([#96](https://github.com/mattrubin/OneTimePassword/pull/96), [#98](https://github.com/mattrubin/OneTimePassword/pull/98), [#107](https://github.com/mattrubin/OneTimePassword/pull/107)) 134 | - Inject Xcode path into the CommonCrypto modulemaps, to support non-standard Xcode locations. ([#92](https://github.com/mattrubin/OneTimePassword/pull/92), [#101](https://github.com/mattrubin/OneTimePassword/pull/101)) 135 | 136 | #### Other Changes 137 | - Clean up project configuration and build settings. ([#95](https://github.com/mattrubin/OneTimePassword/pull/95), [#97](https://github.com/mattrubin/OneTimePassword/pull/97)) 138 | - Improve instructions and project settings to make setting up a new clone easier. ([#104](https://github.com/mattrubin/OneTimePassword/pull/104)) 139 | - Add tests for validation and parsing failures. ([#105](https://github.com/mattrubin/OneTimePassword/pull/105)) 140 | - Improve documentation comments. ([#108](https://github.com/mattrubin/OneTimePassword/pull/108)) 141 | - Refactor the Keychain tests to remove excessive nesting. ([#109](https://github.com/mattrubin/OneTimePassword/pull/109)) 142 | 143 | 144 | ## [2.0.1][] (2016-09-20) 145 | #### Enhancements 146 | - Update the project to support Xcode 8 and Swift 2.3. ([#73](https://github.com/mattrubin/OneTimePassword/pull/73), [#75](https://github.com/mattrubin/OneTimePassword/pull/75), [#84](https://github.com/mattrubin/OneTimePassword/pull/84)) 147 | 148 | #### Fixes 149 | - Disable broken keychain tests on iOS 10. ([#77](https://github.com/mattrubin/OneTimePassword/pull/77), [#88](https://github.com/mattrubin/OneTimePassword/pull/88)) 150 | 151 | #### Other Changes 152 | - Update badge images and links in the README. ([#69](https://github.com/mattrubin/OneTimePassword/pull/69)) 153 | - Reorganize source and test files following the conventions the Swift Package Manager. ([#70](https://github.com/mattrubin/OneTimePassword/pull/70)) 154 | - Isolate the CommonCrypto dependency inside a custom wrapper function. ([#71](https://github.com/mattrubin/OneTimePassword/pull/71)) 155 | - Clean up whitespace. ([#79](https://github.com/mattrubin/OneTimePassword/pull/79)) 156 | - Integrate with codecov.io for automated code coverage reporting. ([#82](https://github.com/mattrubin/OneTimePassword/pull/82)) 157 | - Update SwiftLint configuration. ([#87](https://github.com/mattrubin/OneTimePassword/pull/87)) 158 | - Update Travis configuration to use Xcode 8. ([#89](https://github.com/mattrubin/OneTimePassword/pull/89)) 159 | 160 | 161 | ## [2.0.0][] (2016-02-07) 162 | 163 | Version 2 of the OneTimePassword library has been completely redesigned and rewritten with a modern Swift API. The new library source differs too greatly from its predecessor for the changes to be representable in a changelog. The README has a usage guide for the new API. 164 | 165 | Additional changes of note: 166 | - The library is well-tested and the source fully documented. 167 | - Carthage is used to manage dependencies, which are checked in as Git submodules. 168 | - Travis CI is used for testing, and Hound CI for linting. 169 | - The project now has a detailed README, as well as a changelog, guidelines for contributing, and a code of conduct. 170 | 171 | Changes between prerelease versions of OneTimePassword version 2 can be found below. 172 | 173 | ### [2.0.0-rc][] (2016-02-07) 174 | - Update `Token` tests for full test coverage. (#66) 175 | - Add installation and usage instructions to the README. (#63, #65, #67) 176 | - Upgrade the Travis build configuration to use Xcode 7.2 and iOS 9.2. (#66) 177 | - Add a README file to the CommonCrypto folder to explain the custom modulemaps. (#64) 178 | - Assorted cleanup and formatting improvements. (#61, #62) 179 | 180 | ### [2.0.0-beta.5][] (2016-02-05) 181 | - Use custom `modulemap`s to link CommonCrypto, removing external dependency on `soffes/Crypto` (#57) 182 | - Make `jspahrsummers/xcconfigs` a private dependency. (#58) 183 | - Update `OneTimePassword.podspec` to build the new framework. (#59) 184 | 185 | ### [2.0.0-beta.4][] (2016-02-04) 186 | - Refactor and document new Swift framework 187 | - Remove legacy Objective-C framework 188 | - Add framework dependency for CommonCrypto 189 | - Improve framework tests 190 | 191 | ### [2.0.0-beta.3][] (2015-02-03) 192 | - Add documentation comments to `Token` and `Generator` 193 | - Add static constants for default values 194 | - Remove useless convenience initializer from `OTPToken` 195 | 196 | ### [2.0.0-beta.2][] (2015-02-01) 197 | - Fix compatibility issues in OneTimePasswordLegacy 198 | - Build and link dependencies directly, instead of relying on a pre-built framework 199 | 200 | ### [2.0.0-beta.1][] (2015-02-01) 201 | - Refactor `OTPToken` to prevent issues when creating invalid tokens 202 | - Improve testing of legacy tokens with invalid timer period or digits 203 | - Turn off Swift optimizations in Release builds (to avoid a keychain access issue) 204 | 205 | ### [2.0.0-beta][] (2015-01-26) 206 | - Convert the library to Swift and compile it into a Framework bundle for iOS 8+. 207 | - Replace CocoaPods with Carthage for dependency management. 208 | 209 | ## [1.1.1][] (2015-12-05) 210 | - Bump deployment target to iOS 8 (the framework product was already unsupported on iOS 7) (#46, #48) 211 | - Replace custom query string parsing and escaping with iOS 8's `NSURLQueryItem`. (#47) 212 | - Add `.gitignore` (#46) 213 | - Configure for Travis CI, adding `.travis.yml` and sharing `OneTimePassword.xcscheme` (#46) 214 | - Update `Podfile` and check in the CocoaPods dependencies. (#46) 215 | - Update `.xcodeproj` version and specify project defaults for indentation and prefixing. (#49) 216 | - Add `README` with project description and a note linking to the latest version of the project (#50) 217 | 218 | ## [1.1.0][] (2014-07-23) 219 | 220 | ## [1.0.0][] (2014-07-17) 221 | 222 | [develop]: https://github.com/mattrubin/OneTimePassword/compare/3.2.0...develop 223 | 224 | [3.2.0]: https://github.com/mattrubin/OneTimePassword/compare/3.1.5...3.2.0 225 | [3.1.5]: https://github.com/mattrubin/OneTimePassword/compare/3.1.4...3.1.5 226 | [3.1.4]: https://github.com/mattrubin/OneTimePassword/compare/3.1.3...3.1.4 227 | [3.1.3]: https://github.com/mattrubin/OneTimePassword/compare/3.1.2...3.1.3 228 | [3.1.2]: https://github.com/mattrubin/OneTimePassword/compare/3.1.1...3.1.2 229 | [3.1.1]: https://github.com/mattrubin/OneTimePassword/compare/3.1...3.1.1 230 | [3.1]: https://github.com/mattrubin/OneTimePassword/compare/3.0.1...3.1 231 | [3.0.1]: https://github.com/mattrubin/OneTimePassword/compare/3.0...3.0.1 232 | [3.0]: https://github.com/mattrubin/OneTimePassword/compare/2.1.1...3.0 233 | [2.1.1]: https://github.com/mattrubin/OneTimePassword/compare/2.1...2.1.1 234 | [2.1]: https://github.com/mattrubin/OneTimePassword/compare/2.0.1...2.1 235 | [2.0.1]: https://github.com/mattrubin/OneTimePassword/compare/2.0.0...2.0.1 236 | [2.0.0]: https://github.com/mattrubin/OneTimePassword/compare/1.1.0...2.0.0 237 | [2.0.0-rc]: https://github.com/mattrubin/OneTimePassword/compare/2.0.0-beta.5...2.0.0 238 | [2.0.0-beta.5]: https://github.com/mattrubin/OneTimePassword/compare/2.0.0-beta.4...2.0.0-beta.5 239 | [2.0.0-beta.4]: https://github.com/mattrubin/OneTimePassword/compare/2.0.0-beta.3...2.0.0-beta.4 240 | [2.0.0-beta.3]: https://github.com/mattrubin/OneTimePassword/compare/2.0.0-beta.2...2.0.0-beta.3 241 | [2.0.0-beta.2]: https://github.com/mattrubin/OneTimePassword/compare/2.0.0-beta.1...2.0.0-beta.2 242 | [2.0.0-beta.1]: https://github.com/mattrubin/OneTimePassword/compare/2.0.0-beta...2.0.0-beta.1 243 | [2.0.0-beta]: https://github.com/mattrubin/OneTimePassword/compare/1.1.1...2.0.0-beta 244 | [1.1.1]: https://github.com/mattrubin/OneTimePassword/compare/1.1.0...1.1.1 245 | [1.1.0]: https://github.com/mattrubin/OneTimePassword/compare/1.0.0...1.1.0 246 | [1.0.0]: https://github.com/mattrubin/OneTimePassword/tree/1.0.0 247 | -------------------------------------------------------------------------------- /CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at codeofconduct@mattrubin.me. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | **Pull requests are welcome!** 4 | 5 | If you encounter a problem with OneTimePassword, feel free to [open an issue][issues]. If you know how to fix the bug or implement the desired feature, a pull request is even better. 6 | 7 | A great pull request: 8 | - Follows the coding style and conventions of the project. 9 | - Adds tests to cover the added functionality or fixed bug. 10 | - Is accompanied by a clear explanation of its purpose. 11 | - Remains as simple as possible while achieving its intended goal. 12 | 13 | Please note that this project is released with a [Contributor Code of Conduct][conduct]. By participating in this project you agree to abide by its terms. 14 | 15 | [issues]: https://github.com/mattrubin/OneTimePassword/issues 16 | [conduct]: CONDUCT.md 17 | 18 | 19 | ## Getting Started 20 | 21 | 1. Check out the latest version of the project: 22 | ``` 23 | git clone https://github.com/mattrubin/OneTimePassword.git 24 | ``` 25 | 26 | 2. In the OneTimePassword directory, check out the project's dependencies: 27 | ``` 28 | cd OneTimePassword 29 | git submodule update --init --recursive 30 | ``` 31 | 32 | 3. Open the `OneTimePassword.xcodeproj` file. 33 | 34 | 4. Build and run the "OneTimePassword" scheme. 35 | 36 | 37 | ## Managing Dependencies 38 | 39 | OneTimePassword's source dependencies are [Xcode-managed package dependencies][package dependencies]. 40 | 41 | Additionally, [Carthage][] is used to manage the project's dependency on shared build configuration files. The dependent project is checked out as a submodule. 42 | 43 | To check out the dependencies, simply follow the "Getting Started" instructions above. 44 | 45 | To update the dependencies, modify the [Cartfile][] and run: 46 | ``` 47 | carthage update --no-build --use-submodules 48 | ``` 49 | 50 | [package dependencies]: https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app 51 | [Carthage]: https://github.com/Carthage/Carthage 52 | [Cartfile]: https://github.com/mattrubin/OneTimePassword/blob/develop/Cartfile 53 | -------------------------------------------------------------------------------- /Cartfile.private: -------------------------------------------------------------------------------- 1 | # Configuration for Carthage (https://github.com/Carthage/Carthage) 2 | 3 | github "xcconfigs/xcconfigs" ~> 1.0 4 | -------------------------------------------------------------------------------- /Cartfile.resolved: -------------------------------------------------------------------------------- 1 | github "xcconfigs/xcconfigs" "1.1" 2 | -------------------------------------------------------------------------------- /Configuration/OneTimePassword-iOS.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../Carthage/Checkouts/xcconfigs/iOS/iOS-Framework.xcconfig" 2 | #include "OneTimePassword.xcconfig" 3 | -------------------------------------------------------------------------------- /Configuration/OneTimePassword-watchOS.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../Carthage/Checkouts/xcconfigs/watchOS/watchOS-Framework.xcconfig" 2 | #include "OneTimePassword.xcconfig" 3 | -------------------------------------------------------------------------------- /Configuration/OneTimePassword.xcconfig: -------------------------------------------------------------------------------- 1 | PRODUCT_NAME = OneTimePassword; 2 | PRODUCT_BUNDLE_IDENTIFIER = me.mattrubin.onetimepassword; 3 | INFOPLIST_FILE = Sources/Info.plist; 4 | 5 | DEAD_CODE_STRIPPING = YES; 6 | SWIFT_INSTALL_OBJC_HEADER = NO; 7 | DEFINES_MODULE = NO; 8 | -------------------------------------------------------------------------------- /Configuration/OneTimePasswordTestApp.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../Carthage/Checkouts/xcconfigs/iOS/iOS-Application.xcconfig" 2 | 3 | PRODUCT_NAME = OneTimePasswordTestApp 4 | PRODUCT_BUNDLE_IDENTIFIER = me.mattrubin.onetimepassword.test-app 5 | INFOPLIST_FILE = $(SRCROOT)/Tests/App/Info.plist 6 | 7 | DEAD_CODE_STRIPPING = YES; 8 | -------------------------------------------------------------------------------- /Configuration/OneTimePasswordTests-iOS.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../Carthage/Checkouts/xcconfigs/iOS/iOS-Application.xcconfig" 2 | #include "OneTimePasswordTests.xcconfig" 3 | 4 | TEST_HOST = $(BUILT_PRODUCTS_DIR)/OneTimePasswordTestApp.app/OneTimePasswordTestApp 5 | BUNDLE_LOADER = $(TEST_HOST) 6 | -------------------------------------------------------------------------------- /Configuration/OneTimePasswordTests.xcconfig: -------------------------------------------------------------------------------- 1 | PRODUCT_NAME = OneTimePasswordTests; 2 | PRODUCT_BUNDLE_IDENTIFIER = me.mattrubin.onetimepassword.tests; 3 | INFOPLIST_FILE = Tests/Info.plist; 4 | 5 | DEAD_CODE_STRIPPING = YES; 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2021 Matt Rubin and the [OneTimePassword authors](AUTHORS.txt) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /OneTimePassword.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXAggregateTarget section */ 10 | C9425DE4227501F500EF93BD /* Lint OneTimePassword */ = { 11 | isa = PBXAggregateTarget; 12 | buildConfigurationList = C9425DE7227501F500EF93BD /* Build configuration list for PBXAggregateTarget "Lint OneTimePassword" */; 13 | buildPhases = ( 14 | C97CDF2E1BEFB20000D64406 /* Run SwiftLint */, 15 | ); 16 | dependencies = ( 17 | ); 18 | name = "Lint OneTimePassword"; 19 | productName = "Lint OneTimePassword"; 20 | }; 21 | /* End PBXAggregateTarget section */ 22 | 23 | /* Begin PBXBuildFile section */ 24 | 5B39F49C1DBD06EB00CD2DAB /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = C93A2519196B1BA400F86892 /* Token.swift */; }; 25 | 5B39F49D1DBD06EE00CD2DAB /* Generator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9DC7EC7196BDF3B00B50C82 /* Generator.swift */; }; 26 | 5B39F49F1DBD06F500CD2DAB /* Token+URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9DC7EC3196BD5DF00B50C82 /* Token+URL.swift */; }; 27 | 5B39F4A01DBD06F900CD2DAB /* PersistentToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = C95F9FB81C03D6BC00CEA286 /* PersistentToken.swift */; }; 28 | 5B39F4A11DBD06FC00CD2DAB /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9003417196F7046009733E8 /* Keychain.swift */; }; 29 | C9003418196F7046009733E8 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9003417196F7046009733E8 /* Keychain.swift */; }; 30 | C9290C301947D104008AE4DE /* TokenSerializationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9290C2F1947D104008AE4DE /* TokenSerializationTests.swift */; }; 31 | C93A251A196B1BA400F86892 /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = C93A2519196B1BA400F86892 /* Token.swift */; }; 32 | C94B2007197774A20014A202 /* TokenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94B2006197774A20014A202 /* TokenTests.swift */; }; 33 | C95B10CC196D22B9000840AA /* GeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C95B10CB196D22B9000840AA /* GeneratorTests.swift */; }; 34 | C95F9FB91C03D6BC00CEA286 /* PersistentToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = C95F9FB81C03D6BC00CEA286 /* PersistentToken.swift */; }; 35 | C97C82441946E51D00FD9F4C /* OneTimePassword.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C97C82381946E51D00FD9F4C /* OneTimePassword.framework */; }; 36 | C99262CC29558ABA00C96BDF /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = C99262CB29558ABA00C96BDF /* Base32 */; }; 37 | C99262CE29558AE600C96BDF /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = C99262CD29558AE600C96BDF /* Base32 */; }; 38 | C9B2A19C199A7F1B00BC4A8A /* EquatableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9B2A19B199A7F1B00BC4A8A /* EquatableTests.swift */; }; 39 | C9B77D771C03078B00BAF6BF /* KeychainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C93A2514196AFE1100F86892 /* KeychainTests.swift */; }; 40 | C9DC7EC4196BD5DF00B50C82 /* Token+URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9DC7EC3196BD5DF00B50C82 /* Token+URL.swift */; }; 41 | C9DC7EC8196BDF3B00B50C82 /* Generator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9DC7EC7196BDF3B00B50C82 /* Generator.swift */; }; 42 | FD6C3C0F1E0200F800EC4528 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6C3C0E1E0200F800EC4528 /* AppDelegate.swift */; }; 43 | FD6C3C341E02033600EC4528 /* OneTimePassword.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C97C82381946E51D00FD9F4C /* OneTimePassword.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 44 | FDA64C751E020ABF004AD993 /* Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FDA64C741E020ABF004AD993 /* Launch Screen.storyboard */; }; 45 | /* End PBXBuildFile section */ 46 | 47 | /* Begin PBXContainerItemProxy section */ 48 | C97C82451946E51D00FD9F4C /* PBXContainerItemProxy */ = { 49 | isa = PBXContainerItemProxy; 50 | containerPortal = C97C822F1946E51D00FD9F4C /* Project object */; 51 | proxyType = 1; 52 | remoteGlobalIDString = C97C82371946E51D00FD9F4C; 53 | remoteInfo = "OneTimePassword (iOS)"; 54 | }; 55 | FD6C3C201E0200F900EC4528 /* PBXContainerItemProxy */ = { 56 | isa = PBXContainerItemProxy; 57 | containerPortal = C97C822F1946E51D00FD9F4C /* Project object */; 58 | proxyType = 1; 59 | remoteGlobalIDString = FD6C3C0B1E0200F800EC4528; 60 | remoteInfo = OneTimePasswordTestApp; 61 | }; 62 | FD6C3C351E02033600EC4528 /* PBXContainerItemProxy */ = { 63 | isa = PBXContainerItemProxy; 64 | containerPortal = C97C822F1946E51D00FD9F4C /* Project object */; 65 | proxyType = 1; 66 | remoteGlobalIDString = C97C82371946E51D00FD9F4C; 67 | remoteInfo = "OneTimePassword (iOS)"; 68 | }; 69 | /* End PBXContainerItemProxy section */ 70 | 71 | /* Begin PBXCopyFilesBuildPhase section */ 72 | FD6C3C371E02033700EC4528 /* Embed Frameworks */ = { 73 | isa = PBXCopyFilesBuildPhase; 74 | buildActionMask = 2147483647; 75 | dstPath = ""; 76 | dstSubfolderSpec = 10; 77 | files = ( 78 | FD6C3C341E02033600EC4528 /* OneTimePassword.framework in Embed Frameworks */, 79 | ); 80 | name = "Embed Frameworks"; 81 | runOnlyForDeploymentPostprocessing = 0; 82 | }; 83 | /* End PBXCopyFilesBuildPhase section */ 84 | 85 | /* Begin PBXFileReference section */ 86 | 49403AEA23D3778400539BD3 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; 87 | 5B39F4941DBD06BA00CD2DAB /* OneTimePassword.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneTimePassword.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 88 | C9003417196F7046009733E8 /* Keychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; 89 | C9290C2F1947D104008AE4DE /* TokenSerializationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TokenSerializationTests.swift; sourceTree = ""; }; 90 | C93A2514196AFE1100F86892 /* KeychainTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainTests.swift; sourceTree = ""; }; 91 | C93A2519196B1BA400F86892 /* Token.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = ""; }; 92 | C93CC01A1DCBB755006255FA /* OneTimePassword-iOS.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "OneTimePassword-iOS.xcconfig"; sourceTree = ""; }; 93 | C93CC01B1DCBB7FB006255FA /* OneTimePassword-watchOS.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "OneTimePassword-watchOS.xcconfig"; sourceTree = ""; }; 94 | C93CC01C1DCBB875006255FA /* OneTimePasswordTests-iOS.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "OneTimePasswordTests-iOS.xcconfig"; sourceTree = ""; }; 95 | C93CC01E1DCBBDE7006255FA /* OneTimePassword.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = OneTimePassword.xcconfig; sourceTree = ""; }; 96 | C93CC0211DCBC189006255FA /* OneTimePasswordTests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = OneTimePasswordTests.xcconfig; sourceTree = ""; }; 97 | C94765061C64587800C7527E /* Cartfile.private */ = {isa = PBXFileReference; lastKnownFileType = text; path = Cartfile.private; sourceTree = ""; }; 98 | C94B2006197774A20014A202 /* TokenTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TokenTests.swift; sourceTree = ""; }; 99 | C94B9BC81BD7270E0073D7C5 /* LICENSE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = LICENSE.md; sourceTree = ""; }; 100 | C94B9BC91BD727150073D7C5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 101 | C94B9BCA1BD7271E0073D7C5 /* AUTHORS.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = AUTHORS.txt; sourceTree = ""; }; 102 | C95B10CB196D22B9000840AA /* GeneratorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratorTests.swift; sourceTree = ""; }; 103 | C95F9FB81C03D6BC00CEA286 /* PersistentToken.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersistentToken.swift; sourceTree = ""; }; 104 | C9619C5C1D73FB3500757587 /* .codecov.yml */ = {isa = PBXFileReference; lastKnownFileType = text; path = .codecov.yml; sourceTree = ""; }; 105 | C97C82381946E51D00FD9F4C /* OneTimePassword.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneTimePassword.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 106 | C97C823C1946E51D00FD9F4C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 107 | C97C82431946E51D00FD9F4C /* OneTimePasswordTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneTimePasswordTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 108 | C97C82491946E51D00FD9F4C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 109 | C996EC2C1A74D5830076B105 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Configurations/Debug.xcconfig; sourceTree = ""; }; 110 | C996EC2D1A74D5830076B105 /* Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Profile.xcconfig; path = Configurations/Profile.xcconfig; sourceTree = ""; }; 111 | C996EC2E1A74D5830076B105 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Configurations/Release.xcconfig; sourceTree = ""; }; 112 | C996EC2F1A74D5830076B105 /* Test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Test.xcconfig; path = Configurations/Test.xcconfig; sourceTree = ""; }; 113 | C9B2A19B199A7F1B00BC4A8A /* EquatableTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EquatableTests.swift; sourceTree = ""; }; 114 | C9B84D1C1C015EC0002EE631 /* .hound.yml */ = {isa = PBXFileReference; indentWidth = 2; lastKnownFileType = text; path = .hound.yml; sourceTree = ""; }; 115 | C9B84D1D1C015EC0002EE631 /* .swiftlint.yml */ = {isa = PBXFileReference; indentWidth = 2; lastKnownFileType = text; path = .swiftlint.yml; sourceTree = ""; }; 116 | C9DC7EC3196BD5DF00B50C82 /* Token+URL.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Token+URL.swift"; sourceTree = ""; }; 117 | C9DC7EC7196BDF3B00B50C82 /* Generator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Generator.swift; sourceTree = ""; }; 118 | C9E829531C62DFDA003F5FC9 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; 119 | C9E829541C62FFBD003F5FC9 /* CONTRIBUTING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; 120 | C9E829551C630514003F5FC9 /* CONDUCT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CONDUCT.md; sourceTree = ""; }; 121 | FD6C3C0C1E0200F800EC4528 /* OneTimePasswordTestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OneTimePasswordTestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 122 | FD6C3C0E1E0200F800EC4528 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 123 | FD6C3C1A1E0200F800EC4528 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 124 | FDA64C741E020ABF004AD993 /* Launch Screen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = "Launch Screen.storyboard"; sourceTree = ""; }; 125 | FDA64C771E021394004AD993 /* OneTimePasswordTestApp.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = OneTimePasswordTestApp.xcconfig; sourceTree = ""; }; 126 | /* End PBXFileReference section */ 127 | 128 | /* Begin PBXFrameworksBuildPhase section */ 129 | 5B39F4901DBD06BA00CD2DAB /* Frameworks */ = { 130 | isa = PBXFrameworksBuildPhase; 131 | buildActionMask = 2147483647; 132 | files = ( 133 | C99262CE29558AE600C96BDF /* Base32 in Frameworks */, 134 | ); 135 | runOnlyForDeploymentPostprocessing = 0; 136 | }; 137 | C97C82341946E51D00FD9F4C /* Frameworks */ = { 138 | isa = PBXFrameworksBuildPhase; 139 | buildActionMask = 2147483647; 140 | files = ( 141 | C99262CC29558ABA00C96BDF /* Base32 in Frameworks */, 142 | ); 143 | runOnlyForDeploymentPostprocessing = 0; 144 | }; 145 | C97C82401946E51D00FD9F4C /* Frameworks */ = { 146 | isa = PBXFrameworksBuildPhase; 147 | buildActionMask = 2147483647; 148 | files = ( 149 | C97C82441946E51D00FD9F4C /* OneTimePassword.framework in Frameworks */, 150 | ); 151 | runOnlyForDeploymentPostprocessing = 0; 152 | }; 153 | /* End PBXFrameworksBuildPhase section */ 154 | 155 | /* Begin PBXGroup section */ 156 | C9307A8619A8522F00609B02 /* Serialization */ = { 157 | isa = PBXGroup; 158 | children = ( 159 | C9DC7EC3196BD5DF00B50C82 /* Token+URL.swift */, 160 | ); 161 | name = Serialization; 162 | sourceTree = ""; 163 | }; 164 | C93CC01F1DCBC0E6006255FA /* Targets */ = { 165 | isa = PBXGroup; 166 | children = ( 167 | C93CC01E1DCBBDE7006255FA /* OneTimePassword.xcconfig */, 168 | C93CC01A1DCBB755006255FA /* OneTimePassword-iOS.xcconfig */, 169 | C93CC01B1DCBB7FB006255FA /* OneTimePassword-watchOS.xcconfig */, 170 | C93CC0211DCBC189006255FA /* OneTimePasswordTests.xcconfig */, 171 | C93CC01C1DCBB875006255FA /* OneTimePasswordTests-iOS.xcconfig */, 172 | FDA64C771E021394004AD993 /* OneTimePasswordTestApp.xcconfig */, 173 | ); 174 | name = Targets; 175 | sourceTree = ""; 176 | }; 177 | C94B9BC71BD726F70073D7C5 /* Docs */ = { 178 | isa = PBXGroup; 179 | children = ( 180 | C94B9BCA1BD7271E0073D7C5 /* AUTHORS.txt */, 181 | C9E829531C62DFDA003F5FC9 /* CHANGELOG.md */, 182 | C9E829551C630514003F5FC9 /* CONDUCT.md */, 183 | C9E829541C62FFBD003F5FC9 /* CONTRIBUTING.md */, 184 | C94B9BC81BD7270E0073D7C5 /* LICENSE.md */, 185 | C94B9BC91BD727150073D7C5 /* README.md */, 186 | ); 187 | name = Docs; 188 | sourceTree = ""; 189 | }; 190 | C97C822E1946E51D00FD9F4C = { 191 | isa = PBXGroup; 192 | children = ( 193 | C97C823A1946E51D00FD9F4C /* Sources */, 194 | C97C82471946E51D00FD9F4C /* Tests */, 195 | C996EC281A74D5830076B105 /* Configuration */, 196 | C97C82391946E51D00FD9F4C /* Products */, 197 | C9B84D1B1C015EA2002EE631 /* Tools */, 198 | C94B9BC71BD726F70073D7C5 /* Docs */, 199 | ); 200 | indentWidth = 4; 201 | sourceTree = ""; 202 | usesTabs = 0; 203 | }; 204 | C97C82391946E51D00FD9F4C /* Products */ = { 205 | isa = PBXGroup; 206 | children = ( 207 | C97C82381946E51D00FD9F4C /* OneTimePassword.framework */, 208 | C97C82431946E51D00FD9F4C /* OneTimePasswordTests.xctest */, 209 | 5B39F4941DBD06BA00CD2DAB /* OneTimePassword.framework */, 210 | FD6C3C0C1E0200F800EC4528 /* OneTimePasswordTestApp.app */, 211 | ); 212 | name = Products; 213 | sourceTree = ""; 214 | }; 215 | C97C823A1946E51D00FD9F4C /* Sources */ = { 216 | isa = PBXGroup; 217 | children = ( 218 | C93A2519196B1BA400F86892 /* Token.swift */, 219 | C9DC7EC7196BDF3B00B50C82 /* Generator.swift */, 220 | C9307A8619A8522F00609B02 /* Serialization */, 221 | C9A9B09A1A81EF4B00F3C4DC /* Persistence */, 222 | C97C823B1946E51D00FD9F4C /* Supporting Files */, 223 | ); 224 | path = Sources; 225 | sourceTree = ""; 226 | }; 227 | C97C823B1946E51D00FD9F4C /* Supporting Files */ = { 228 | isa = PBXGroup; 229 | children = ( 230 | C97C823C1946E51D00FD9F4C /* Info.plist */, 231 | ); 232 | name = "Supporting Files"; 233 | sourceTree = ""; 234 | }; 235 | C97C82471946E51D00FD9F4C /* Tests */ = { 236 | isa = PBXGroup; 237 | children = ( 238 | C94B2006197774A20014A202 /* TokenTests.swift */, 239 | C95B10CB196D22B9000840AA /* GeneratorTests.swift */, 240 | C93A2514196AFE1100F86892 /* KeychainTests.swift */, 241 | C9290C2F1947D104008AE4DE /* TokenSerializationTests.swift */, 242 | C9B2A19B199A7F1B00BC4A8A /* EquatableTests.swift */, 243 | C97C82481946E51D00FD9F4C /* Supporting Files */, 244 | FD6C3C0D1E0200F800EC4528 /* Test App */, 245 | ); 246 | path = Tests; 247 | sourceTree = ""; 248 | }; 249 | C97C82481946E51D00FD9F4C /* Supporting Files */ = { 250 | isa = PBXGroup; 251 | children = ( 252 | C97C82491946E51D00FD9F4C /* Info.plist */, 253 | ); 254 | name = "Supporting Files"; 255 | sourceTree = ""; 256 | }; 257 | C996EC281A74D5830076B105 /* Configuration */ = { 258 | isa = PBXGroup; 259 | children = ( 260 | C996EC291A74D5830076B105 /* Project */, 261 | C93CC01F1DCBC0E6006255FA /* Targets */, 262 | ); 263 | path = Configuration; 264 | sourceTree = ""; 265 | }; 266 | C996EC291A74D5830076B105 /* Project */ = { 267 | isa = PBXGroup; 268 | children = ( 269 | C996EC2C1A74D5830076B105 /* Debug.xcconfig */, 270 | C996EC2D1A74D5830076B105 /* Profile.xcconfig */, 271 | C996EC2E1A74D5830076B105 /* Release.xcconfig */, 272 | C996EC2F1A74D5830076B105 /* Test.xcconfig */, 273 | ); 274 | name = Project; 275 | path = Carthage/Checkouts/xcconfigs/Base; 276 | sourceTree = SOURCE_ROOT; 277 | }; 278 | C9A9B09A1A81EF4B00F3C4DC /* Persistence */ = { 279 | isa = PBXGroup; 280 | children = ( 281 | C95F9FB81C03D6BC00CEA286 /* PersistentToken.swift */, 282 | C9003417196F7046009733E8 /* Keychain.swift */, 283 | ); 284 | name = Persistence; 285 | sourceTree = ""; 286 | }; 287 | C9B84D1B1C015EA2002EE631 /* Tools */ = { 288 | isa = PBXGroup; 289 | children = ( 290 | 49403AEA23D3778400539BD3 /* Package.swift */, 291 | C94765061C64587800C7527E /* Cartfile.private */, 292 | C9619C5C1D73FB3500757587 /* .codecov.yml */, 293 | C9B84D1C1C015EC0002EE631 /* .hound.yml */, 294 | C9B84D1D1C015EC0002EE631 /* .swiftlint.yml */, 295 | ); 296 | name = Tools; 297 | sourceTree = ""; 298 | }; 299 | FD6C3C0D1E0200F800EC4528 /* Test App */ = { 300 | isa = PBXGroup; 301 | children = ( 302 | FD6C3C0E1E0200F800EC4528 /* AppDelegate.swift */, 303 | FD6C3C1A1E0200F800EC4528 /* Info.plist */, 304 | FDA64C741E020ABF004AD993 /* Launch Screen.storyboard */, 305 | ); 306 | name = "Test App"; 307 | path = App; 308 | sourceTree = ""; 309 | }; 310 | /* End PBXGroup section */ 311 | 312 | /* Begin PBXNativeTarget section */ 313 | 5B39F4931DBD06BA00CD2DAB /* OneTimePassword (watchOS) */ = { 314 | isa = PBXNativeTarget; 315 | buildConfigurationList = 5B39F49B1DBD06BA00CD2DAB /* Build configuration list for PBXNativeTarget "OneTimePassword (watchOS)" */; 316 | buildPhases = ( 317 | 5B39F48F1DBD06BA00CD2DAB /* Sources */, 318 | 5B39F4901DBD06BA00CD2DAB /* Frameworks */, 319 | ); 320 | buildRules = ( 321 | ); 322 | dependencies = ( 323 | ); 324 | name = "OneTimePassword (watchOS)"; 325 | packageProductDependencies = ( 326 | C99262CD29558AE600C96BDF /* Base32 */, 327 | ); 328 | productName = "OneTimePassword (watchOS)"; 329 | productReference = 5B39F4941DBD06BA00CD2DAB /* OneTimePassword.framework */; 330 | productType = "com.apple.product-type.framework"; 331 | }; 332 | C97C82371946E51D00FD9F4C /* OneTimePassword (iOS) */ = { 333 | isa = PBXNativeTarget; 334 | buildConfigurationList = C97C824E1946E51D00FD9F4C /* Build configuration list for PBXNativeTarget "OneTimePassword (iOS)" */; 335 | buildPhases = ( 336 | C97C82331946E51D00FD9F4C /* Sources */, 337 | C97C82341946E51D00FD9F4C /* Frameworks */, 338 | ); 339 | buildRules = ( 340 | ); 341 | dependencies = ( 342 | ); 343 | name = "OneTimePassword (iOS)"; 344 | packageProductDependencies = ( 345 | C99262CB29558ABA00C96BDF /* Base32 */, 346 | ); 347 | productName = "OneTimePassword (iOS)"; 348 | productReference = C97C82381946E51D00FD9F4C /* OneTimePassword.framework */; 349 | productType = "com.apple.product-type.framework"; 350 | }; 351 | C97C82421946E51D00FD9F4C /* OneTimePasswordTests */ = { 352 | isa = PBXNativeTarget; 353 | buildConfigurationList = C97C82511946E51D00FD9F4C /* Build configuration list for PBXNativeTarget "OneTimePasswordTests" */; 354 | buildPhases = ( 355 | C97C823F1946E51D00FD9F4C /* Sources */, 356 | C97C82401946E51D00FD9F4C /* Frameworks */, 357 | ); 358 | buildRules = ( 359 | ); 360 | dependencies = ( 361 | C97C82461946E51D00FD9F4C /* PBXTargetDependency */, 362 | FD6C3C211E0200F900EC4528 /* PBXTargetDependency */, 363 | ); 364 | name = OneTimePasswordTests; 365 | productName = OneTimePasswordTests; 366 | productReference = C97C82431946E51D00FD9F4C /* OneTimePasswordTests.xctest */; 367 | productType = "com.apple.product-type.bundle.unit-test"; 368 | }; 369 | FD6C3C0B1E0200F800EC4528 /* OneTimePasswordTestApp */ = { 370 | isa = PBXNativeTarget; 371 | buildConfigurationList = FD6C3C2A1E0200F900EC4528 /* Build configuration list for PBXNativeTarget "OneTimePasswordTestApp" */; 372 | buildPhases = ( 373 | FD6C3C081E0200F800EC4528 /* Sources */, 374 | FD6C3C0A1E0200F800EC4528 /* Resources */, 375 | FD6C3C371E02033700EC4528 /* Embed Frameworks */, 376 | ); 377 | buildRules = ( 378 | ); 379 | dependencies = ( 380 | FD6C3C361E02033600EC4528 /* PBXTargetDependency */, 381 | ); 382 | name = OneTimePasswordTestApp; 383 | productName = OneTimePasswordApp; 384 | productReference = FD6C3C0C1E0200F800EC4528 /* OneTimePasswordTestApp.app */; 385 | productType = "com.apple.product-type.application"; 386 | }; 387 | /* End PBXNativeTarget section */ 388 | 389 | /* Begin PBXProject section */ 390 | C97C822F1946E51D00FD9F4C /* Project object */ = { 391 | isa = PBXProject; 392 | attributes = { 393 | LastSwiftMigration = 0700; 394 | LastSwiftUpdateCheck = 0700; 395 | LastUpgradeCheck = 1420; 396 | ORGANIZATIONNAME = "Matt Rubin"; 397 | TargetAttributes = { 398 | 5B39F4931DBD06BA00CD2DAB = { 399 | CreatedOnToolsVersion = 8.0; 400 | LastSwiftMigration = 1020; 401 | ProvisioningStyle = Manual; 402 | }; 403 | C9425DE4227501F500EF93BD = { 404 | CreatedOnToolsVersion = 10.2.1; 405 | }; 406 | C97C82371946E51D00FD9F4C = { 407 | CreatedOnToolsVersion = 6.0; 408 | LastSwiftMigration = 1020; 409 | ProvisioningStyle = Manual; 410 | }; 411 | C97C82421946E51D00FD9F4C = { 412 | CreatedOnToolsVersion = 6.0; 413 | LastSwiftMigration = 1020; 414 | ProvisioningStyle = Manual; 415 | TestTargetID = FD6C3C0B1E0200F800EC4528; 416 | }; 417 | FD6C3C0B1E0200F800EC4528 = { 418 | CreatedOnToolsVersion = 8.2; 419 | LastSwiftMigration = 1020; 420 | ProvisioningStyle = Automatic; 421 | }; 422 | }; 423 | }; 424 | buildConfigurationList = C97C82321946E51D00FD9F4C /* Build configuration list for PBXProject "OneTimePassword" */; 425 | compatibilityVersion = "Xcode 14.0"; 426 | developmentRegion = en; 427 | hasScannedForEncodings = 0; 428 | knownRegions = ( 429 | en, 430 | Base, 431 | ); 432 | mainGroup = C97C822E1946E51D00FD9F4C; 433 | packageReferences = ( 434 | C99262CA29558ABA00C96BDF /* XCRemoteSwiftPackageReference "Base32" */, 435 | ); 436 | productRefGroup = C97C82391946E51D00FD9F4C /* Products */; 437 | projectDirPath = ""; 438 | projectRoot = ""; 439 | targets = ( 440 | C97C82371946E51D00FD9F4C /* OneTimePassword (iOS) */, 441 | C97C82421946E51D00FD9F4C /* OneTimePasswordTests */, 442 | FD6C3C0B1E0200F800EC4528 /* OneTimePasswordTestApp */, 443 | 5B39F4931DBD06BA00CD2DAB /* OneTimePassword (watchOS) */, 444 | C9425DE4227501F500EF93BD /* Lint OneTimePassword */, 445 | ); 446 | }; 447 | /* End PBXProject section */ 448 | 449 | /* Begin PBXResourcesBuildPhase section */ 450 | FD6C3C0A1E0200F800EC4528 /* Resources */ = { 451 | isa = PBXResourcesBuildPhase; 452 | buildActionMask = 2147483647; 453 | files = ( 454 | FDA64C751E020ABF004AD993 /* Launch Screen.storyboard in Resources */, 455 | ); 456 | runOnlyForDeploymentPostprocessing = 0; 457 | }; 458 | /* End PBXResourcesBuildPhase section */ 459 | 460 | /* Begin PBXShellScriptBuildPhase section */ 461 | C97CDF2E1BEFB20000D64406 /* Run SwiftLint */ = { 462 | isa = PBXShellScriptBuildPhase; 463 | alwaysOutOfDate = 1; 464 | buildActionMask = 2147483647; 465 | files = ( 466 | ); 467 | inputFileListPaths = ( 468 | ); 469 | inputPaths = ( 470 | ); 471 | name = "Run SwiftLint"; 472 | outputFileListPaths = ( 473 | ); 474 | outputPaths = ( 475 | ); 476 | runOnlyForDeploymentPostprocessing = 0; 477 | shellPath = /bin/sh; 478 | shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint is not installed. (https://github.com/realm/SwiftLint)\"\nfi\n"; 479 | }; 480 | /* End PBXShellScriptBuildPhase section */ 481 | 482 | /* Begin PBXSourcesBuildPhase section */ 483 | 5B39F48F1DBD06BA00CD2DAB /* Sources */ = { 484 | isa = PBXSourcesBuildPhase; 485 | buildActionMask = 2147483647; 486 | files = ( 487 | 5B39F49F1DBD06F500CD2DAB /* Token+URL.swift in Sources */, 488 | 5B39F49C1DBD06EB00CD2DAB /* Token.swift in Sources */, 489 | 5B39F49D1DBD06EE00CD2DAB /* Generator.swift in Sources */, 490 | 5B39F4A01DBD06F900CD2DAB /* PersistentToken.swift in Sources */, 491 | 5B39F4A11DBD06FC00CD2DAB /* Keychain.swift in Sources */, 492 | ); 493 | runOnlyForDeploymentPostprocessing = 0; 494 | }; 495 | C97C82331946E51D00FD9F4C /* Sources */ = { 496 | isa = PBXSourcesBuildPhase; 497 | buildActionMask = 2147483647; 498 | files = ( 499 | C93A251A196B1BA400F86892 /* Token.swift in Sources */, 500 | C95F9FB91C03D6BC00CEA286 /* PersistentToken.swift in Sources */, 501 | C9DC7EC8196BDF3B00B50C82 /* Generator.swift in Sources */, 502 | C9003418196F7046009733E8 /* Keychain.swift in Sources */, 503 | C9DC7EC4196BD5DF00B50C82 /* Token+URL.swift in Sources */, 504 | ); 505 | runOnlyForDeploymentPostprocessing = 0; 506 | }; 507 | C97C823F1946E51D00FD9F4C /* Sources */ = { 508 | isa = PBXSourcesBuildPhase; 509 | buildActionMask = 2147483647; 510 | files = ( 511 | C94B2007197774A20014A202 /* TokenTests.swift in Sources */, 512 | C95B10CC196D22B9000840AA /* GeneratorTests.swift in Sources */, 513 | C9B77D771C03078B00BAF6BF /* KeychainTests.swift in Sources */, 514 | C9290C301947D104008AE4DE /* TokenSerializationTests.swift in Sources */, 515 | C9B2A19C199A7F1B00BC4A8A /* EquatableTests.swift in Sources */, 516 | ); 517 | runOnlyForDeploymentPostprocessing = 0; 518 | }; 519 | FD6C3C081E0200F800EC4528 /* Sources */ = { 520 | isa = PBXSourcesBuildPhase; 521 | buildActionMask = 2147483647; 522 | files = ( 523 | FD6C3C0F1E0200F800EC4528 /* AppDelegate.swift in Sources */, 524 | ); 525 | runOnlyForDeploymentPostprocessing = 0; 526 | }; 527 | /* End PBXSourcesBuildPhase section */ 528 | 529 | /* Begin PBXTargetDependency section */ 530 | C97C82461946E51D00FD9F4C /* PBXTargetDependency */ = { 531 | isa = PBXTargetDependency; 532 | target = C97C82371946E51D00FD9F4C /* OneTimePassword (iOS) */; 533 | targetProxy = C97C82451946E51D00FD9F4C /* PBXContainerItemProxy */; 534 | }; 535 | FD6C3C211E0200F900EC4528 /* PBXTargetDependency */ = { 536 | isa = PBXTargetDependency; 537 | target = FD6C3C0B1E0200F800EC4528 /* OneTimePasswordTestApp */; 538 | targetProxy = FD6C3C201E0200F900EC4528 /* PBXContainerItemProxy */; 539 | }; 540 | FD6C3C361E02033600EC4528 /* PBXTargetDependency */ = { 541 | isa = PBXTargetDependency; 542 | target = C97C82371946E51D00FD9F4C /* OneTimePassword (iOS) */; 543 | targetProxy = FD6C3C351E02033600EC4528 /* PBXContainerItemProxy */; 544 | }; 545 | /* End PBXTargetDependency section */ 546 | 547 | /* Begin XCBuildConfiguration section */ 548 | 5B39F4991DBD06BA00CD2DAB /* Debug */ = { 549 | isa = XCBuildConfiguration; 550 | baseConfigurationReference = C93CC01B1DCBB7FB006255FA /* OneTimePassword-watchOS.xcconfig */; 551 | buildSettings = { 552 | }; 553 | name = Debug; 554 | }; 555 | 5B39F49A1DBD06BA00CD2DAB /* Release */ = { 556 | isa = XCBuildConfiguration; 557 | baseConfigurationReference = C93CC01B1DCBB7FB006255FA /* OneTimePassword-watchOS.xcconfig */; 558 | buildSettings = { 559 | }; 560 | name = Release; 561 | }; 562 | C9425DE5227501F500EF93BD /* Debug */ = { 563 | isa = XCBuildConfiguration; 564 | buildSettings = { 565 | }; 566 | name = Debug; 567 | }; 568 | C9425DE6227501F500EF93BD /* Release */ = { 569 | isa = XCBuildConfiguration; 570 | buildSettings = { 571 | }; 572 | name = Release; 573 | }; 574 | C97C824C1946E51D00FD9F4C /* Debug */ = { 575 | isa = XCBuildConfiguration; 576 | baseConfigurationReference = C996EC2C1A74D5830076B105 /* Debug.xcconfig */; 577 | buildSettings = { 578 | DEAD_CODE_STRIPPING = YES; 579 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 580 | MACOSX_DEPLOYMENT_TARGET = 13.0; 581 | SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; 582 | SWIFT_VERSION = 5.0; 583 | WATCHOS_DEPLOYMENT_TARGET = 9.0; 584 | }; 585 | name = Debug; 586 | }; 587 | C97C824D1946E51D00FD9F4C /* Release */ = { 588 | isa = XCBuildConfiguration; 589 | baseConfigurationReference = C996EC2E1A74D5830076B105 /* Release.xcconfig */; 590 | buildSettings = { 591 | DEAD_CODE_STRIPPING = YES; 592 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 593 | MACOSX_DEPLOYMENT_TARGET = 13.0; 594 | SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; 595 | SWIFT_VERSION = 5.0; 596 | WATCHOS_DEPLOYMENT_TARGET = 9.0; 597 | }; 598 | name = Release; 599 | }; 600 | C97C824F1946E51D00FD9F4C /* Debug */ = { 601 | isa = XCBuildConfiguration; 602 | baseConfigurationReference = C93CC01A1DCBB755006255FA /* OneTimePassword-iOS.xcconfig */; 603 | buildSettings = { 604 | }; 605 | name = Debug; 606 | }; 607 | C97C82501946E51D00FD9F4C /* Release */ = { 608 | isa = XCBuildConfiguration; 609 | baseConfigurationReference = C93CC01A1DCBB755006255FA /* OneTimePassword-iOS.xcconfig */; 610 | buildSettings = { 611 | }; 612 | name = Release; 613 | }; 614 | C97C82521946E51D00FD9F4C /* Debug */ = { 615 | isa = XCBuildConfiguration; 616 | baseConfigurationReference = C93CC01C1DCBB875006255FA /* OneTimePasswordTests-iOS.xcconfig */; 617 | buildSettings = { 618 | }; 619 | name = Debug; 620 | }; 621 | C97C82531946E51D00FD9F4C /* Release */ = { 622 | isa = XCBuildConfiguration; 623 | baseConfigurationReference = C93CC01C1DCBB875006255FA /* OneTimePasswordTests-iOS.xcconfig */; 624 | buildSettings = { 625 | }; 626 | name = Release; 627 | }; 628 | FD6C3C261E0200F900EC4528 /* Debug */ = { 629 | isa = XCBuildConfiguration; 630 | baseConfigurationReference = FDA64C771E021394004AD993 /* OneTimePasswordTestApp.xcconfig */; 631 | buildSettings = { 632 | }; 633 | name = Debug; 634 | }; 635 | FD6C3C271E0200F900EC4528 /* Release */ = { 636 | isa = XCBuildConfiguration; 637 | baseConfigurationReference = FDA64C771E021394004AD993 /* OneTimePasswordTestApp.xcconfig */; 638 | buildSettings = { 639 | }; 640 | name = Release; 641 | }; 642 | /* End XCBuildConfiguration section */ 643 | 644 | /* Begin XCConfigurationList section */ 645 | 5B39F49B1DBD06BA00CD2DAB /* Build configuration list for PBXNativeTarget "OneTimePassword (watchOS)" */ = { 646 | isa = XCConfigurationList; 647 | buildConfigurations = ( 648 | 5B39F4991DBD06BA00CD2DAB /* Debug */, 649 | 5B39F49A1DBD06BA00CD2DAB /* Release */, 650 | ); 651 | defaultConfigurationIsVisible = 0; 652 | defaultConfigurationName = Release; 653 | }; 654 | C9425DE7227501F500EF93BD /* Build configuration list for PBXAggregateTarget "Lint OneTimePassword" */ = { 655 | isa = XCConfigurationList; 656 | buildConfigurations = ( 657 | C9425DE5227501F500EF93BD /* Debug */, 658 | C9425DE6227501F500EF93BD /* Release */, 659 | ); 660 | defaultConfigurationIsVisible = 0; 661 | defaultConfigurationName = Release; 662 | }; 663 | C97C82321946E51D00FD9F4C /* Build configuration list for PBXProject "OneTimePassword" */ = { 664 | isa = XCConfigurationList; 665 | buildConfigurations = ( 666 | C97C824C1946E51D00FD9F4C /* Debug */, 667 | C97C824D1946E51D00FD9F4C /* Release */, 668 | ); 669 | defaultConfigurationIsVisible = 0; 670 | defaultConfigurationName = Release; 671 | }; 672 | C97C824E1946E51D00FD9F4C /* Build configuration list for PBXNativeTarget "OneTimePassword (iOS)" */ = { 673 | isa = XCConfigurationList; 674 | buildConfigurations = ( 675 | C97C824F1946E51D00FD9F4C /* Debug */, 676 | C97C82501946E51D00FD9F4C /* Release */, 677 | ); 678 | defaultConfigurationIsVisible = 0; 679 | defaultConfigurationName = Release; 680 | }; 681 | C97C82511946E51D00FD9F4C /* Build configuration list for PBXNativeTarget "OneTimePasswordTests" */ = { 682 | isa = XCConfigurationList; 683 | buildConfigurations = ( 684 | C97C82521946E51D00FD9F4C /* Debug */, 685 | C97C82531946E51D00FD9F4C /* Release */, 686 | ); 687 | defaultConfigurationIsVisible = 0; 688 | defaultConfigurationName = Release; 689 | }; 690 | FD6C3C2A1E0200F900EC4528 /* Build configuration list for PBXNativeTarget "OneTimePasswordTestApp" */ = { 691 | isa = XCConfigurationList; 692 | buildConfigurations = ( 693 | FD6C3C261E0200F900EC4528 /* Debug */, 694 | FD6C3C271E0200F900EC4528 /* Release */, 695 | ); 696 | defaultConfigurationIsVisible = 0; 697 | defaultConfigurationName = Release; 698 | }; 699 | /* End XCConfigurationList section */ 700 | 701 | /* Begin XCRemoteSwiftPackageReference section */ 702 | C99262CA29558ABA00C96BDF /* XCRemoteSwiftPackageReference "Base32" */ = { 703 | isa = XCRemoteSwiftPackageReference; 704 | repositoryURL = "https://github.com/mattrubin/Base32.git"; 705 | requirement = { 706 | kind = upToNextMajorVersion; 707 | minimumVersion = 1.2.0; 708 | }; 709 | }; 710 | /* End XCRemoteSwiftPackageReference section */ 711 | 712 | /* Begin XCSwiftPackageProductDependency section */ 713 | C99262CB29558ABA00C96BDF /* Base32 */ = { 714 | isa = XCSwiftPackageProductDependency; 715 | package = C99262CA29558ABA00C96BDF /* XCRemoteSwiftPackageReference "Base32" */; 716 | productName = Base32; 717 | }; 718 | C99262CD29558AE600C96BDF /* Base32 */ = { 719 | isa = XCSwiftPackageProductDependency; 720 | package = C99262CA29558ABA00C96BDF /* XCRemoteSwiftPackageReference "Base32" */; 721 | productName = Base32; 722 | }; 723 | /* End XCSwiftPackageProductDependency section */ 724 | }; 725 | rootObject = C97C822F1946E51D00FD9F4C /* Project object */; 726 | } 727 | -------------------------------------------------------------------------------- /OneTimePassword.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /OneTimePassword.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /OneTimePassword.xcodeproj/xcshareddata/xcschemes/OneTimePassword (iOS).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 52 | 53 | 54 | 55 | 57 | 63 | 64 | 65 | 66 | 67 | 77 | 78 | 84 | 85 | 86 | 87 | 93 | 94 | 100 | 101 | 102 | 103 | 105 | 106 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /OneTimePassword.xcodeproj/xcshareddata/xcschemes/OneTimePassword (watchOS).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 52 | 53 | 54 | 55 | 56 | 57 | 67 | 68 | 74 | 75 | 76 | 77 | 83 | 84 | 90 | 91 | 92 | 93 | 95 | 96 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "OneTimePassword", 6 | platforms: [ 7 | .iOS(.v16), 8 | .macOS(.v13), 9 | .watchOS(.v9), 10 | ], 11 | products: [ 12 | .library( 13 | name: "OneTimePassword", 14 | targets: ["OneTimePassword"]), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/mattrubin/Base32.git", from: "1.2.0"), 18 | ], 19 | targets: [ 20 | .target( 21 | name: "OneTimePassword", 22 | dependencies: ["Base32"], 23 | path: "Sources"), 24 | .testTarget( 25 | name: "OneTimePasswordTests", 26 | dependencies: ["OneTimePassword"], 27 | path: "Tests", 28 | exclude: ["App", "KeychainTests.swift"]), 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OneTimePassword 2 | ### TOTP and HOTP one-time passwords for iOS 3 | 4 | [![Xcode CI status](https://github.com/mattrubin/OneTimePassword/actions/workflows/xcode.yml/badge.svg)](https://github.com/mattrubin/OneTimePassword/actions/workflows/xcode.yml) 5 | [![SPM CI status](https://github.com/mattrubin/OneTimePassword/actions/workflows/spm.yml/badge.svg)](https://github.com/mattrubin/OneTimePassword/actions/workflows/spm.yml) 6 | [![Carthage CI status](https://github.com/mattrubin/OneTimePassword/actions/workflows/carthage.yml/badge.svg)](https://github.com/mattrubin/OneTimePassword/actions/workflows/carthage.yml) 7 | [![Code Coverage](https://img.shields.io/codecov/c/github/mattrubin/OneTimePassword/develop.svg)](https://codecov.io/gh/mattrubin/OneTimePassword) 8 | [![Swift 5.x](https://img.shields.io/badge/swift-5.x-orange.svg)](#usage) 9 | ![Platforms: iOS, macOS, watchOS](https://img.shields.io/badge/platforms-iOS%20%7C%20macOS%20%7C%20watchOS-blue.svg) 10 | [![MIT License](https://img.shields.io/badge/license-MIT-lightgray.svg)](LICENSE.md) 11 | 12 | The OneTimePassword library is the core of [Authenticator][]. It can generate both [time-based][RFC 6238] and [counter-based][RFC 4226] one-time passwords as standardized in [RFC 4226][] and [RFC 6238][]. It can also read and generate the ["otpauth://" URLs][otpauth] commonly used to set up OTP tokens, and can save and load tokens to and from the iOS secure keychain. 13 | 14 | [Authenticator]: https://mattrubin.me/authenticator/ 15 | [RFC 6238]: https://tools.ietf.org/html/rfc6238 16 | [RFC 4226]: https://tools.ietf.org/html/rfc4226 17 | [otpauth]: https://github.com/google/google-authenticator/wiki/Key-Uri-Format 18 | 19 | 20 | ## Installation 21 | 22 | ### [Carthage][] 23 | 24 | Add the following line to your [Cartfile][]: 25 | 26 | ````config 27 | github "mattrubin/OneTimePassword" ~> 4.0 28 | ```` 29 | 30 | Then run `carthage update OneTimePassword` to install the latest version of the framework. 31 | 32 | Be sure to check the Carthage README file for the latest instructions on [adding frameworks to an application][carthage-instructions]. 33 | 34 | [Carthage]: https://github.com/Carthage/Carthage 35 | [Cartfile]: https://github.com/Carthage/Carthage/blob/master/Documentation/Artifacts.md#cartfile 36 | [carthage-instructions]: https://github.com/Carthage/Carthage/blob/master/README.md#adding-frameworks-to-an-application 37 | 38 | ### [SPM][] 39 | 40 | Add the following line to the `dependencies` section of your [package manifest][Package.swift]: 41 | 42 | ```swift 43 | .package(url: "https://github.com/mattrubin/OneTimePassword.git", from: "4.0.0"), 44 | ``` 45 | 46 | Then add `"OneTimePassword"` to the dependencies array of any target which should be linked with this library. 47 | 48 | [SPM]: https://swift.org/package-manager/ 49 | [Package.swift]: https://github.com/apple/swift-package-manager/tree/master/Documentation 50 | 51 | ## Usage 52 | 53 | > The [latest version][swift-5] of OneTimePassword compiles with Swift 5. To use OneTimePassword with earlier versions of Swift, check out the [`swift-4.2`][swift-4.2], [`swift-4`][swift-4], [`swift-3`][swift-3], and [`swift-2.3`][swift-2.3] branches. To use OneTimePassword in an Objective-C based project, check out the [`objc` branch][objc] and the [1.x releases][releases]. 54 | 55 | [swift-5]: https://github.com/mattrubin/OneTimePassword/tree/swift-5 56 | [swift-4.2]: https://github.com/mattrubin/OneTimePassword/tree/swift-4.2 57 | [swift-4]: https://github.com/mattrubin/OneTimePassword/tree/swift-4 58 | [swift-3]: https://github.com/mattrubin/OneTimePassword/tree/swift-3 59 | [swift-2.3]: https://github.com/mattrubin/OneTimePassword/tree/swift-2.3 60 | [objc]: https://github.com/mattrubin/OneTimePassword/tree/objc 61 | [releases]: https://github.com/mattrubin/OneTimePassword/releases 62 | 63 | ### Create a Token 64 | 65 | The [`Generator`][Generator] struct contains the parameters necessary to generate a one-time password. The [`Token`][Token] struct associates a `generator` with a `name` and an `issuer` string. 66 | 67 | [Generator]: ./Sources/Generator.swift 68 | [Token]: ./Sources/Token.swift 69 | 70 | To initialize a token with an `otpauth://` url: 71 | 72 | ````swift 73 | if let token = Token(url: url) { 74 | print("Password: \(token.currentPassword)") 75 | } else { 76 | print("Invalid token URL") 77 | } 78 | ```` 79 | 80 | To create a generator and a token from user input: 81 | 82 | > This example assumes the user provides the secret as a Base32-encoded string. To use the decoding function seen below, add `import Base32` to the top of your Swift file. 83 | 84 | ````swift 85 | let name = "..." 86 | let issuer = "..." 87 | let secretString = "..." 88 | 89 | guard let secretData = MF_Base32Codec.data(fromBase32String: secretString), 90 | !secretData.isEmpty else { 91 | print("Invalid secret") 92 | return nil 93 | } 94 | 95 | guard let generator = Generator( 96 | factor: .timer(period: 30), 97 | secret: secretData, 98 | algorithm: .sha1, 99 | digits: 6) else { 100 | print("Invalid generator parameters") 101 | return nil 102 | } 103 | 104 | let token = Token(name: name, issuer: issuer, generator: generator) 105 | return token 106 | ```` 107 | 108 | ### Generate a One-Time Password 109 | 110 | To generate the current password: 111 | 112 | ````swift 113 | let password = token.currentPassword 114 | ```` 115 | 116 | To generate the password at a specific point in time: 117 | 118 | ````swift 119 | let time = Date(timeIntervalSince1970: ...) 120 | do { 121 | let passwordAtTime = try token.generator.password(at: time) 122 | print("Password at time: \(passwordAtTime)") 123 | } catch { 124 | print("Cannot generate password for invalid time \(time)") 125 | } 126 | ```` 127 | 128 | ### Persistence 129 | 130 | Token persistence is managed by the [`Keychain`][Keychain] class, which represents the iOS system keychain. 131 | 132 | ````swift 133 | let keychain = Keychain.sharedInstance 134 | ```` 135 | 136 | The [`PersistentToken`][PersistentToken] struct represents a `Token` that has been saved to the keychain, and associates a `token` with a keychain-provided data `identifier`. 137 | 138 | [Keychain]: ./Sources/Keychain.swift 139 | [PersistentToken]: ./Sources/PersistentToken.swift 140 | 141 | To save a token to the keychain: 142 | 143 | ````swift 144 | do { 145 | let persistentToken = try keychain.add(token) 146 | print("Saved to keychain with identifier: \(persistentToken.identifier)") 147 | } catch { 148 | print("Keychain error: \(error)") 149 | } 150 | ```` 151 | 152 | To retrieve a token from the keychain: 153 | 154 | ````swift 155 | do { 156 | if let persistentToken = try keychain.persistentToken(withIdentifier: identifier) { 157 | print("Retrieved token: \(persistentToken.token)") 158 | } 159 | // Or... 160 | let persistentTokens = try keychain.allPersistentTokens() 161 | print("All tokens: \(persistentTokens.map({ $0.token }))") 162 | } catch { 163 | print("Keychain error: \(error)") 164 | } 165 | ```` 166 | 167 | To update a saved token in the keychain: 168 | 169 | ````swift 170 | do { 171 | let updatedPersistentToken = try keychain.update(persistentToken, with: token) 172 | print("Updated token: \(updatedPersistentToken)") 173 | } catch { 174 | print("Keychain error: \(error)") 175 | } 176 | ```` 177 | 178 | To delete a token from the keychain: 179 | 180 | ````swift 181 | do { 182 | try keychain.delete(persistentToken) 183 | print("Deleted token.") 184 | } catch { 185 | print("Keychain error: \(error)") 186 | } 187 | ```` 188 | 189 | 190 | ## License 191 | 192 | OneTimePassword was created by [Matt Rubin][] and the [OneTimePassword authors](AUTHORS.txt) and is released under the [MIT License](LICENSE.md). 193 | 194 | [Matt Rubin]: https://mattrubin.me 195 | -------------------------------------------------------------------------------- /Sources/Generator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Generator.swift 3 | // OneTimePassword 4 | // 5 | // Copyright (c) 2014-2022 Matt Rubin and the OneTimePassword authors 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | // 25 | 26 | import CryptoKit 27 | import Foundation 28 | 29 | /// A `Generator` contains all of the parameters needed to generate a one-time password. 30 | public struct Generator: Equatable { 31 | /// The moving factor, either timer- or counter-based. 32 | public let factor: Factor 33 | 34 | /// The secret shared between the client and server. 35 | public let secret: Data 36 | 37 | /// The cryptographic hash function used to generate the password. 38 | public let algorithm: Algorithm 39 | 40 | /// The number of digits in the password. 41 | public let digits: Int 42 | 43 | /// Initializes a new password generator with the given parameters. 44 | /// 45 | /// - parameter factor: The moving factor. 46 | /// - parameter secret: The shared secret. 47 | /// - parameter algorithm: The cryptographic hash function. 48 | /// - parameter digits: The number of digits in the password. 49 | /// 50 | /// - throws: A `Generator.Error` if the given parameters are invalid. 51 | /// - returns: A new password generator with the given parameters. 52 | public init(factor: Factor, secret: Data, algorithm: Algorithm, digits: Int) throws { 53 | try Self.validateFactor(factor) 54 | try Self.validateDigits(digits) 55 | 56 | self.factor = factor 57 | self.secret = secret 58 | self.algorithm = algorithm 59 | self.digits = digits 60 | } 61 | 62 | // MARK: Password Generation 63 | 64 | /// Generates the password for the given point in time. 65 | /// 66 | /// - parameter time: The target time, represented as a `Date`. 67 | /// The time must not be before the Unix epoch. 68 | /// 69 | /// - throws: A `Generator.Error` if a valid password cannot be generated for the given time. 70 | /// - returns: The generated password, or throws an error if a password could not be generated. 71 | public func password(at time: Date) throws -> String { 72 | try Self.validateDigits(digits) 73 | 74 | let counter = try factor.counterValue(at: time) 75 | // Ensure the counter value is big-endian 76 | var bigCounter = counter.bigEndian 77 | 78 | // Generate an HMAC value from the key and counter 79 | let counterData = Data(bytes: &bigCounter, count: MemoryLayout.size) 80 | let hash = Self.generateHMAC(for: counterData, using: secret, with: algorithm) 81 | 82 | var truncatedHash = hash.withUnsafeBytes { ptr -> UInt32 in 83 | // Use the last 4 bits of the hash as an offset (0 <= offset <= 15) 84 | let offset = ptr[hash.count - 1] & 0x0f 85 | 86 | // Take 4 bytes from the hash, starting at the given byte offset 87 | let truncatedHashPtr = ptr.baseAddress! + Int(offset) 88 | return truncatedHashPtr.bindMemory(to: UInt32.self, capacity: 1).pointee 89 | } 90 | 91 | // Ensure the four bytes taken from the hash match the current endian format 92 | truncatedHash = UInt32(bigEndian: truncatedHash) 93 | // Discard the most significant bit 94 | truncatedHash &= 0x7fffffff 95 | // Constrain to the right number of digits 96 | truncatedHash = truncatedHash % UInt32(pow(10, Float(digits))) 97 | 98 | // Pad the string representation with zeros, if necessary 99 | return String(truncatedHash).padded(with: "0", toLength: digits) 100 | } 101 | 102 | private static func generateHMAC(for data: Data, using keyData: Data, with algorithm: Generator.Algorithm) -> Data { 103 | func authenticationCode(with _: H.Type) -> Data { 104 | let key = SymmetricKey(data: keyData) 105 | let authenticationCode = HMAC.authenticationCode(for: data, using: key) 106 | return Data(authenticationCode) 107 | } 108 | 109 | switch algorithm { 110 | case .sha1: 111 | return authenticationCode(with: Insecure.SHA1.self) 112 | case .sha256: 113 | return authenticationCode(with: SHA256.self) 114 | case .sha512: 115 | return authenticationCode(with: SHA512.self) 116 | } 117 | } 118 | 119 | // MARK: Update 120 | 121 | /// Returns a `Generator` configured to generate the *next* password, which follows the password 122 | /// generated by `self`. 123 | /// 124 | /// - requires: The next generator is valid. 125 | public func successor() -> Generator { 126 | switch factor { 127 | case .counter(let counterValue): 128 | // Update a counter-based generator by incrementing the counter. 129 | // Force-trying should be safe here, since any valid generator should have a valid successor. 130 | // swiftlint:disable:next force_try 131 | return try! Generator( 132 | factor: .counter(counterValue + 1), 133 | secret: secret, 134 | algorithm: algorithm, 135 | digits: digits 136 | ) 137 | case .timer: 138 | // A timer-based generator does not need to be updated. 139 | return self 140 | } 141 | } 142 | 143 | // MARK: Nested Types 144 | 145 | /// A moving factor with which a generator produces different one-time passwords over time. 146 | /// The possible values are `Counter` and `Timer`, with associated values for each. 147 | public enum Factor: Equatable { 148 | /// Indicates a HOTP, with an associated 8-byte counter value for the moving factor. After 149 | /// each use of the password generator, the counter should be incremented to stay in sync 150 | /// with the server. 151 | case counter(UInt64) 152 | /// Indicates a TOTP, with an associated time interval for calculating the time-based moving 153 | /// factor. This period value remains constant, and is used as a divisor for the number of 154 | /// seconds since the Unix epoch. 155 | case timer(period: TimeInterval) 156 | 157 | /// Calculates the counter value for the moving factor at the target time. For a counter- 158 | /// based factor, this will be the associated counter value, but for a timer-based factor, 159 | /// it will be the number of time steps since the Unix epoch, based on the associated 160 | /// period value. 161 | /// 162 | /// - parameter time: The target time, represented as a `Date`. 163 | /// The time must not be before the Unix epoch. 164 | /// 165 | /// - throws: A `Generator.Error` if a valid counter cannot be calculated. 166 | /// - returns: The counter value needed to generate the password for the target time. 167 | fileprivate func counterValue(at time: Date) throws -> UInt64 { 168 | switch self { 169 | case .counter(let counter): 170 | return counter 171 | case .timer(let period): 172 | let timeSinceEpoch = time.timeIntervalSince1970 173 | try Generator.validateTime(timeSinceEpoch) 174 | try Generator.validatePeriod(period) 175 | return UInt64(timeSinceEpoch / period) 176 | } 177 | } 178 | } 179 | 180 | /// A cryptographic hash function used to calculate the HMAC from which a password is derived. 181 | /// The supported algorithms are SHA-1, SHA-256, and SHA-512. 182 | public enum Algorithm: Equatable { 183 | /// The SHA-1 hash function. 184 | case sha1 185 | /// The SHA-256 hash function. 186 | case sha256 187 | /// The SHA-512 hash function. 188 | case sha512 189 | } 190 | 191 | /// An error type enum representing the various errors a `Generator` can throw when computing a 192 | /// password. 193 | public enum Error: Swift.Error { 194 | /// The requested time is before the epoch date. 195 | case invalidTime 196 | /// The timer period is not a positive number of seconds. 197 | case invalidPeriod 198 | /// The number of digits is either too short to be secure, or too long to compute. 199 | case invalidDigits 200 | } 201 | } 202 | 203 | // MARK: - Private 204 | 205 | private extension Generator { 206 | // MARK: Validation 207 | 208 | static func validateDigits(_ digits: Int) throws { 209 | // https://tools.ietf.org/html/rfc4226#section-5.3 states "Implementations MUST extract a 210 | // 6-digit code at a minimum and possibly 7 and 8-digit codes." 211 | let acceptableDigits = 6...8 212 | guard acceptableDigits.contains(digits) else { 213 | throw Error.invalidDigits 214 | } 215 | } 216 | 217 | static func validateFactor(_ factor: Factor) throws { 218 | switch factor { 219 | case .counter: 220 | return 221 | case .timer(let period): 222 | try validatePeriod(period) 223 | } 224 | } 225 | 226 | static func validatePeriod(_ period: TimeInterval) throws { 227 | // The period must be positive and non-zero to produce a valid counter value. 228 | guard period > 0 else { 229 | throw Error.invalidPeriod 230 | } 231 | } 232 | 233 | static func validateTime(_ timeSinceEpoch: TimeInterval) throws { 234 | // The time must be positive to produce a valid counter value. 235 | guard timeSinceEpoch >= 0 else { 236 | throw Error.invalidTime 237 | } 238 | } 239 | } 240 | 241 | private extension String { 242 | /// Prepends the given character to the beginning of `self` until it matches the given length. 243 | /// 244 | /// - parameter character: The padding character. 245 | /// - parameter length: The desired length of the padded string. 246 | /// 247 | /// - returns: A new string padded to the given length. 248 | func padded(with character: Character, toLength length: Int) -> String { 249 | let paddingCount = length - count 250 | guard paddingCount > 0 else { 251 | return self 252 | } 253 | 254 | let padding = String(repeating: String(character), count: paddingCount) 255 | return padding + self 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /Sources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | ${EXECUTABLE_NAME} 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | ${PRODUCT_NAME} 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 4.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 4.0.0 23 | NSPrincipalClass 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Sources/Keychain.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Keychain.swift 3 | // OneTimePassword 4 | // 5 | // Copyright (c) 2014-2018 Matt Rubin and the OneTimePassword authors 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | // 25 | 26 | import Foundation 27 | 28 | /// The `Keychain`'s shared instance is a singleton which represents the iOS system keychain used 29 | /// to securely store tokens. 30 | public final class Keychain { 31 | /// The singleton `Keychain` instance. 32 | public static let sharedInstance = Keychain() 33 | 34 | // MARK: Read 35 | 36 | /// Finds the persistent token with the given identifer, if one exists. 37 | /// 38 | /// - parameter identifier: The persistent identifier for the desired token. 39 | /// 40 | /// - throws: A `Keychain.Error` if an error occurred. 41 | /// - returns: The persistent token, or `nil` if no token matched the given identifier. 42 | public func persistentToken(withIdentifier identifier: Data) throws -> PersistentToken? { 43 | return try keychainItem(forPersistentRef: identifier).map(PersistentToken.init(keychainDictionary:)) 44 | } 45 | 46 | /// Returns the set of all persistent tokens found in the keychain. 47 | /// 48 | /// - throws: A `Keychain.Error` if an error occurred. 49 | public func allPersistentTokens() throws -> Set { 50 | let allItems = try allKeychainItems() 51 | // This code intentionally ignores items which fail deserialization, instead opting to return as many readable 52 | // tokens as possible. 53 | // TODO: Restore deserialization error handling, in a way that provides info on the failure reason and allows 54 | // the caller to choose whether to fail completely or recover some data. 55 | return Set(allItems.compactMap({ try? PersistentToken(keychainDictionary: $0) })) 56 | } 57 | 58 | // MARK: Write 59 | 60 | /// Adds the given token to the keychain and returns the persistent token which contains it. 61 | /// 62 | /// - parameter token: The token to save to the keychain. 63 | /// 64 | /// - throws: A `Keychain.Error` if the token was not added successfully. 65 | /// - returns: The new persistent token. 66 | public func add(_ token: Token) throws -> PersistentToken { 67 | let attributes = try token.keychainAttributes() 68 | let persistentRef = try addKeychainItem(withAttributes: attributes) 69 | return PersistentToken(token: token, identifier: persistentRef) 70 | } 71 | 72 | /// Updates the given persistent token with a new token value. 73 | /// 74 | /// - parameter persistentToken: The persistent token to update. 75 | /// - parameter token: The new token value. 76 | /// 77 | /// - throws: A `Keychain.Error` if the update did not succeed. 78 | /// - returns: The updated persistent token. 79 | public func update(_ persistentToken: PersistentToken, with token: Token) throws -> PersistentToken { 80 | let attributes = try token.keychainAttributes() 81 | try updateKeychainItem(forPersistentRef: persistentToken.identifier, 82 | withAttributes: attributes) 83 | return PersistentToken(token: token, identifier: persistentToken.identifier) 84 | } 85 | 86 | /// Deletes the given persistent token from the keychain. 87 | /// 88 | /// - note: After calling `deletePersistentToken(_:)`, the persistent token's `identifier` is no 89 | /// longer valid, and the token should be discarded. 90 | /// 91 | /// - parameter persistentToken: The persistent token to delete. 92 | /// 93 | /// - throws: A `Keychain.Error` if the deletion did not succeed. 94 | public func delete(_ persistentToken: PersistentToken) throws { 95 | try deleteKeychainItem(forPersistentRef: persistentToken.identifier) 96 | } 97 | 98 | // MARK: Errors 99 | 100 | /// An error type enum representing the various errors a `Keychain` operation can throw. 101 | public enum Error: Swift.Error { 102 | /// The keychain operation returned a system error code. 103 | case systemError(OSStatus) 104 | /// The keychain operation returned an unexpected type of data. 105 | case incorrectReturnType 106 | /// The given token could not be serialized to keychain data. 107 | case tokenSerializationFailure 108 | } 109 | } 110 | 111 | // MARK: - Private 112 | 113 | private let kOTPService = "me.mattrubin.onetimepassword.token" 114 | private let urlStringEncoding = String.Encoding.utf8 115 | 116 | private extension Token { 117 | func keychainAttributes() throws -> [String: AnyObject] { 118 | let url = try self.toURL() 119 | guard let data = url.absoluteString.data(using: urlStringEncoding) else { 120 | throw Keychain.Error.tokenSerializationFailure 121 | } 122 | return [ 123 | kSecAttrGeneric as String: data as NSData, 124 | kSecValueData as String: generator.secret as NSData, 125 | kSecAttrService as String: kOTPService as NSString, 126 | ] 127 | } 128 | } 129 | 130 | private extension PersistentToken { 131 | enum DeserializationError: Error { 132 | case missingData 133 | case missingSecret 134 | case missingPersistentRef 135 | case unreadableData 136 | } 137 | 138 | init(keychainDictionary: NSDictionary) throws { 139 | guard let urlData = keychainDictionary[kSecAttrGeneric as String] as? Data else { 140 | throw DeserializationError.missingData 141 | } 142 | guard let secret = keychainDictionary[kSecValueData as String] as? Data else { 143 | throw DeserializationError.missingSecret 144 | } 145 | guard let keychainItemRef = keychainDictionary[kSecValuePersistentRef as String] as? Data else { 146 | throw DeserializationError.missingPersistentRef 147 | } 148 | guard let urlString = String(data: urlData, encoding: urlStringEncoding), 149 | let url = URL(string: urlString) else { 150 | throw DeserializationError.unreadableData 151 | } 152 | let token = try Token(url: url, secret: secret) 153 | self.init(token: token, identifier: keychainItemRef) 154 | } 155 | } 156 | 157 | private func addKeychainItem(withAttributes attributes: [String: AnyObject]) throws -> Data { 158 | var mutableAttributes = attributes 159 | mutableAttributes[kSecClass as String] = kSecClassGenericPassword 160 | mutableAttributes[kSecReturnPersistentRef as String] = kCFBooleanTrue 161 | // Set a random string for the account name. 162 | // We never query by or display this value, but the keychain requires it to be unique. 163 | if mutableAttributes[kSecAttrAccount as String] == nil { 164 | mutableAttributes[kSecAttrAccount as String] = UUID().uuidString as NSString 165 | } 166 | 167 | var result: AnyObject? 168 | let resultCode: OSStatus = withUnsafeMutablePointer(to: &result) { 169 | SecItemAdd(mutableAttributes as CFDictionary, $0) 170 | } 171 | 172 | guard resultCode == errSecSuccess else { 173 | throw Keychain.Error.systemError(resultCode) 174 | } 175 | guard let persistentRef = result as? Data else { 176 | throw Keychain.Error.incorrectReturnType 177 | } 178 | return persistentRef 179 | } 180 | 181 | private func updateKeychainItem(forPersistentRef persistentRef: Data, 182 | withAttributes attributesToUpdate: [String: AnyObject]) throws { 183 | let queryDict: [String: AnyObject] = [ 184 | kSecClass as String: kSecClassGenericPassword, 185 | kSecValuePersistentRef as String: persistentRef as NSData, 186 | ] 187 | 188 | let resultCode = SecItemUpdate(queryDict as CFDictionary, attributesToUpdate as CFDictionary) 189 | 190 | guard resultCode == errSecSuccess else { 191 | throw Keychain.Error.systemError(resultCode) 192 | } 193 | } 194 | 195 | private func deleteKeychainItem(forPersistentRef persistentRef: Data) throws { 196 | let queryDict: [String: AnyObject] = [ 197 | kSecClass as String: kSecClassGenericPassword, 198 | kSecValuePersistentRef as String: persistentRef as NSData, 199 | ] 200 | 201 | let resultCode = SecItemDelete(queryDict as CFDictionary) 202 | 203 | guard resultCode == errSecSuccess else { 204 | throw Keychain.Error.systemError(resultCode) 205 | } 206 | } 207 | 208 | private func keychainItem(forPersistentRef persistentRef: Data) throws -> NSDictionary? { 209 | let queryDict: [String: AnyObject] = [ 210 | kSecClass as String: kSecClassGenericPassword, 211 | kSecValuePersistentRef as String: persistentRef as NSData, 212 | kSecReturnPersistentRef as String: kCFBooleanTrue, 213 | kSecReturnAttributes as String: kCFBooleanTrue, 214 | kSecReturnData as String: kCFBooleanTrue, 215 | ] 216 | 217 | var result: AnyObject? 218 | let resultCode = withUnsafeMutablePointer(to: &result) { 219 | SecItemCopyMatching(queryDict as CFDictionary, $0) 220 | } 221 | 222 | if resultCode == errSecItemNotFound { 223 | // Not finding any keychain items is not an error in this case. Return nil. 224 | return nil 225 | } 226 | guard resultCode == errSecSuccess else { 227 | throw Keychain.Error.systemError(resultCode) 228 | } 229 | guard let keychainItem = result as? NSDictionary else { 230 | throw Keychain.Error.incorrectReturnType 231 | } 232 | return keychainItem 233 | } 234 | 235 | private func allKeychainItems() throws -> [NSDictionary] { 236 | let queryDict: [String: AnyObject] = [ 237 | kSecClass as String: kSecClassGenericPassword, 238 | kSecMatchLimit as String: kSecMatchLimitAll, 239 | kSecReturnPersistentRef as String: kCFBooleanTrue, 240 | kSecReturnAttributes as String: kCFBooleanTrue, 241 | kSecReturnData as String: kCFBooleanTrue, 242 | ] 243 | 244 | var result: AnyObject? 245 | let resultCode = withUnsafeMutablePointer(to: &result) { 246 | SecItemCopyMatching(queryDict as CFDictionary, $0) 247 | } 248 | 249 | if resultCode == errSecItemNotFound { 250 | // Not finding any keychain items is not an error in this case. Return an empty array. 251 | return [] 252 | } 253 | guard resultCode == errSecSuccess else { 254 | throw Keychain.Error.systemError(resultCode) 255 | } 256 | guard let keychainItems = result as? [NSDictionary] else { 257 | throw Keychain.Error.incorrectReturnType 258 | } 259 | return keychainItems 260 | } 261 | -------------------------------------------------------------------------------- /Sources/PersistentToken.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistentToken.swift 3 | // OneTimePassword 4 | // 5 | // Copyright (c) 2014-2018 Matt Rubin and the OneTimePassword authors 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | // 25 | 26 | import Foundation 27 | 28 | /// A `PersistentToken` represents a `Token` stored in the `Keychain`. The keychain assigns each 29 | /// saved `token` a unique `identifier` which can be used to recover the token from the keychain at 30 | /// a later time. 31 | public struct PersistentToken: Equatable, Hashable { 32 | /// A `Token` stored in the keychain. 33 | public let token: Token 34 | /// The keychain's persistent identifier for the saved token. 35 | public let identifier: Data 36 | 37 | /// Hashes the persistent token's identifier into the given hasher, providing `Hashable` conformance. 38 | public func hash(into hasher: inout Hasher) { 39 | // Since we expect every `PersistentToken`s identifier to be unique, the identifier's hash 40 | // value makes a simple and adequate hash value for the struct as a whole. 41 | hasher.combine(identifier) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/Token+URL.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Token+URL.swift 3 | // OneTimePassword 4 | // 5 | // Copyright (c) 2014-2018 Matt Rubin and the OneTimePassword authors 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | // 25 | 26 | import Foundation 27 | import Base32 28 | 29 | public extension Token { 30 | // MARK: Serialization 31 | 32 | /// Serializes the token to a URL. 33 | func toURL() throws -> URL { 34 | return try urlForToken( 35 | name: name, 36 | issuer: issuer, 37 | factor: generator.factor, 38 | algorithm: generator.algorithm, 39 | digits: generator.digits 40 | ) 41 | } 42 | 43 | /// Attempts to initialize a token represented by the given URL. 44 | /// 45 | /// - throws: A `DeserializationError` if a token could not be built from the given parameters. 46 | /// - returns: A `Token` built from the given URL and secret. 47 | init(url: URL, secret: Data? = nil) throws { 48 | self = try token(from: url, secret: secret) 49 | } 50 | } 51 | 52 | internal enum SerializationError: Swift.Error { 53 | case urlGenerationFailure 54 | } 55 | 56 | internal enum DeserializationError: Swift.Error { 57 | case invalidURLScheme 58 | case duplicateQueryItem(String) 59 | case missingFactor 60 | case invalidFactor(String) 61 | case invalidCounterValue(String) 62 | case invalidTimerPeriod(String) 63 | case missingSecret 64 | case invalidSecret(String) 65 | case invalidAlgorithm(String) 66 | case invalidDigits(String) 67 | } 68 | 69 | private let defaultAlgorithm: Generator.Algorithm = .sha1 70 | private let defaultDigits: Int = 6 71 | private let defaultCounter: UInt64 = 0 72 | private let defaultPeriod: TimeInterval = 30 73 | 74 | private let kOTPAuthScheme = "otpauth" 75 | private let kQueryAlgorithmKey = "algorithm" 76 | private let kQuerySecretKey = "secret" 77 | private let kQueryCounterKey = "counter" 78 | private let kQueryDigitsKey = "digits" 79 | private let kQueryPeriodKey = "period" 80 | private let kQueryIssuerKey = "issuer" 81 | 82 | private let kFactorCounterKey = "hotp" 83 | private let kFactorTimerKey = "totp" 84 | 85 | private let kAlgorithmSHA1 = "SHA1" 86 | private let kAlgorithmSHA256 = "SHA256" 87 | private let kAlgorithmSHA512 = "SHA512" 88 | 89 | private func stringForAlgorithm(_ algorithm: Generator.Algorithm) -> String { 90 | switch algorithm { 91 | case .sha1: 92 | return kAlgorithmSHA1 93 | case .sha256: 94 | return kAlgorithmSHA256 95 | case .sha512: 96 | return kAlgorithmSHA512 97 | } 98 | } 99 | 100 | private func algorithmFromString(_ string: String) throws -> Generator.Algorithm { 101 | switch string { 102 | case kAlgorithmSHA1: 103 | return .sha1 104 | case kAlgorithmSHA256: 105 | return .sha256 106 | case kAlgorithmSHA512: 107 | return .sha512 108 | default: 109 | throw DeserializationError.invalidAlgorithm(string) 110 | } 111 | } 112 | 113 | private func urlForToken(name: String, issuer: String, factor: Generator.Factor, algorithm: Generator.Algorithm, digits: Int) throws -> URL { 114 | var urlComponents = URLComponents() 115 | urlComponents.scheme = kOTPAuthScheme 116 | urlComponents.path = "/" + name 117 | 118 | var queryItems = [ 119 | URLQueryItem(name: kQueryAlgorithmKey, value: stringForAlgorithm(algorithm)), 120 | URLQueryItem(name: kQueryDigitsKey, value: String(digits)), 121 | URLQueryItem(name: kQueryIssuerKey, value: issuer), 122 | ] 123 | 124 | switch factor { 125 | case .timer(let period): 126 | urlComponents.host = kFactorTimerKey 127 | queryItems.append(URLQueryItem(name: kQueryPeriodKey, value: String(Int(period)))) 128 | case .counter(let counter): 129 | urlComponents.host = kFactorCounterKey 130 | queryItems.append(URLQueryItem(name: kQueryCounterKey, value: String(counter))) 131 | } 132 | 133 | urlComponents.queryItems = queryItems 134 | 135 | guard let url = urlComponents.url else { 136 | throw SerializationError.urlGenerationFailure 137 | } 138 | return url 139 | } 140 | 141 | private func token(from url: URL, secret externalSecret: Data? = nil) throws -> Token { 142 | guard url.scheme == kOTPAuthScheme else { 143 | throw DeserializationError.invalidURLScheme 144 | } 145 | 146 | let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] 147 | 148 | let factor: Generator.Factor 149 | switch url.host { 150 | case .some(kFactorCounterKey): 151 | let counterValue = try queryItems.value(for: kQueryCounterKey).map(parseCounterValue) ?? defaultCounter 152 | factor = .counter(counterValue) 153 | case .some(kFactorTimerKey): 154 | let period = try queryItems.value(for: kQueryPeriodKey).map(parseTimerPeriod) ?? defaultPeriod 155 | factor = .timer(period: period) 156 | case let .some(rawValue): 157 | throw DeserializationError.invalidFactor(rawValue) 158 | case .none: 159 | throw DeserializationError.missingFactor 160 | } 161 | 162 | let algorithm = try queryItems.value(for: kQueryAlgorithmKey).map(algorithmFromString) ?? defaultAlgorithm 163 | let digits = try queryItems.value(for: kQueryDigitsKey).map(parseDigits) ?? defaultDigits 164 | guard let secret = try externalSecret ?? queryItems.value(for: kQuerySecretKey).map(parseSecret) else { 165 | throw DeserializationError.missingSecret 166 | } 167 | let generator = try Generator(factor: factor, secret: secret, algorithm: algorithm, digits: digits) 168 | 169 | // Skip the leading "/" 170 | let fullName = String(url.path.dropFirst()) 171 | 172 | let issuer: String 173 | if let issuerString = try queryItems.value(for: kQueryIssuerKey) { 174 | issuer = issuerString 175 | } else if let separatorRange = fullName.range(of: ":") { 176 | // If there is no issuer string, try to extract one from the name 177 | issuer = String(fullName[.. UInt64 { 190 | guard let counterValue = UInt64(rawValue) else { 191 | throw DeserializationError.invalidCounterValue(rawValue) 192 | } 193 | return counterValue 194 | } 195 | 196 | private func parseTimerPeriod(_ rawValue: String) throws -> TimeInterval { 197 | guard let period = TimeInterval(rawValue) else { 198 | throw DeserializationError.invalidTimerPeriod(rawValue) 199 | } 200 | return period 201 | } 202 | 203 | private func parseSecret(_ rawValue: String) throws -> Data { 204 | guard let secret = MF_Base32Codec.data(fromBase32String: rawValue) else { 205 | throw DeserializationError.invalidSecret(rawValue) 206 | } 207 | return secret 208 | } 209 | 210 | private func parseDigits(_ rawValue: String) throws -> Int { 211 | guard let digits = Int(rawValue) else { 212 | throw DeserializationError.invalidDigits(rawValue) 213 | } 214 | return digits 215 | } 216 | 217 | private func shortName(byTrimming issuer: String, from fullName: String) -> String { 218 | if !issuer.isEmpty { 219 | let prefix = issuer + ":" 220 | if fullName.hasPrefix(prefix), let prefixRange = fullName.range(of: prefix) { 221 | let substringAfterSeparator = fullName[prefixRange.upperBound...] 222 | return substringAfterSeparator.trimmingCharacters(in: CharacterSet.whitespaces) 223 | } 224 | } 225 | return String(fullName) 226 | } 227 | 228 | extension Array where Element == URLQueryItem { 229 | func value(for name: String) throws -> String? { 230 | let matchingQueryItems = self.filter({ 231 | $0.name == name 232 | }) 233 | guard matchingQueryItems.count <= 1 else { 234 | throw DeserializationError.duplicateQueryItem(name) 235 | } 236 | return matchingQueryItems.first?.value 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /Sources/Token.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Token.swift 3 | // OneTimePassword 4 | // 5 | // Copyright (c) 2014-2018 Matt Rubin and the OneTimePassword authors 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | // 25 | 26 | import Foundation 27 | 28 | /// A `Token` contains a password generator and information identifying the corresponding account. 29 | public struct Token: Equatable { 30 | /// A string indicating the account represented by the token. 31 | /// This is often an email address or username. 32 | public let name: String 33 | 34 | /// A string indicating the provider or service which issued the token. 35 | public let issuer: String 36 | 37 | /// A password generator containing this token's secret, algorithm, etc. 38 | public let generator: Generator 39 | 40 | /// Initializes a new token with the given parameters. 41 | /// 42 | /// - parameter name: The account name for the token (defaults to ""). 43 | /// - parameter issuer: The entity which issued the token (defaults to ""). 44 | /// - parameter generator: The password generator. 45 | /// 46 | /// - returns: A new token with the given parameters. 47 | public init(name: String = "", issuer: String = "", generator: Generator) { 48 | self.name = name 49 | self.issuer = issuer 50 | self.generator = generator 51 | } 52 | 53 | // MARK: Password Generation 54 | 55 | /// Calculates the current password based on the token's generator. The password generated will 56 | /// be consistent for a counter-based token, but for a timer-based token the password will 57 | /// depend on the current time when this property is accessed. 58 | /// 59 | /// - returns: The current password, or `nil` if a password could not be generated. 60 | public var currentPassword: String? { 61 | let currentTime = Date() 62 | return try? generator.password(at: currentTime) 63 | } 64 | 65 | // MARK: Update 66 | 67 | /// - returns: A new `Token`, configured to generate the next password. 68 | public func updatedToken() -> Token { 69 | return Token(name: name, issuer: issuer, generator: generator.successor()) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Tests/App/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // OneTimePassword 4 | // 5 | // Copyright (c) 2016 Matt Rubin and the OneTimePassword authors 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | // 25 | 26 | import UIKit 27 | 28 | @UIApplicationMain 29 | class AppDelegate: UIResponder, UIApplicationDelegate { 30 | } 31 | -------------------------------------------------------------------------------- /Tests/App/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | Launch Screen 25 | 26 | 27 | -------------------------------------------------------------------------------- /Tests/App/Launch Screen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /Tests/EquatableTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EquatableTests.swift 3 | // OneTimePassword 4 | // 5 | // Copyright (c) 2014-2019 Matt Rubin and the OneTimePassword authors 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | // 25 | 26 | import XCTest 27 | import OneTimePassword 28 | 29 | class EquatableTests: XCTestCase { 30 | func testFactorEquality() { 31 | let smallCounter = Generator.Factor.counter(30) 32 | let bigCounter = Generator.Factor.counter(60) 33 | let shortTimer = Generator.Factor.timer(period: 30) 34 | let longTimer = Generator.Factor.timer(period: 60) 35 | 36 | XCTAssertEqual(smallCounter, smallCounter) 37 | XCTAssertEqual(bigCounter, bigCounter) 38 | XCTAssertNotEqual(smallCounter, bigCounter) 39 | XCTAssertNotEqual(bigCounter, smallCounter) 40 | 41 | XCTAssertEqual(shortTimer, shortTimer) 42 | XCTAssertEqual(longTimer, longTimer) 43 | XCTAssertNotEqual(shortTimer, longTimer) 44 | XCTAssertNotEqual(longTimer, shortTimer) 45 | 46 | XCTAssertNotEqual(smallCounter, shortTimer) 47 | XCTAssertNotEqual(smallCounter, longTimer) 48 | XCTAssertNotEqual(bigCounter, shortTimer) 49 | XCTAssertNotEqual(bigCounter, longTimer) 50 | 51 | XCTAssertNotEqual(shortTimer, smallCounter) 52 | XCTAssertNotEqual(shortTimer, bigCounter) 53 | XCTAssertNotEqual(longTimer, smallCounter) 54 | XCTAssertNotEqual(longTimer, bigCounter) 55 | } 56 | 57 | func testGeneratorEquality() throws { 58 | let generator = try Generator(factor: .counter(0), secret: Data(), algorithm: .sha1, digits: 6) 59 | let badData = Data("0".utf8) 60 | 61 | XCTAssert(try generator == Generator(factor: .counter(0), secret: Data(), algorithm: .sha1, digits: 6)) 62 | XCTAssert(try generator != Generator(factor: .counter(1), secret: Data(), algorithm: .sha1, digits: 6)) 63 | XCTAssert(try generator != Generator(factor: .counter(0), secret: badData, algorithm: .sha1, digits: 6)) 64 | XCTAssert(try generator != Generator(factor: .counter(0), secret: Data(), algorithm: .sha256, digits: 6)) 65 | XCTAssert(try generator != Generator(factor: .counter(0), secret: Data(), algorithm: .sha1, digits: 8)) 66 | } 67 | 68 | func testTokenEquality() throws { 69 | let generator = try Generator(factor: .counter(0), secret: Data(), algorithm: .sha1, digits: 6) 70 | let otherGenerator = try Generator(factor: .counter(1), secret: Data(), algorithm: .sha512, digits: 8) 71 | 72 | let token = Token(name: "Name", issuer: "Issuer", generator: generator) 73 | 74 | XCTAssertEqual(token, Token(name: "Name", issuer: "Issuer", generator: generator)) 75 | XCTAssertNotEqual(token, Token(name: "", issuer: "Issuer", generator: generator)) 76 | XCTAssertNotEqual(token, Token(name: "Name", issuer: "", generator: generator)) 77 | XCTAssertNotEqual(token, Token(name: "Name", issuer: "Issuer", generator: otherGenerator)) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Tests/GeneratorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneratorTests.swift 3 | // OneTimePassword 4 | // 5 | // Copyright (c) 2014-2019 Matt Rubin and the OneTimePassword authors 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | // 25 | 26 | import XCTest 27 | import OneTimePassword 28 | 29 | class GeneratorTests: XCTestCase { 30 | func testInit() throws { 31 | // Create a generator 32 | let factor = OneTimePassword.Generator.Factor.counter(111) 33 | let secret = "12345678901234567890".data(using: String.Encoding.ascii)! 34 | let algorithm = Generator.Algorithm.sha256 35 | let digits = 8 36 | 37 | let generator = try Generator( 38 | factor: factor, 39 | secret: secret, 40 | algorithm: algorithm, 41 | digits: digits 42 | ) 43 | 44 | XCTAssertEqual(generator.factor, factor) 45 | XCTAssertEqual(generator.secret, secret) 46 | XCTAssertEqual(generator.algorithm, algorithm) 47 | XCTAssertEqual(generator.digits, digits) 48 | 49 | // Create another generator 50 | let otherFactor = OneTimePassword.Generator.Factor.timer(period: 123) 51 | let otherSecret = "09876543210987654321".data(using: String.Encoding.ascii)! 52 | let otherAlgorithm = Generator.Algorithm.sha512 53 | let otherDigits = 7 54 | 55 | let otherGenerator = try Generator( 56 | factor: otherFactor, 57 | secret: otherSecret, 58 | algorithm: otherAlgorithm, 59 | digits: otherDigits 60 | ) 61 | 62 | XCTAssertEqual(otherGenerator.factor, otherFactor) 63 | XCTAssertEqual(otherGenerator.secret, otherSecret) 64 | XCTAssertEqual(otherGenerator.algorithm, otherAlgorithm) 65 | XCTAssertEqual(otherGenerator.digits, otherDigits) 66 | 67 | // Ensure the generators are different 68 | XCTAssertNotEqual(generator.factor, otherGenerator.factor) 69 | XCTAssertNotEqual(generator.secret, otherGenerator.secret) 70 | XCTAssertNotEqual(generator.algorithm, otherGenerator.algorithm) 71 | XCTAssertNotEqual(generator.digits, otherGenerator.digits) 72 | } 73 | 74 | func testCounter() throws { 75 | // swiftlint:disable:next large_tuple 76 | let factors: [(TimeInterval, TimeInterval, UInt64)] = [ 77 | // swiftlint:disable comma 78 | (100, 30, 3), 79 | (10000, 30, 333), 80 | (1000000, 30, 33333), 81 | (100000000, 60, 1666666), 82 | (10000000000, 90, 111111111), 83 | // swiftlint:enable comma 84 | ] 85 | 86 | for (timeSinceEpoch, period, count) in factors { 87 | let time = Date(timeIntervalSince1970: timeSinceEpoch) 88 | let timer = Generator.Factor.timer(period: period) 89 | let counter = Generator.Factor.counter(count) 90 | let secret = "12345678901234567890".data(using: String.Encoding.ascii)! 91 | let hotp = try Generator(factor: counter, secret: secret, algorithm: .sha1, digits: 6).password(at: time) 92 | let totp = try Generator(factor: timer, secret: secret, algorithm: .sha1, digits: 6).password(at: time) 93 | XCTAssertEqual(hotp, totp, 94 | "TOTP with \(timer) should match HOTP with counter \(counter) at time \(time).") 95 | } 96 | } 97 | 98 | func testValidation() { 99 | let digitTests: [(Int, Bool)] = [ 100 | (-6, false), 101 | (0, false), 102 | (1, false), 103 | (5, false), 104 | (6, true), 105 | (7, true), 106 | (8, true), 107 | (9, false), 108 | (10, false), 109 | ] 110 | 111 | let periodTests: [(TimeInterval, Bool)] = [ 112 | (-30, false), 113 | (0, false), 114 | (1, true), 115 | (30, true), 116 | (300, true), 117 | (301, true), 118 | ] 119 | 120 | for (digits, digitsAreValid) in digitTests { 121 | let generator = try? Generator( 122 | factor: .counter(0), 123 | secret: Data(), 124 | algorithm: .sha1, 125 | digits: digits 126 | ) 127 | // If the digits are invalid, password generation should throw an error 128 | let generatorIsValid = digitsAreValid 129 | if generatorIsValid { 130 | XCTAssertNotNil(generator) 131 | } else { 132 | XCTAssertNil(generator) 133 | } 134 | 135 | for (period, periodIsValid) in periodTests { 136 | let generator = try? Generator( 137 | factor: .timer(period: period), 138 | secret: Data(), 139 | algorithm: .sha1, 140 | digits: digits 141 | ) 142 | // If the digits or period are invalid, password generation should throw an error 143 | let generatorIsValid = digitsAreValid && periodIsValid 144 | if generatorIsValid { 145 | XCTAssertNotNil(generator) 146 | } else { 147 | XCTAssertNil(generator) 148 | } 149 | } 150 | } 151 | } 152 | 153 | func testPasswordAtInvalidTime() throws { 154 | let generator = try Generator( 155 | factor: .timer(period: 30), 156 | secret: Data(), 157 | algorithm: .sha1, 158 | digits: 6 159 | ) 160 | 161 | let badTime = Date(timeIntervalSince1970: -100) 162 | XCTAssertThrowsError(try generator.password(at: badTime)) { error in 163 | guard case Generator.Error.invalidTime = error else { 164 | XCTFail("password(at: \(badTime)) threw an unexpected type of error: \(error)") 165 | return 166 | } 167 | } 168 | } 169 | 170 | func testPasswordWithInvalidPeriod() { 171 | // It should not be possible to try to get a password from a generator with an invalid period, because the 172 | // generator initializer should fail when given an invalid period. 173 | XCTAssertThrowsError( 174 | try Generator(factor: .timer(period: 0), secret: Data(), algorithm: .sha1, digits: 8) 175 | ) { error in 176 | guard case Generator.Error.invalidPeriod = error else { 177 | XCTFail("Generator.init threw an unexpected type of error: \(error)") 178 | return 179 | } 180 | } 181 | } 182 | 183 | func testPasswordWithInvalidDigits() { 184 | // It should not be possible to try to get a password from a generator with an invalid digit count, because the 185 | // generator initializer should fail when given an invalid digit count. 186 | XCTAssertThrowsError( 187 | try Generator(factor: .timer(period: 30), secret: Data(), algorithm: .sha1, digits: 3) 188 | ) { error in 189 | guard case Generator.Error.invalidDigits = error else { 190 | XCTFail("Generator.init threw an unexpected type of error: \(error)") 191 | return 192 | } 193 | } 194 | } 195 | 196 | // The values in this test are found in Appendix D of the HOTP RFC 197 | // https://tools.ietf.org/html/rfc4226#appendix-D 198 | func testHOTPRFCValues() throws { 199 | let secret = "12345678901234567890".data(using: String.Encoding.ascii)! 200 | let expectedValues: [UInt64: String] = [ 201 | 0: "755224", 202 | 1: "287082", 203 | 2: "359152", 204 | 3: "969429", 205 | 4: "338314", 206 | 5: "254676", 207 | 6: "287922", 208 | 7: "162583", 209 | 8: "399871", 210 | 9: "520489", 211 | ] 212 | for (counter, expectedPassword) in expectedValues { 213 | let generator = try Generator(factor: .counter(counter), secret: secret, algorithm: .sha1, digits: 6) 214 | let time = Date(timeIntervalSince1970: 0) 215 | let password = try generator.password(at: time) 216 | XCTAssertEqual(password, expectedPassword, 217 | "The generator did not produce the expected OTP.") 218 | } 219 | } 220 | 221 | // The values in this test are found in Appendix B of the TOTP RFC 222 | // https://tools.ietf.org/html/rfc6238#appendix-B 223 | func testTOTPRFCValues() throws { 224 | let secretKeys: [Generator.Algorithm: String] = [ 225 | .sha1: "12345678901234567890", 226 | .sha256: "12345678901234567890123456789012", 227 | .sha512: "1234567890123456789012345678901234567890123456789012345678901234", 228 | ] 229 | 230 | let timesSinceEpoch: [TimeInterval] = [59, 1111111109, 1111111111, 1234567890, 2000000000, 20000000000] 231 | 232 | let expectedValues: [Generator.Algorithm: [String]] = [ 233 | .sha1: ["94287082", "07081804", "14050471", "89005924", "69279037", "65353130"], 234 | .sha256: ["46119246", "68084774", "67062674", "91819424", "90698825", "77737706"], 235 | .sha512: ["90693936", "25091201", "99943326", "93441116", "38618901", "47863826"], 236 | ] 237 | 238 | for (algorithm, secretKey) in secretKeys { 239 | let secret = secretKey.data(using: String.Encoding.ascii)! 240 | let generator = try Generator(factor: .timer(period: 30), secret: secret, algorithm: algorithm, digits: 8) 241 | 242 | for (timeSinceEpoch, expectedPassword) in zip(timesSinceEpoch, expectedValues[algorithm]!) { 243 | let time = Date(timeIntervalSince1970: timeSinceEpoch) 244 | let password = try generator.password(at: time) 245 | XCTAssertEqual(password, expectedPassword, 246 | "Incorrect result for \(algorithm) at \(timeSinceEpoch)") 247 | } 248 | } 249 | } 250 | 251 | // From Google Authenticator for iOS 252 | // https://code.google.com/p/google-authenticator/source/browse/mobile/ios/Classes/TOTPGeneratorTest.m 253 | func testTOTPGoogleValues() throws { 254 | let secret = "12345678901234567890".data(using: String.Encoding.ascii)! 255 | let timesSinceEpoch: [TimeInterval] = [1111111111, 1234567890, 2000000000] 256 | 257 | let expectedValues: [Generator.Algorithm: [String]] = [ 258 | .sha1: ["050471", "005924", "279037"], 259 | .sha256: ["584430", "829826", "428693"], 260 | .sha512: ["380122", "671578", "464532"], 261 | ] 262 | 263 | for (algorithm, expectedPasswords) in expectedValues { 264 | let generator = try Generator(factor: .timer(period: 30), secret: secret, algorithm: algorithm, digits: 6) 265 | for (timeSinceEpoch, expectedPassword) in zip(timesSinceEpoch, expectedPasswords) { 266 | let time = Date(timeIntervalSince1970: timeSinceEpoch) 267 | let password = try generator.password(at: time) 268 | XCTAssertEqual(password, expectedPassword, 269 | "Incorrect result for \(algorithm) at \(timeSinceEpoch)") 270 | } 271 | } 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | ${EXECUTABLE_NAME} 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | ${PRODUCT_NAME} 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /Tests/KeychainTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeychainTests.swift 3 | // OneTimePassword 4 | // 5 | // Copyright (c) 2013-2018 Matt Rubin and the OneTimePassword authors 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | // 25 | 26 | import XCTest 27 | import OneTimePassword 28 | import Base32 29 | 30 | let testToken = Token( 31 | name: "Name", 32 | issuer: "Issuer", 33 | // swiftlint:disable:next force_try 34 | generator: try! Generator( 35 | factor: .timer(period: 45), 36 | secret: MF_Base32Codec.data(fromBase32String: "AAAQEAYEAUDAOCAJBIFQYDIOB4"), 37 | algorithm: .sha256, 38 | digits: 8 39 | ) 40 | ) 41 | 42 | class KeychainTests: XCTestCase { 43 | let keychain = Keychain.sharedInstance 44 | 45 | func testPersistentTokenWithIdentifier() { 46 | // Create a token 47 | let token = testToken 48 | 49 | // Save the token 50 | let savedToken: PersistentToken 51 | do { 52 | savedToken = try keychain.add(token) 53 | } catch { 54 | XCTFail("addToken(_:) failed with error: \(error)") 55 | return 56 | } 57 | 58 | // Restore the token 59 | do { 60 | let fetchedToken = try keychain.persistentToken(withIdentifier: savedToken.identifier) 61 | XCTAssertEqual(fetchedToken, savedToken, "Token should have been saved to keychain") 62 | } catch { 63 | XCTFail("persistentTokenWithIdentifier(_:) failed with error: \(error)") 64 | } 65 | 66 | // Modify the token 67 | let modifiedToken = Token( 68 | name: "New Name", 69 | issuer: "New Issuer", 70 | generator: token.generator.successor() 71 | ) 72 | do { 73 | let updatedToken = try keychain.update(savedToken, with: modifiedToken) 74 | XCTAssertEqual(updatedToken.identifier, savedToken.identifier) 75 | XCTAssertEqual(updatedToken.token, modifiedToken) 76 | } catch { 77 | XCTFail("updatePersistentToken(_:withToken:) failed with error: \(error)") 78 | } 79 | 80 | // Fetch the token again 81 | do { 82 | let fetchedToken = try keychain.persistentToken(withIdentifier: savedToken.identifier) 83 | XCTAssertEqual(fetchedToken?.token, modifiedToken) 84 | XCTAssertEqual(fetchedToken?.identifier, savedToken.identifier) 85 | } catch { 86 | XCTFail("persistentTokenWithIdentifier(_:) failed with error: \(error)") 87 | } 88 | 89 | // Remove the token 90 | do { 91 | try keychain.delete(savedToken) 92 | } catch { 93 | XCTFail("deletePersistentToken(_:) failed with error: \(error)") 94 | } 95 | 96 | // Attempt to restore the deleted token 97 | do { 98 | let fetchedToken = try keychain.persistentToken(withIdentifier: savedToken.identifier) 99 | XCTAssertNil(fetchedToken, "Token should have been removed from keychain") 100 | } catch { 101 | XCTFail("persistentTokenWithIdentifier(_:) failed with error: \(error)") 102 | } 103 | } 104 | 105 | // swiftlint:disable:next function_body_length 106 | func testDuplicateTokens() { 107 | let token1 = testToken, token2 = testToken 108 | 109 | // Add both tokens to the keychain 110 | let savedItem1: PersistentToken 111 | let savedItem2: PersistentToken 112 | do { 113 | savedItem1 = try keychain.add(token1) 114 | savedItem2 = try keychain.add(token2) 115 | XCTAssertEqual(savedItem1.token, token1) 116 | XCTAssertEqual(savedItem2.token, token2) 117 | } catch { 118 | XCTFail("addToken(_:) failed with error: \(error)") 119 | return 120 | } 121 | 122 | // Fetch both tokens from the keychain 123 | do { 124 | let fetchedItem1 = try keychain.persistentToken(withIdentifier: savedItem1.identifier) 125 | let fetchedItem2 = try keychain.persistentToken(withIdentifier: savedItem2.identifier) 126 | XCTAssertEqual(fetchedItem1, savedItem1, "Saved token not found in keychain") 127 | XCTAssertEqual(fetchedItem2, savedItem2, "Saved token not found in keychain") 128 | } catch { 129 | XCTFail("persistentTokenWithIdentifier(_:) failed with error: \(error)") 130 | } 131 | 132 | // Remove the first token from the keychain 133 | do { 134 | try keychain.delete(savedItem1) 135 | } catch { 136 | XCTFail("deletePersistentToken(_:) failed with error: \(error)") 137 | } 138 | 139 | do { 140 | let checkItem1 = try keychain.persistentToken(withIdentifier: savedItem1.identifier) 141 | let checkItem2 = try keychain.persistentToken(withIdentifier: savedItem2.identifier) 142 | XCTAssertNil(checkItem1, "Token should not be in keychain: \(token1)") 143 | XCTAssertNotNil(checkItem2, "Token should be in keychain: \(token2)") 144 | } catch { 145 | XCTFail("persistentTokenWithIdentifier(_:) failed with error: \(error)") 146 | } 147 | 148 | // Remove the second token from the keychain 149 | do { 150 | try keychain.delete(savedItem2) 151 | } catch { 152 | XCTFail("deletePersistentToken(_:) failed with error: \(error)") 153 | } 154 | 155 | do { 156 | let recheckItem1 = try keychain.persistentToken(withIdentifier: savedItem1.identifier) 157 | let recheckItem2 = try keychain.persistentToken(withIdentifier: savedItem2.identifier) 158 | XCTAssertNil(recheckItem1, "Token should not be in keychain: \(token1)") 159 | XCTAssertNil(recheckItem2, "Token should not be in keychain: \(token2)") 160 | } catch { 161 | XCTFail("persistentTokenWithIdentifier(_:) failed with error: \(error)") 162 | } 163 | 164 | // Try to remove both tokens from the keychain again 165 | do { 166 | try keychain.delete(savedItem1) 167 | // The deletion should throw and this line should never be reached. 168 | XCTFail("Removing again should fail: \(token1)") 169 | } catch { 170 | // An error thrown is the expected outcome 171 | } 172 | do { 173 | try keychain.delete(savedItem2) 174 | // The deletion should throw and this line should never be reached. 175 | XCTFail("Removing again should fail: \(token2)") 176 | } catch { 177 | // An error thrown is the expected outcome 178 | } 179 | } 180 | 181 | func testAllPersistentTokens() { 182 | let token1 = testToken, token2 = testToken, token3 = testToken 183 | 184 | do { 185 | let noTokens = try keychain.allPersistentTokens() 186 | XCTAssert(noTokens.isEmpty, "Expected no tokens in keychain: \(noTokens)") 187 | } catch { 188 | XCTFail("allPersistentTokens() failed with error: \(error)") 189 | } 190 | 191 | let persistentToken1: PersistentToken 192 | let persistentToken2: PersistentToken 193 | let persistentToken3: PersistentToken 194 | do { 195 | persistentToken1 = try keychain.add(token1) 196 | persistentToken2 = try keychain.add(token2) 197 | persistentToken3 = try keychain.add(token3) 198 | } catch { 199 | XCTFail("addToken(_:) failed with error: \(error)") 200 | return 201 | } 202 | 203 | do { 204 | let allTokens = try keychain.allPersistentTokens() 205 | XCTAssertEqual(allTokens, [persistentToken1, persistentToken2, persistentToken3], 206 | "Tokens not correctly recovered from keychain") 207 | } catch { 208 | XCTFail("allPersistentTokens() failed with error: \(error)") 209 | } 210 | 211 | do { 212 | try keychain.delete(persistentToken1) 213 | try keychain.delete(persistentToken2) 214 | try keychain.delete(persistentToken3) 215 | } catch { 216 | XCTFail("deletePersistentToken(_:) failed with error: \(error)") 217 | } 218 | 219 | do { 220 | let noTokens = try keychain.allPersistentTokens() 221 | XCTAssert(noTokens.isEmpty, "Expected no tokens in keychain: \(noTokens)") 222 | } catch { 223 | XCTFail("allPersistentTokens() failed with error: \(error)") 224 | } 225 | } 226 | 227 | func testMissingData() throws { 228 | let keychainAttributes: [String: AnyObject] = [ 229 | kSecValueData as String: testToken.generator.secret as NSData, 230 | ] 231 | 232 | let persistentRef = try addKeychainItem(withAttributes: keychainAttributes) 233 | 234 | XCTAssertThrowsError(try keychain.persistentToken(withIdentifier: persistentRef)) 235 | // TODO: Restore deserialization error handling in allPersistentTokens() 236 | // XCTAssertThrowsError(try keychain.allPersistentTokens()) 237 | 238 | XCTAssertNoThrow(try deleteKeychainItem(forPersistentRef: persistentRef), 239 | "Failed to delete the test token from the keychain. This may cause future test runs to fail.") 240 | } 241 | 242 | func testMissingSecret() throws { 243 | let data = try testToken.toURL().absoluteString.data(using: .utf8)! 244 | 245 | let keychainAttributes: [String: AnyObject] = [ 246 | kSecAttrGeneric as String: data as NSData, 247 | ] 248 | 249 | let persistentRef = try addKeychainItem(withAttributes: keychainAttributes) 250 | 251 | XCTAssertThrowsError(try keychain.persistentToken(withIdentifier: persistentRef)) 252 | // TODO: Restore deserialization error handling in allPersistentTokens() 253 | // XCTAssertThrowsError(try keychain.allPersistentTokens()) 254 | 255 | XCTAssertNoThrow(try deleteKeychainItem(forPersistentRef: persistentRef), 256 | "Failed to delete the test token from the keychain. This may cause future test runs to fail.") 257 | } 258 | 259 | func testBadData() throws { 260 | let badData = Data(" ".utf8) 261 | 262 | let keychainAttributes: [String: AnyObject] = [ 263 | kSecAttrGeneric as String: badData as NSData, 264 | kSecValueData as String: testToken.generator.secret as NSData, 265 | ] 266 | 267 | let persistentRef = try addKeychainItem(withAttributes: keychainAttributes) 268 | 269 | XCTAssertThrowsError(try keychain.persistentToken(withIdentifier: persistentRef)) 270 | // TODO: Restore deserialization error handling in allPersistentTokens() 271 | // XCTAssertThrowsError(try keychain.allPersistentTokens()) 272 | 273 | XCTAssertNoThrow(try deleteKeychainItem(forPersistentRef: persistentRef), 274 | "Failed to delete the test token from the keychain. This may cause future test runs to fail.") 275 | } 276 | 277 | func testBadURL() throws { 278 | let badData = Data("http://example.com".utf8) 279 | 280 | let keychainAttributes: [String: AnyObject] = [ 281 | kSecAttrGeneric as String: badData as NSData, 282 | kSecValueData as String: testToken.generator.secret as NSData, 283 | ] 284 | 285 | let persistentRef = try addKeychainItem(withAttributes: keychainAttributes) 286 | 287 | XCTAssertThrowsError(try keychain.persistentToken(withIdentifier: persistentRef)) 288 | // TODO: Restore deserialization error handling in allPersistentTokens() 289 | // XCTAssertThrowsError(try keychain.allPersistentTokens()) 290 | 291 | XCTAssertNoThrow(try deleteKeychainItem(forPersistentRef: persistentRef), 292 | "Failed to delete the test token from the keychain. This may cause future test runs to fail.") 293 | } 294 | } 295 | 296 | // MARK: Keychain helpers 297 | 298 | private func addKeychainItem(withAttributes attributes: [String: AnyObject]) throws -> Data { 299 | var mutableAttributes = attributes 300 | mutableAttributes[kSecClass as String] = kSecClassGenericPassword 301 | mutableAttributes[kSecReturnPersistentRef as String] = kCFBooleanTrue 302 | // Set a random string for the account name. 303 | // We never query by or display this value, but the keychain requires it to be unique. 304 | if mutableAttributes[kSecAttrAccount as String] == nil { 305 | mutableAttributes[kSecAttrAccount as String] = UUID().uuidString as NSString 306 | } 307 | 308 | var result: AnyObject? 309 | let resultCode: OSStatus = withUnsafeMutablePointer(to: &result) { 310 | SecItemAdd(mutableAttributes as CFDictionary, $0) 311 | } 312 | 313 | guard resultCode == errSecSuccess else { 314 | throw Keychain.Error.systemError(resultCode) 315 | } 316 | guard let persistentRef = result as? Data else { 317 | throw Keychain.Error.incorrectReturnType 318 | } 319 | return persistentRef 320 | } 321 | 322 | public func deleteKeychainItem(forPersistentRef persistentRef: Data) throws { 323 | let queryDict: [String: AnyObject] = [ 324 | kSecClass as String: kSecClassGenericPassword, 325 | kSecValuePersistentRef as String: persistentRef as NSData, 326 | ] 327 | 328 | let resultCode = SecItemDelete(queryDict as CFDictionary) 329 | 330 | guard resultCode == errSecSuccess else { 331 | throw Keychain.Error.systemError(resultCode) 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /Tests/TokenSerializationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TokenSerializationTests.swift 3 | // OneTimePassword 4 | // 5 | // Copyright (c) 2014-2022 Matt Rubin and the OneTimePassword authors 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | // 25 | 26 | import Base32 27 | import OneTimePassword 28 | import XCTest 29 | 30 | private let validSecret: [UInt8] = [ 31 | 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 32 | ] 33 | 34 | // swiftlint:disable:next type_body_length 35 | class TokenSerializationTests: XCTestCase { 36 | let kOTPScheme = "otpauth" 37 | let kOTPTokenTypeCounterHost = "hotp" 38 | let kOTPTokenTypeTimerHost = "totp" 39 | let kOTPAlgorithmSHA1 = "SHA1" 40 | let kOTPAlgorithmSHA256 = "SHA256" 41 | let kOTPAlgorithmSHA512 = "SHA512" 42 | 43 | let factors: [Generator.Factor] = [ 44 | .counter(0), 45 | .counter(1), 46 | .counter(.max), 47 | .timer(period: 1), 48 | .timer(period: 30), 49 | .timer(period: 300), 50 | ] 51 | let names = ["", "Login", "user_123@website.com", "Léon", ":/?#[]@!$&'()*+,;=%\""] 52 | let issuers = ["", "Big Cörpøráçìôn", ":/?#[]@!$&'()*+,;=%\""] 53 | let secretStrings = [ 54 | "12345678901234567890", 55 | "12345678901234567890123456789012", 56 | "1234567890123456789012345678901234567890123456789012345678901234", 57 | "", 58 | ] 59 | let algorithms: [Generator.Algorithm] = [.sha1, .sha256, .sha512] 60 | let digits = [6, 7, 8] 61 | 62 | // MARK: mark - Brute Force Tests 63 | 64 | func testDeserialization() throws { 65 | for factor in factors { 66 | for name in names { 67 | for issuer in issuers { 68 | for secretString in secretStrings { 69 | for algorithm in algorithms { 70 | for digitNumber in digits { 71 | let secret = secretString.data(using: .ascii)! 72 | 73 | // Construct the URL 74 | var urlComponents = URLComponents() 75 | urlComponents.scheme = kOTPScheme 76 | urlComponents.host = urlHost(for: factor) 77 | urlComponents.path = "/" + name 78 | 79 | var queryItems: [URLQueryItem] = [] 80 | let algorithmValue = string(for: algorithm) 81 | queryItems.append(URLQueryItem(name: "algorithm", value: algorithmValue)) 82 | queryItems.append(URLQueryItem(name: "digits", value: String(digitNumber))) 83 | let secretValue = MF_Base32Codec.base32String(from: secret) 84 | .replacingOccurrences(of: "=", with: "") 85 | queryItems.append(URLQueryItem(name: "secret", value: secretValue)) 86 | switch factor { 87 | case .timer(let period): 88 | let periodValue = String(Int(period)) 89 | queryItems.append(URLQueryItem(name: "period", value: periodValue)) 90 | 91 | case .counter(let count): 92 | let counterValue = String(count) 93 | queryItems.append(URLQueryItem(name: "counter", value: counterValue)) 94 | } 95 | queryItems.append(URLQueryItem(name: "issuer", value: issuer)) 96 | urlComponents.queryItems = queryItems 97 | let url = urlComponents.url! 98 | 99 | // Create the token 100 | let token = try Token(url: url) 101 | 102 | XCTAssertEqual(token.generator.factor, factor, "Incorrect token type") 103 | XCTAssertEqual(token.name, name, "Incorrect token name") 104 | XCTAssertEqual(token.issuer, issuer, "Incorrect token issuer") 105 | XCTAssertEqual(token.generator.secret, secret, "Incorrect token secret") 106 | XCTAssertEqual(token.generator.algorithm, algorithm, "Incorrect token algorithm") 107 | XCTAssertEqual(token.generator.digits, digitNumber, "Incorrect token digits") 108 | } 109 | } 110 | } 111 | } 112 | } 113 | } 114 | } 115 | 116 | private func urlHost(for factor: Generator.Factor) -> String { 117 | switch factor { 118 | case .counter: 119 | return kOTPTokenTypeCounterHost 120 | case .timer: 121 | return kOTPTokenTypeTimerHost 122 | } 123 | } 124 | 125 | private func string(for algorithm: Generator.Algorithm) -> String { 126 | switch algorithm { 127 | case .sha1: 128 | return kOTPAlgorithmSHA1 129 | case .sha256: 130 | return kOTPAlgorithmSHA256 131 | case .sha512: 132 | return kOTPAlgorithmSHA512 133 | } 134 | } 135 | 136 | func testTokenWithURLAndSecret() throws { 137 | for factor in factors { 138 | for name in names { 139 | for issuer in issuers { 140 | for secretString in secretStrings { 141 | for algorithm in algorithms { 142 | for digitNumber in digits { 143 | let secret = secretString.data(using: .ascii)! 144 | 145 | // Construct the URL 146 | var urlComponents = URLComponents() 147 | urlComponents.scheme = kOTPScheme 148 | urlComponents.host = urlHost(for: factor) 149 | urlComponents.path = "/" + name 150 | 151 | var queryItems: [URLQueryItem] = [] 152 | let algorithmValue = string(for: algorithm) 153 | queryItems.append(URLQueryItem(name: "algorithm", value: algorithmValue)) 154 | queryItems.append(URLQueryItem(name: "digits", value: String(digitNumber))) 155 | // TODO: Test secret overriding in a separate test case 156 | queryItems.append(URLQueryItem(name: "secret", value: "A")) 157 | switch factor { 158 | case .timer(let period): 159 | let periodValue = String(Int(period)) 160 | queryItems.append(URLQueryItem(name: "period", value: periodValue)) 161 | 162 | case .counter(let count): 163 | let counterValue = String(count) 164 | queryItems.append(URLQueryItem(name: "counter", value: counterValue)) 165 | } 166 | queryItems.append(URLQueryItem(name: "issuer", value: issuer)) 167 | urlComponents.queryItems = queryItems 168 | let url = urlComponents.url! 169 | 170 | // Create the token 171 | let token = try Token(url: url, secret: secret) 172 | 173 | XCTAssertEqual(token.generator.factor, factor, "Incorrect token type") 174 | XCTAssertEqual(token.name, name, "Incorrect token name") 175 | XCTAssertEqual(token.issuer, issuer, "Incorrect token issuer") 176 | XCTAssertEqual(token.generator.secret, secret, "Incorrect token secret") 177 | XCTAssertEqual(token.generator.algorithm, algorithm, "Incorrect token algorithm") 178 | XCTAssertEqual(token.generator.digits, digitNumber, "Incorrect token digits") 179 | } 180 | } 181 | } 182 | } 183 | } 184 | } 185 | } 186 | 187 | // swiftlint:disable:next function_body_length 188 | func testSerialization() throws { 189 | for factor in factors { 190 | for name in names { 191 | for issuer in issuers { 192 | for secretString in secretStrings { 193 | for algorithm in algorithms { 194 | for digitNumber in digits { 195 | // Create the token 196 | let generator = try Generator( 197 | factor: factor, 198 | secret: secretString.data(using: .ascii)!, 199 | algorithm: algorithm, 200 | digits: digitNumber 201 | ) 202 | let token = Token( 203 | name: name, 204 | issuer: issuer, 205 | generator: generator 206 | ) 207 | 208 | // Serialize 209 | let url = try token.toURL() 210 | 211 | // Test scheme 212 | XCTAssertEqual(url.scheme, kOTPScheme, "The url scheme should be \"\(kOTPScheme)\"") 213 | // Test Factor 214 | let expectedHost = urlHost(for: factor) 215 | XCTAssertEqual(url.host, expectedHost, "The url host should be \"\(expectedHost)\"") 216 | // Test name 217 | XCTAssertEqual(url.path, "/" + name, "The url path should be \"/\(name)\"") 218 | 219 | let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) 220 | let queryItems = urlComponents?.queryItems ?? [] 221 | let expectedItemCount = 4 222 | XCTAssertEqual(queryItems.count, expectedItemCount, 223 | "There shouldn't be any unexpected query arguments: \(url)") 224 | 225 | var queryArguments: [String: String] = [:] 226 | for queryItem in queryItems { 227 | XCTAssertNil(queryArguments[queryItem.name]) 228 | queryArguments[queryItem.name] = queryItem.value 229 | } 230 | XCTAssertEqual(queryArguments.count, expectedItemCount, 231 | "There shouldn't be any unexpected query arguments: \(url)") 232 | 233 | // Test algorithm 234 | let expectedAlgorithmString = string(for: algorithm) 235 | XCTAssertEqual(queryArguments["algorithm"], expectedAlgorithmString, 236 | "The algorithm value should be \"\(expectedAlgorithmString)\"") 237 | 238 | // Test digits 239 | let expectedDigitsString = String(digitNumber) 240 | XCTAssertEqual(queryArguments["digits"], expectedDigitsString, 241 | "The digits value should be \"\(expectedDigitsString)\"") 242 | // Test secret 243 | XCTAssertNil(queryArguments["secret"], 244 | "The url query string should not contain the secret") 245 | 246 | // Test period 247 | switch factor { 248 | case .timer(let period): 249 | let expectedPeriodString = String(Int(period)) 250 | XCTAssertEqual(queryArguments["period"], expectedPeriodString, 251 | "The period value should be \"\(expectedPeriodString)\"") 252 | 253 | default: 254 | XCTAssertNil(queryArguments["period"], 255 | "The url query string should not contain the period") 256 | } 257 | // Test counter 258 | switch factor { 259 | case .counter(let count): 260 | let expectedCounterString = String(count) 261 | XCTAssertEqual(queryArguments["counter"], expectedCounterString, 262 | "The counter value should be \"\(expectedCounterString)\"") 263 | 264 | default: 265 | XCTAssertNil(queryArguments["counter"], 266 | "The url query string should not contain the counter") 267 | } 268 | 269 | // Test issuer 270 | XCTAssertEqual(queryArguments["issuer"], issuer, 271 | "The issuer value should be \"\(issuer)\"") 272 | 273 | // Check url again 274 | let checkURL = try token.toURL() 275 | XCTAssertEqual(url, checkURL, "Repeated calls to url() should return the same result!") 276 | } 277 | } 278 | } 279 | } 280 | } 281 | } 282 | } 283 | 284 | func testTokenWithDefaultCounter() throws { 285 | let tokenURLString = "otpauth://hotp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4" 286 | guard let tokenURL = URL(string: tokenURLString) else { 287 | XCTFail("Failed to initialize a URL from String \"\(tokenURLString)\"") 288 | return 289 | } 290 | let token = try Token(url: tokenURL) 291 | XCTAssertEqual(token.generator.factor, .counter(0)) 292 | } 293 | 294 | // MARK: - Test with specific URLs 295 | // From Google Authenticator for iOS 296 | // https://code.google.com/p/google-authenticator/source/browse/mobile/ios/Classes/OTPAuthURLTest.m 297 | 298 | // MARK: Deserialization 299 | 300 | func testTokenWithTOTPURL() throws { 301 | let urlString = "otpauth://totp/L%C3%A9on?algorithm=SHA256&digits=8&period=45&secret=AAAQEAYEAUDAOCAJBIFQYDIOB4" 302 | let token = try Token(url: URL(string: urlString)!) 303 | 304 | XCTAssertEqual(token.name, "Léon") 305 | XCTAssertEqual(token.generator.secret, Data(bytes: validSecret, count: validSecret.count)) 306 | XCTAssertEqual(token.generator.factor, Generator.Factor.timer(period: 45)) 307 | XCTAssertEqual(token.generator.algorithm, Generator.Algorithm.sha256) 308 | XCTAssertEqual(token.generator.digits, 8) 309 | } 310 | 311 | func testTokenWithHOTPURL() throws { 312 | let urlString = "otpauth://hotp/L%C3%A9on?algorithm=SHA256&digits=8&counter=18446744073709551615" + 313 | "&secret=AAAQEAYEAUDAOCAJBIFQYDIOB4" 314 | let secret = Data(bytes: validSecret, count: validSecret.count) 315 | let token = try Token(url: URL(string: urlString)!) 316 | 317 | XCTAssertEqual(token.name, "Léon") 318 | XCTAssertEqual(token.generator.secret, secret) 319 | XCTAssertEqual(token.generator.factor, Generator.Factor.counter(18446744073709551615)) 320 | XCTAssertEqual(token.generator.algorithm, Generator.Algorithm.sha256) 321 | XCTAssertEqual(token.generator.digits, 8) 322 | } 323 | 324 | func testTokenWithInvalidURLs() throws { 325 | let badURLs = [ 326 | "http://foo", // invalid scheme 327 | "otpauth://foo", // invalid type 328 | "otpauth:///bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4", // missing type 329 | "otpauth://totp/bar", // missing secret 330 | "otpauth://totp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4&period=0", // invalid period 331 | "otpauth://totp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4&period=x", // non-numeric period 332 | "otpauth://totp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4&period=30&period=60", // multiple period 333 | "otpauth://totp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4&algorithm=MD5", // invalid algorithm 334 | "otpauth://totp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4&digits=2", // invalid digits 335 | "otpauth://totp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4&digits=x", // non-numeric digits 336 | "otpauth://hotp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4&counter=1.5", // invalid counter 337 | "otpauth://hotp/bar?secret=AAAQEAYEAUDAOCAJBIFQYDIOB4&counter=x", // non-numeric counter 338 | ] 339 | 340 | for badURL in badURLs { 341 | let token = try? Token(url: URL(string: badURL)!) 342 | XCTAssertNil(token, "Invalid url (\(badURL)) generated \(String(describing: token))") 343 | } 344 | } 345 | 346 | func testTokenWithIssuer() throws { 347 | let simpleToken = try Token(url: URL(string: "otpauth://totp/name?secret=A&issuer=issuer")!) 348 | XCTAssertNotNil(simpleToken) 349 | XCTAssertEqual(simpleToken.name, "name") 350 | XCTAssertEqual(simpleToken.issuer, "issuer") 351 | 352 | // TODO: test this more thoroughly, including the override case with 353 | // "otpauth://totp/_issuer:name?secret=A&isser=issuer" 354 | 355 | let urlStrings = [ 356 | "otpauth://totp/issu%C3%A9r%20!:name?secret=A", 357 | "otpauth://totp/issu%C3%A9r%20!:%20name?secret=A", 358 | "otpauth://totp/issu%C3%A9r%20!:%20%20%20name?secret=A", 359 | "otpauth://totp/issu%C3%A9r%20!%3Aname?secret=A", 360 | "otpauth://totp/issu%C3%A9r%20!%3A%20name?secret=A", 361 | "otpauth://totp/issu%C3%A9r%20!%3A%20%20%20name?secret=A", 362 | ] 363 | for urlString in urlStrings { 364 | // If there is no issuer argument, extract the issuer from the name 365 | let token = try Token(url: URL(string: urlString)!) 366 | 367 | XCTAssertNotNil(token, "<\(urlString)> did not create a valid token.") 368 | XCTAssertEqual(token.name, "name") 369 | XCTAssertEqual(token.issuer, "issuér !") 370 | 371 | // If there is an issuer argument which matches the one in the name, trim the name 372 | let token2 = try Token(url: URL(string: urlString.appending("&issuer=issu%C3%A9r%20!"))!) 373 | 374 | XCTAssertNotNil(token2, "<\(urlString)> did not create a valid token.") 375 | XCTAssertEqual(token2.name, "name") 376 | XCTAssertEqual(token2.issuer, "issuér !") 377 | 378 | // If there is an issuer argument different from the name prefix, 379 | // trust the argument and leave the name as it is 380 | let token3 = try Token(url: URL(string: urlString.appending("&issuer=test"))!) 381 | 382 | XCTAssertNotNil(token3, "<\(urlString)> did not create a valid token.") 383 | XCTAssertNotEqual(token3.name, "name") 384 | XCTAssertTrue(token3.name.hasPrefix("issuér !"), "The name should begin with \"issuér !\"") 385 | XCTAssertTrue(token3.name.hasSuffix("name"), "The name should end with \"name\"") 386 | XCTAssertEqual(token3.issuer, "test") 387 | } 388 | } 389 | 390 | // MARK: Serialization 391 | 392 | func testTOTPURL() throws { 393 | let secret = MF_Base32Codec.data(fromBase32String: "AAAQEAYEAUDAOCAJBIFQYDIOB4")! 394 | let generator = try Generator(factor: .timer(period: 45), secret: secret, algorithm: .sha256, digits: 8) 395 | let token = Token(name: "Léon", generator: generator) 396 | 397 | // swiftlint:disable:next force_try 398 | let url = try! token.toURL() 399 | 400 | XCTAssertEqual(url.scheme, "otpauth") 401 | XCTAssertEqual(url.host, "totp") 402 | XCTAssertEqual(url.path, "/Léon") 403 | 404 | let expectedQueryItems = [ 405 | URLQueryItem(name: "algorithm", value: "SHA256"), 406 | URLQueryItem(name: "digits", value: "8"), 407 | URLQueryItem(name: "issuer", value: ""), 408 | URLQueryItem(name: "period", value: "45"), 409 | ] 410 | let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems 411 | XCTAssertEqual(queryItems, expectedQueryItems) 412 | } 413 | 414 | func testHOTPURL() throws { 415 | let secret = MF_Base32Codec.data(fromBase32String: "AAAQEAYEAUDAOCAJBIFQYDIOB4")! 416 | let generator = try Generator( 417 | factor: .counter(18446744073709551615), 418 | secret: secret, 419 | algorithm: .sha256, 420 | digits: 8) 421 | let token = Token(name: "Léon", generator: generator) 422 | 423 | // swiftlint:disable:next force_try 424 | let url = try! token.toURL() 425 | 426 | XCTAssertEqual(url.scheme, "otpauth") 427 | XCTAssertEqual(url.host, "hotp") 428 | XCTAssertEqual(url.path, "/Léon") 429 | 430 | let expectedQueryItems = [ 431 | URLQueryItem(name: "algorithm", value: "SHA256"), 432 | URLQueryItem(name: "digits", value: "8"), 433 | URLQueryItem(name: "issuer", value: ""), 434 | URLQueryItem(name: "counter", value: "18446744073709551615"), 435 | ] 436 | let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems 437 | XCTAssertEqual(queryItems, expectedQueryItems) 438 | } 439 | } 440 | 441 | // swiftlint:disable:this file_length 442 | -------------------------------------------------------------------------------- /Tests/TokenTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TokenTests.swift 3 | // OneTimePassword 4 | // 5 | // Copyright (c) 2014-2019 Matt Rubin and the OneTimePassword authors 6 | // 7 | // Permission is hereby granted, free of charge, to any person obtaining a copy 8 | // of this software and associated documentation files (the "Software"), to deal 9 | // in the Software without restriction, including without limitation the rights 10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | // copies of the Software, and to permit persons to whom the Software is 12 | // furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all 15 | // copies or substantial portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | // SOFTWARE. 24 | // 25 | 26 | import XCTest 27 | import OneTimePassword 28 | 29 | class TokenTests: XCTestCase { 30 | let secretData = "12345678901234567890".data(using: String.Encoding.ascii)! 31 | let otherSecretData = "09876543210987654321".data(using: String.Encoding.ascii)! 32 | 33 | func testInit() throws { 34 | // Create a token 35 | let name = "Test Name" 36 | let issuer = "Test Issuer" 37 | let generator = try Generator( 38 | factor: .counter(111), 39 | secret: secretData, 40 | algorithm: .sha1, 41 | digits: 6 42 | ) 43 | 44 | let token = Token( 45 | name: name, 46 | issuer: issuer, 47 | generator: generator 48 | ) 49 | 50 | XCTAssertEqual(token.name, name) 51 | XCTAssertEqual(token.issuer, issuer) 52 | XCTAssertEqual(token.generator, generator) 53 | 54 | // Create another token 55 | let otherName = "Other Test Name" 56 | let otherIssuer = "Other Test Issuer" 57 | let otherGenerator = try Generator( 58 | factor: .timer(period: 123), 59 | secret: otherSecretData, 60 | algorithm: .sha512, 61 | digits: 8 62 | ) 63 | 64 | let otherToken = Token( 65 | name: otherName, 66 | issuer: otherIssuer, 67 | generator: otherGenerator 68 | ) 69 | 70 | XCTAssertEqual(otherToken.name, otherName) 71 | XCTAssertEqual(otherToken.issuer, otherIssuer) 72 | XCTAssertEqual(otherToken.generator, otherGenerator) 73 | 74 | // Ensure the tokens are different 75 | XCTAssertNotEqual(token.name, otherToken.name) 76 | XCTAssertNotEqual(token.issuer, otherToken.issuer) 77 | XCTAssertNotEqual(token.generator, otherToken.generator) 78 | } 79 | 80 | func testDefaults() throws { 81 | let generator = try Generator( 82 | factor: .counter(0), 83 | secret: Data(), 84 | algorithm: .sha1, 85 | digits: 6 86 | ) 87 | let name = "Test Name" 88 | let issuer = "Test Issuer" 89 | 90 | let tokenWithDefaultName = Token(issuer: issuer, generator: generator) 91 | XCTAssertEqual(tokenWithDefaultName.name, "") 92 | XCTAssertEqual(tokenWithDefaultName.issuer, issuer) 93 | 94 | let tokenWithDefaultIssuer = Token(name: name, generator: generator) 95 | XCTAssertEqual(tokenWithDefaultIssuer.name, name) 96 | XCTAssertEqual(tokenWithDefaultIssuer.issuer, "") 97 | 98 | let tokenWithAllDefaults = Token(generator: generator) 99 | XCTAssertEqual(tokenWithAllDefaults.name, "") 100 | XCTAssertEqual(tokenWithAllDefaults.issuer, "") 101 | } 102 | 103 | func testCurrentPassword() throws { 104 | let timerGenerator = try Generator( 105 | factor: .timer(period: 30), 106 | secret: secretData, 107 | algorithm: .sha1, 108 | digits: 6 109 | ) 110 | let timerToken = Token(generator: timerGenerator) 111 | 112 | do { 113 | let password = try timerToken.generator.password(at: Date()) 114 | XCTAssertEqual(timerToken.currentPassword, password) 115 | 116 | let oldPassword = try timerToken.generator.password(at: Date(timeIntervalSince1970: 0)) 117 | XCTAssertNotEqual(timerToken.currentPassword, oldPassword) 118 | } catch { 119 | XCTFail("Failed to generate password with error: \(error)") 120 | return 121 | } 122 | 123 | let counterGenerator = try Generator( 124 | factor: .counter(12345), 125 | secret: otherSecretData, 126 | algorithm: .sha1, 127 | digits: 6 128 | ) 129 | let counterToken = Token(generator: counterGenerator) 130 | 131 | do { 132 | let password = try counterToken.generator.password(at: Date()) 133 | XCTAssertEqual(counterToken.currentPassword, password) 134 | 135 | let oldPassword = try counterToken.generator.password(at: Date(timeIntervalSince1970: 0)) 136 | XCTAssertEqual(counterToken.currentPassword, oldPassword) 137 | } catch { 138 | XCTFail("Failed to generate password with error: \(error)") 139 | return 140 | } 141 | } 142 | 143 | func testUpdatedToken() throws { 144 | let timerGenerator = try Generator( 145 | factor: .timer(period: 30), 146 | secret: secretData, 147 | algorithm: .sha1, 148 | digits: 6 149 | ) 150 | let timerToken = Token(generator: timerGenerator) 151 | 152 | let updatedTimerToken = timerToken.updatedToken() 153 | XCTAssertEqual(updatedTimerToken, timerToken) 154 | 155 | let count: UInt64 = 12345 156 | let counterGenerator = try Generator( 157 | factor: .counter(count), 158 | secret: otherSecretData, 159 | algorithm: .sha1, 160 | digits: 6 161 | ) 162 | let counterToken = Token(generator: counterGenerator) 163 | 164 | let updatedCounterToken = counterToken.updatedToken() 165 | XCTAssertNotEqual(updatedCounterToken, counterToken) 166 | 167 | XCTAssertEqual(updatedCounterToken.name, counterToken.name) 168 | XCTAssertEqual(updatedCounterToken.issuer, counterToken.issuer) 169 | XCTAssertEqual(updatedCounterToken.generator.secret, counterToken.generator.secret) 170 | XCTAssertEqual(updatedCounterToken.generator.algorithm, counterToken.generator.algorithm) 171 | XCTAssertEqual(updatedCounterToken.generator.digits, counterToken.generator.digits) 172 | 173 | let updatedFactor = Generator.Factor.counter(count + 1) 174 | XCTAssertEqual(updatedCounterToken.generator.factor, updatedFactor) 175 | } 176 | } 177 | --------------------------------------------------------------------------------