From 9aa09604b337b4f677f7b791df95bce533b1066b Mon Sep 17 00:00:00 2001 From: Gaetano Matonti Date: Sat, 28 Aug 2021 00:10:53 +0200 Subject: [PATCH 01/10] Add Github Action for Build and Testing (#2) * Create swift.yml * Update Package.swift * Update Package.swift --- .github/workflows/swift.yml | 19 ++++++++++++++ Package.swift | 52 +++++++++++++++++++++---------------- 2 files changed, 49 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/swift.yml diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000..a5153d7 --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,19 @@ +name: Swift + +on: + push: + branches: [ develop ] + pull_request: + branches: [ develop ] + +jobs: + build: + + runs-on: macos-latest + + steps: + - uses: actions/checkout@v2 + - name: Build + run: swift build -v + - name: Run tests + run: swift test -v diff --git a/Package.swift b/Package.swift index 1a17c0d..7e2506e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,28 +1,36 @@ -// swift-tools-version:5.5 +// swift-tools-version:5.3 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( - name: "Uno", - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "Uno", - targets: ["Uno"]), - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "Uno", - dependencies: []), - .testTarget( - name: "UnoTests", - dependencies: ["Uno"]), - ] + name: "Uno", + platforms: [ + .iOS(.v13), .macOS(.v10_15) + ], + products: [ + .library( + name: "Uno", + targets: [ + "Uno" + ] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-crypto.git", from: "1.1.0"), + ], + targets: [ + .target( + name: "Uno", + dependencies: [ + .product(name: "Crypto", package: "swift-crypto") + ] + ), + .testTarget( + name: "UnoTests", + dependencies: [ + "Uno" + ] + ), + ] ) From 73f490e65eabafce87d7ae356a7717f8c7a47ab1 Mon Sep 17 00:00:00 2001 From: Gaetano Matonti Date: Sun, 29 Aug 2021 17:06:17 +0200 Subject: [PATCH 02/10] Add HMAC-based One Time Password (HOTP) Generator (#1) * Add logic for OTP generation and tests * Refactor code and add docs * Add docs and refactor * Fix README.md headers * Update Package.resolved * Add a new Secret wrapper object * Remove file name from headers * Refactor the HOTP generation and tests * Update docs * Rename OTP and HOTP with a "generator" name * Fix docs * Apply suggestions from code review Co-authored-by: Fabrizio Brancati * Ignore and remove .swiftpm * Use template for .gitignore Co-authored-by: Fabrizio Brancati --- .gitignore | 20 ++- .../xcshareddata/IDEWorkspaceChecks.plist | 8 -- Package.resolved | 16 +++ README.md | 17 ++- Sources/Uno/AuthenticationCodeGenerator.swift | 32 +++++ Sources/Uno/CounterBasedGenerator.swift | 64 ++++++++++ Sources/Uno/Extensions/Data+Extensions.swift | 17 +++ ...MessageAuthenticationCode+Extensions.swift | 57 +++++++++ .../Uno/Extensions/String+Extensions.swift | 19 +++ Sources/Uno/Secret.swift | 58 +++++++++ Sources/Uno/Uno.swift | 6 - .../UnoTests/CounterBasedGeneratorTests.swift | 116 ++++++++++++++++++ Tests/UnoTests/StringTests.swift | 42 +++++++ Tests/UnoTests/UnoTests.swift | 11 -- 14 files changed, 451 insertions(+), 32 deletions(-) delete mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Package.resolved create mode 100644 Sources/Uno/AuthenticationCodeGenerator.swift create mode 100644 Sources/Uno/CounterBasedGenerator.swift create mode 100644 Sources/Uno/Extensions/Data+Extensions.swift create mode 100644 Sources/Uno/Extensions/MessageAuthenticationCode+Extensions.swift create mode 100644 Sources/Uno/Extensions/String+Extensions.swift create mode 100644 Sources/Uno/Secret.swift delete mode 100644 Sources/Uno/Uno.swift create mode 100644 Tests/UnoTests/CounterBasedGeneratorTests.swift create mode 100644 Tests/UnoTests/StringTests.swift delete mode 100644 Tests/UnoTests/UnoTests.swift diff --git a/.gitignore b/.gitignore index bb460e7..93c28b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,15 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ +# Created by https://www.toptal.com/developers/gitignore/api/swiftpm,code +# Edit at https://www.toptal.com/developers/gitignore?templates=swiftpm,code + +#!! ERROR: code is undefined. Use list command to see defined gitignore types !!# + +### SwiftPM ### +Packages +.build/ +xcuserdata DerivedData/ -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +*.xcodeproj +.swiftpm + + +# End of https://www.toptal.com/developers/gitignore/api/swiftpm,code \ No newline at end of file diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..318e73f --- /dev/null +++ b/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "swift-crypto", + "repositoryURL": "https://github.com/apple/swift-crypto.git", + "state": { + "branch": null, + "revision": "3bea268b223651c4ab7b7b9ad62ef9b2d4143eb6", + "version": "1.1.6" + } + } + ] + }, + "version": 1 +} diff --git a/README.md b/README.md index 020817e..6a7dda1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,18 @@ # Uno -A description of this package. +A simple OTP generator written in Swift. + +## One-Time Password +An OTP (One-Time Password) is an authentication code that can only be used once. As such, it is often used in combination with a regular password to provide an extra layer of security (multi-factor authentication). + +### HMAC-based One-Time Password +A HOTP (where the "H" stands for _Hash-based Message Authentication Code_, or _HMAC_) is a one-time password generated from a cryptographic hash function and a static secret key. It also involves a moving factor that seeds into the generator function, usually a counter. +Implementation follows the [RFC-4226](https://datatracker.ietf.org/doc/html/rfc4226) specifications. + +### Time-based One-Time Password +A TOTP (Time-based One-Time Password) extends the HOTP by replacing the moving factor in the hashing function. While in the HOTP the counter is counter-based, in TOTPs it is time-based. This means that the OTP is only valid in a certain interval of time. +The amount of time in which every OTP is valid is called _timestep_, and tends to be 30 seconds or 60 seconds in length. +Implementation follows the [RFC-6238](https://datatracker.ietf.org/doc/html/rfc6238) specifications. + +## Hashing Algorithms +While SHA1 is the most frequently used hash function to generate HMACs, SHA256 and SHA512 could also be used. diff --git a/Sources/Uno/AuthenticationCodeGenerator.swift b/Sources/Uno/AuthenticationCodeGenerator.swift new file mode 100644 index 0000000..bd9da0b --- /dev/null +++ b/Sources/Uno/AuthenticationCodeGenerator.swift @@ -0,0 +1,32 @@ +// +// Uno +// +// Created by Gaetano Matonti on 28/08/21. +// + +import Foundation + +/// A protocol the represents the requirements for a one-time password generator. +public protocol AuthenticationCodeGenerator { + /// The secret to seed into the generator. + var secret: Secret { get } + + /// The amount of digits composing the authentication code. + var codeLength: Int { get } +} + +// MARK: - Helper Functions + +public extension AuthenticationCodeGenerator { + /// The range describing the supported length of the authentication code. + /// - Note: As required by [RFC-4226](https://datatracker.ietf.org/doc/html/rfc4226) + /// an authentication code should have a minimum length of 6 and a maximum of 8 digits. + static var supportedCodeLengthRange: ClosedRange { + 6...8 + } + + /// Whether the specified length of the code is valid. + var isCodeLengthValid: Bool { + Self.supportedCodeLengthRange.contains(codeLength) + } +} diff --git a/Sources/Uno/CounterBasedGenerator.swift b/Sources/Uno/CounterBasedGenerator.swift new file mode 100644 index 0000000..0a9b5bb --- /dev/null +++ b/Sources/Uno/CounterBasedGenerator.swift @@ -0,0 +1,64 @@ +// +// Uno +// +// Created by Gaetano Matonti on 27/08/21. +// + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +import Foundation + +/// A type that represents an HMAC-based one-time password. +public struct CounterBasedGenerator: AuthenticationCodeGenerator { + /// The possible errors thrown in `CounterBasedGenerator`. + public enum Error: Swift.Error { + /// The length of the authentication code is not supported. + case codeLengthNotSupported + } + + // MARK: - Stored Properties + + public let secret: Secret + + public let codeLength: Int + + // MARK: - Init + + public init(secret: Secret, codeLength: Int = 6) { + self.secret = secret + self.codeLength = codeLength + } +} + +// MARK: - OTP Functions + +extension CounterBasedGenerator { + /// Generates a HMAC-based One-Time Password (HOTP) from a secret and moving factor. + /// - Parameters: + /// - counter: A variable number that acts as a seed for the generator. + /// - Returns: A `String` representing the generated one-time password. + public func generate(from counter: UInt64) throws -> String { + let hash = try generateHMACHash(from: counter) + let code = hash.dynamicallyTrimmed(numberOfDigits: codeLength) + return code + } + + /// Generates the HMAC hash from a secret payload and a moving factor. + /// - Parameters: + /// - counter: A variable number that acts as a seed for the generator. + /// - Returns: A `MessageAuthenticationCode` representing the generated hash. + func generateHMACHash(from counter: UInt64) throws -> some MessageAuthenticationCode { + guard isCodeLengthValid else { + throw Error.codeLengthNotSupported + } + + var counter = counter.bigEndian + let counterData = Data(bytes: &counter, count: MemoryLayout.size) + + return HMAC.authenticationCode(for: counterData, using: secret.symmetricKey) + } +} diff --git a/Sources/Uno/Extensions/Data+Extensions.swift b/Sources/Uno/Extensions/Data+Extensions.swift new file mode 100644 index 0000000..9724858 --- /dev/null +++ b/Sources/Uno/Extensions/Data+Extensions.swift @@ -0,0 +1,17 @@ +// +// Uno +// +// Created by Gaetano Matonti on 27/08/21. +// + +import Foundation + +extension Data { + /// An hexadecimal representation of the bytes. + /// - Parameter forcingZeroPadding: Whether or not leading zeroes should be added to the string. + /// - Returns: A `String` representing the bytes sequence in hexadecimal format. + func hexString(forcingZeroPadding shouldForcePadding: Bool = true) -> String { + let hexDigitFormat = shouldForcePadding ? "02" : "" + return map { String(format: "%\(hexDigitFormat)hhx", $0) }.joined() + } +} diff --git a/Sources/Uno/Extensions/MessageAuthenticationCode+Extensions.swift b/Sources/Uno/Extensions/MessageAuthenticationCode+Extensions.swift new file mode 100644 index 0000000..7cc55e4 --- /dev/null +++ b/Sources/Uno/Extensions/MessageAuthenticationCode+Extensions.swift @@ -0,0 +1,57 @@ +// +// Uno +// +// Created by Gaetano Matonti on 27/08/21. +// + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +import Foundation + +extension MessageAuthenticationCode { + /// The dynamically trimmed hash in decimal format. + var dynamicallyTrimmedDecimals: UInt64 { + UInt64(dynamicallyTrimmedHexadecimals, radix: 16) ?? 0 + } + + /// The dynamically trimmed hash in hexadecimal format. + var dynamicallyTrimmedHexadecimals: String { + dynamicallyTrimmedHash.hexString(forcingZeroPadding: false) + } + + /// The dynamically trimmed hash bytes. + var dynamicallyTrimmedHash: Data { + let data = Data(self) + let lastByte = data.last ?? 0x00 + // Get the last 4 bits of the packet. + let startIndex = Int(lastByte & 0x0f) + // Get the end index needed for a 32 bit stride. + let endIndex = startIndex + 3 + // Create a new `Data` object to prevent "offset index" errors. + var trimmedPacket = Data(data[startIndex...endIndex]) + // Truncate the first bit (the packet should contain 31 bits). + trimmedPacket[0] &= 0x7f + return trimmedPacket + } + + /// An hexadecimal representation of the code hash. + var hexString: String { + Data(self).hexString() + } + + // MARK: - Functions + + /// Trims the authentication code using *dynamic truncation*. + /// - Parameter numberOfDigits: The amount of digits the truncated authentication code should be composed of. + /// - Returns: A `String` representing the truncated authentication code suitable for user authentication. + func dynamicallyTrimmed(numberOfDigits: Int) -> String { + let decimalPosition = UInt64(pow(10, Double(numberOfDigits))) + let code = dynamicallyTrimmedDecimals % decimalPosition + + return String.formatAuthenticationCode(code, numberOfDigits: numberOfDigits) + } +} diff --git a/Sources/Uno/Extensions/String+Extensions.swift b/Sources/Uno/Extensions/String+Extensions.swift new file mode 100644 index 0000000..417eff0 --- /dev/null +++ b/Sources/Uno/Extensions/String+Extensions.swift @@ -0,0 +1,19 @@ +// +// Uno +// +// Created by Gaetano Matonti on 27/08/21. +// + +import Foundation + +extension String { + /// Creates a `String` representing a formatted one-time password code. + /// - Parameters: + /// - code: The one-time password in unsigned integer format. + /// - numberOfDigits: The number of digits composing the one-time password. + /// - Returns: A `String` representing the formatted one-time password. + static func formatAuthenticationCode(_ code: UInt64, numberOfDigits: Int) -> String { + let digitFormat = "0\(numberOfDigits)" + return String(format: "%\(digitFormat)d", code) + } +} diff --git a/Sources/Uno/Secret.swift b/Sources/Uno/Secret.swift new file mode 100644 index 0000000..7c0f81a --- /dev/null +++ b/Sources/Uno/Secret.swift @@ -0,0 +1,58 @@ +// +// Uno +// +// Created by Gaetano Matonti on 28/08/21. +// + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +import Foundation + +/// An object containing information for a one-time password hash secret. +public struct Secret { + + // MARK: - Stored Properties + + /// The bytes of the secret. + let data: Data + + // MARK: - Computed Properties + + /// The symmetric cryptographic key enclosing the secret. + var symmetricKey: SymmetricKey { + SymmetricKey(data: data) + } + + // MARK: - Init + + /// Creates an instance of `Secret` from an ASCII encoded String. + /// - Parameter ascii: The ASCII encoded String. + public init(ascii string: String) throws { + guard let data = string.data(using: .ascii) else { + throw Error.asciiConversionToDataFailed + } + + self.data = data + } +} + +// MARK: - Errors + +public extension Secret { + /// The possible errors regarding a `Secret`. + enum Error: Swift.Error, LocalizedError { + /// The conversion from ASCII to data bytes failed. + case asciiConversionToDataFailed + + public var errorDescription: String? { + switch self { + case .asciiConversionToDataFailed: + return "The conversion from ASCII to data bytes failed." + } + } + } +} diff --git a/Sources/Uno/Uno.swift b/Sources/Uno/Uno.swift deleted file mode 100644 index 22839d9..0000000 --- a/Sources/Uno/Uno.swift +++ /dev/null @@ -1,6 +0,0 @@ -public struct Uno { - public private(set) var text = "Hello, World!" - - public init() { - } -} diff --git a/Tests/UnoTests/CounterBasedGeneratorTests.swift b/Tests/UnoTests/CounterBasedGeneratorTests.swift new file mode 100644 index 0000000..4f3f142 --- /dev/null +++ b/Tests/UnoTests/CounterBasedGeneratorTests.swift @@ -0,0 +1,116 @@ +// +// Uno +// +// Created by Gaetano Matonti on 27/08/21. +// + +import XCTest +@testable import Uno + +/// Test case for the `CounterBasedGenerator`. +/// - Note: Test data set from [page 31](https://datatracker.ietf.org/doc/html/rfc4226#page-31) of the +/// [RFC-4226](https://datatracker.ietf.org/doc/html/rfc4226) specifications. +final class CounterBasedGeneratorTests: XCTestCase { + + // MARK: - Stored Properties + + /// The secret to use for tests. + private var secret: Secret! + + /// The `CounterBasedGenerator` under test. + private var sut: CounterBasedGenerator! + + // MARK: - Test Case Functions + + override func setUpWithError() throws { + secret = try Secret(ascii: "12345678901234567890") + sut = CounterBasedGenerator(secret: secret) + } + + // MARK: - Tests + + func testCodeGenerationShouldThrow() { + let hotp = CounterBasedGenerator(secret: secret, codeLength: 4) + XCTAssertThrowsError(try hotp.generate(from: 0)) + } + + func testGeneratedHashesShouldBeCorrect() throws { + let testHashes = [ + "cc93cf18508d94934c64b65d8ba7667fb7cde4b0", + "75a48a19d4cbe100644e8ac1397eea747a2d33ab", + "0bacb7fa082fef30782211938bc1c5e70416ff44", + "66c28227d03a2d5529262ff016a1e6ef76557ece", + "a904c900a64b35909874b33e61c5938a8e15ed1c", + "a37e783d7b7233c083d4f62926c7a25f238d0316", + "bc9cd28561042c83f219324d3c607256c03272ae", + "a4fb960c0bc06e1eabb804e5b397cdc4b45596fa", + "1b3c89f65e6c9e883012052823443f048b4332db", + "1637409809a679dc698207310c8c7fc07290d9e5" + ] + + for index in testHashes.indices { + let generatedHash = try sut.generateHMACHash(from: UInt64(index)) + XCTAssertEqual(generatedHash.hexString, testHashes[index]) + } + } + + func testGeneratedTrimmedHashesShouldBeCorrect() throws { + let trimmedHashes = [ + "4c93cf18", + "41397eea", + "82fef30", + "66ef7655", + "61c5938a", + "33c083d4", + "7256c032", + "4e5b397", + "2823443f", + "2679dc69" + ] + + for index in trimmedHashes.indices { + let generatedHash = try sut.generateHMACHash(from: UInt64(index)) + XCTAssertEqual(generatedHash.dynamicallyTrimmedHexadecimals, trimmedHashes[index]) + } + } + + func testGeneratedTrimmedDecimalsShouldBeCorrect() throws { + let trimmedDecimals: [UInt64] = [ + 1284755224, + 1094287082, + 137359152, + 1726969429, + 1640338314, + 868254676, + 1918287922, + 82162583, + 673399871, + 645520489 + ] + + for index in trimmedDecimals.indices { + let generatedHash = try sut.generateHMACHash(from: UInt64(index)) + XCTAssertEqual(generatedHash.dynamicallyTrimmedDecimals, trimmedDecimals[index]) + } + } + + func testGeneratedOTPsShouldBeCorrect() throws { + let otps = [ + "755224", + "287082", + "359152", + "969429", + "338314", + "254676", + "287922", + "162583", + "399871", + "520489" + ] + + for index in otps.indices { + let generatedOTP = try sut.generate(from: UInt64(index)) + XCTAssertEqual(generatedOTP, otps[index]) + } + } +} diff --git a/Tests/UnoTests/StringTests.swift b/Tests/UnoTests/StringTests.swift new file mode 100644 index 0000000..3d5d2ab --- /dev/null +++ b/Tests/UnoTests/StringTests.swift @@ -0,0 +1,42 @@ +// +// Uno +// +// Created by Gaetano Matonti on 27/08/21. +// + +import XCTest +@testable import Uno + +/// Test case for the `String` type extensions. +final class StringTests: XCTestCase { + + // MARK: - Tests + + func testAuthenticationCodeShouldBeFormattedCorrectly() { + let testNumbers = [ + 0, + 1, + 12, + 123, + 1234, + 12345, + 123456 + ] + + let expectedResults = [ + "000000", + "000001", + "000012", + "000123", + "001234", + "012345", + "123456" + ] + + for index in testNumbers.indices { + let code = testNumbers[index] + let formattedCode = String.formatAuthenticationCode(UInt64(code), numberOfDigits: 6) + XCTAssertEqual(formattedCode, expectedResults[index]) + } + } +} diff --git a/Tests/UnoTests/UnoTests.swift b/Tests/UnoTests/UnoTests.swift deleted file mode 100644 index 36e4dc1..0000000 --- a/Tests/UnoTests/UnoTests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import XCTest -@testable import Uno - -final class UnoTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(Uno().text, "Hello, World!") - } -} From 2b96b66c398d4fbddb7fa507e22c58ea4f8a9427 Mon Sep 17 00:00:00 2001 From: Gaetano Matonti Date: Sun, 29 Aug 2021 17:27:19 +0200 Subject: [PATCH 03/10] Add Time-based One Time Password Generator (#10) * Add logic for OTP generation and tests * Refactor code and add docs * Add docs and refactor * Fix README.md headers * Update Package.resolved * Add a new Secret wrapper object * Remove file name from headers * Refactor the HOTP generation and tests * Update docs * Rename OTP and HOTP with a "generator" name * Add TImeBasedGenerator and tests * Rename TimeBasedGenerator.swift to TimeBasedGeneratorTests.swift * Fix build error --- Sources/Uno/TimeBasedGenerator.swift | 83 ++++++++++++++++++++ Tests/UnoTests/TimeBasedGeneratorTests.swift | 73 +++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 Sources/Uno/TimeBasedGenerator.swift create mode 100644 Tests/UnoTests/TimeBasedGeneratorTests.swift diff --git a/Sources/Uno/TimeBasedGenerator.swift b/Sources/Uno/TimeBasedGenerator.swift new file mode 100644 index 0000000..2d78b9b --- /dev/null +++ b/Sources/Uno/TimeBasedGenerator.swift @@ -0,0 +1,83 @@ +// +// Uno +// +// Created by Gaetano Matonti on 28/08/21. +// + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +import Foundation + +/// A type that represents a Time-based one-time password. +public struct TimeBasedGenerator: AuthenticationCodeGenerator { + /// The possible errors thrown in the `TimeBasedGenerator`. + public enum Error: Swift.Error { + /// The provided timestep is not valid. + case timestepInvalid + + /// The time for counter computation is invalid. + case timeInvalid + } + + // MARK: - Stored Properties + + public let secret: Secret + + public let codeLength: Int + + /// The period of validity of the authentication code expressed in seconds. + public let timestep: TimeInterval + + /// The underlying code-based generator to generate an HOTP. + private let counterBasedGenerator: CounterBasedGenerator + + // MARK: - Init + + public init(secret: Secret, codeLength: Int = 6, timestep: TimeInterval) { + self.secret = secret + self.codeLength = codeLength + self.timestep = timestep + self.counterBasedGenerator = CounterBasedGenerator(secret: secret, codeLength: codeLength) + } +} + +// MARK: - Functions + +extension TimeBasedGenerator { + /// Generates a time-based OTP from the seconds interval. + /// - Parameter time: The `Date` representing the time to use for generation. + /// - Returns: A `String` representing the generated one-time password. + public func generate(from time: Date) throws -> String { + try generate(from: time.timeIntervalSince1970) + } + + /// Generates a time-based OTP from the seconds interval. + /// - Parameter secondsSince1970: The Unix epoch to use for generation. + /// - Returns: A `String` representing the generated one-time password. + public func generate(from secondsSince1970: TimeInterval) throws -> String { + guard secondsSince1970 >= 0 else { + throw Error.timeInvalid + } + + let counterFromSeconds = try counter(from: secondsSince1970) + return try counterBasedGenerator.generate(from: counterFromSeconds) + } + + /// Computes the counter factor from the seconds interval. + /// - Parameter secondsSince1970: The Unix epoch to use for generation. + /// - Returns: A `UInt64` representing the counter factor for HOTP generation. + func counter(from secondsSince1970: TimeInterval) throws -> UInt64 { + guard timestep >= 0 else { + throw Error.timestepInvalid + } + + // Round down and remove the fractional part. + let secondsSince1970 = floor(secondsSince1970) + let counter = floor(secondsSince1970 / timestep) + return UInt64(counter) + } +} diff --git a/Tests/UnoTests/TimeBasedGeneratorTests.swift b/Tests/UnoTests/TimeBasedGeneratorTests.swift new file mode 100644 index 0000000..0f12107 --- /dev/null +++ b/Tests/UnoTests/TimeBasedGeneratorTests.swift @@ -0,0 +1,73 @@ +// +// Uno +// +// Created by Gaetano Matonti on 27/08/21. +// + +import XCTest +@testable import Uno + +/// Test case for the `TimeBasedGenerator`. +/// - Note: Test data set from [page 14](https://datatracker.ietf.org/doc/html/rfc6238#page-14) of the +/// [RFC-6238](https://datatracker.ietf.org/doc/html/rfc6238) specifications. +final class TimerBasedGeneratorTests: XCTestCase { + + // MARK: - Stored Properties + + /// The secret to use for tests. + private var secret: Secret! + + /// The `CounterBasedGenerator` under test. + private var sut: TimeBasedGenerator! + + /// The seconds data set. + private let testSeconds: [TimeInterval] = [ + 59, + 1111111109, + 1111111111, + 1234567890, + 2000000000, + 20000000000 + ] + + // MARK: - Test Case Functions + + override func setUpWithError() throws { + secret = try Secret(ascii: "12345678901234567890") + sut = TimeBasedGenerator(secret: secret, codeLength: 8, timestep: 30) + } + + // MARK: - Tests + + func testCounterConversionShouldBeCorrect() throws { + let expectedResults: [UInt64] = [ + 0x0000000000000001, + 0x00000000023523EC, + 0x00000000023523ED, + 0x000000000273EF07, + 0x0000000003F940AA, + 0x0000000027BC86AA + ] + + for index in testSeconds.indices { + let counter = try sut.counter(from: testSeconds[index]) + XCTAssertEqual(counter, expectedResults[index]) + } + } + + func testGeneratedOTPsShouldBeCorrect() throws { + let expectedResults = [ + "94287082", + "07081804", + "14050471", + "89005924", + "69279037", + "65353130" + ] + + for index in testSeconds.indices { + let counter = try sut.generate(from: testSeconds[index]) + XCTAssertEqual(counter, expectedResults[index]) + } + } +} From f729ac0b606a4f5d4c0f80656d5821b14820cc33 Mon Sep 17 00:00:00 2001 From: Gaetano Matonti Date: Mon, 30 Aug 2021 18:15:54 +0200 Subject: [PATCH 04/10] Add Secret from Hexadecimal String (#14) * Add Data initialisation from hex string * Update Sources/Uno/Secret.swift Co-authored-by: Fabrizio Brancati Co-authored-by: Fabrizio Brancati --- Sources/Uno/Extensions/Data+Extensions.swift | 46 ++++++++++++++++++++ Sources/Uno/Secret.swift | 6 +++ Tests/UnoTests/DataTests.swift | 22 ++++++++++ 3 files changed, 74 insertions(+) create mode 100644 Tests/UnoTests/DataTests.swift diff --git a/Sources/Uno/Extensions/Data+Extensions.swift b/Sources/Uno/Extensions/Data+Extensions.swift index 9724858..7dc2cdd 100644 --- a/Sources/Uno/Extensions/Data+Extensions.swift +++ b/Sources/Uno/Extensions/Data+Extensions.swift @@ -7,6 +7,42 @@ import Foundation extension Data { + + // MARK: - Init + + /// Creates a data buffer from a hexadecimal string. + /// - Parameter string: The hexadecimal `String` representation of the buffer. + init(hex string: String) throws { + let expectedBytesCount = string.count / 2 + let regex = try NSRegularExpression(pattern: "[0-9a-f]{1,2}", options: .caseInsensitive) + + let range = NSRange(string.startIndex..., in: string) + let hexString = NSString(string: string) + + var bytes: [UInt8] = [] + regex.enumerateMatches(in: string, range: range) { result, _, _ in + guard let result = result else { + return + } + + let substring = hexString.substring(with: result.range) + + guard let byte = UInt8(substring, radix: 16) else { + return + } + + bytes.append(byte) + } + + guard bytes.count == expectedBytesCount else { + throw Error.bytesCountMismatch + } + + self.init(bytes) + } + + // MARK: - Functions + /// An hexadecimal representation of the bytes. /// - Parameter forcingZeroPadding: Whether or not leading zeroes should be added to the string. /// - Returns: A `String` representing the bytes sequence in hexadecimal format. @@ -15,3 +51,13 @@ extension Data { return map { String(format: "%\(hexDigitFormat)hhx", $0) }.joined() } } + +// MARK: - Errors + +extension Data { + /// The possible errors regarding the `Data` type. + enum Error: Swift.Error { + /// The count of the converted bytes doesn't match the expected byte count. + case bytesCountMismatch + } +} diff --git a/Sources/Uno/Secret.swift b/Sources/Uno/Secret.swift index 7c0f81a..bb7d11e 100644 --- a/Sources/Uno/Secret.swift +++ b/Sources/Uno/Secret.swift @@ -38,6 +38,12 @@ public struct Secret { self.data = data } + + /// Creates an instance of `Secret` from an ASCII encoded String. + /// - Parameter string: The hexadecimal `String` representation of the secret. + public init(hex string: String) throws { + self.data = try Data(hex: string) + } } // MARK: - Errors diff --git a/Tests/UnoTests/DataTests.swift b/Tests/UnoTests/DataTests.swift new file mode 100644 index 0000000..702b6c9 --- /dev/null +++ b/Tests/UnoTests/DataTests.swift @@ -0,0 +1,22 @@ +// +// Uno +// +// Created by Gaetano Matonti on 29/08/21. +// + +import XCTest +@testable import Uno + +/// Test case for the `Data` type extensions. +final class DataTests: XCTestCase { + + // MARK: - Tests + + func testDataFromHexStringShouldBeCorrect() throws { + let hex = "48656C6C6F21DEADBEEF" + let data = try Data(hex: hex) + let bytes = data.map { $0 } + let expectedResults: [UInt8] = [0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x21, 0xDE, 0xAD, 0xBE, 0xEF] + XCTAssertEqual(bytes, expectedResults) + } +} From bc60c680b4e1c4f9877630c6891289f249638f20 Mon Sep 17 00:00:00 2001 From: Gaetano Matonti Date: Sat, 4 Sep 2021 19:10:18 +0200 Subject: [PATCH 05/10] Add SHA256 and SHA512 Hash Functions Support (#11) * Add Algorithm model and refactor base HMAC logic * Improve hash hexadecimal representations * Add tests for new hash functions * Add error for hash function key requirements * Refactor and update docs * Update Sources/Uno/Algorithm.swift * Update Sources/Uno/Extensions/Data+Extensions.swift Co-authored-by: Fabrizio Brancati Co-authored-by: Fabrizio Brancati --- Sources/Uno/Algorithm.swift | 51 ++++++++++++++++ Sources/Uno/AuthenticationCodeGenerator.swift | 3 + Sources/Uno/CounterBasedGenerator.swift | 42 +++++++++++--- Sources/Uno/Extensions/Data+Extensions.swift | 45 +++++++++++--- ...MessageAuthenticationCode+Extensions.swift | 57 ------------------ Sources/Uno/Secret.swift | 11 ++++ Sources/Uno/TimeBasedGenerator.swift | 7 ++- .../UnoTests/CounterBasedGeneratorTests.swift | 13 +++-- Tests/UnoTests/TimeBasedGeneratorTests.swift | 58 ++++++++++++++++--- 9 files changed, 202 insertions(+), 85 deletions(-) create mode 100644 Sources/Uno/Algorithm.swift delete mode 100644 Sources/Uno/Extensions/MessageAuthenticationCode+Extensions.swift diff --git a/Sources/Uno/Algorithm.swift b/Sources/Uno/Algorithm.swift new file mode 100644 index 0000000..604706e --- /dev/null +++ b/Sources/Uno/Algorithm.swift @@ -0,0 +1,51 @@ +// +// Uno +// +// Created by Gaetano Matonti on 29/08/21. +// + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +/// The hash function used to generate the HMAC. +public enum Algorithm { + /// The SHA1 hash function. This is the most frequently used albeit insecure. + case sha1 + + /// The SHA256 hash function. + case sha256 + + /// The SHA512 hash function. + case sha512 +} + +// MARK: - Helpers + +extension Algorithm { + /// The minimum size of the symmetric key in bytes. + var minimumKeySize: Int { + switch self { + case .sha1: + return 20 + + case .sha256: + return 32 + + case .sha512: + return 64 + } + } +} + +// MARK: - Errors + +public extension Algorithm { + /// The possible errors regarding the hash functions. + enum Error: Swift.Error { + /// The minimum size of the symmetric key does not match the requirement. + case invalidMinimumKeySize + } +} diff --git a/Sources/Uno/AuthenticationCodeGenerator.swift b/Sources/Uno/AuthenticationCodeGenerator.swift index bd9da0b..daa868f 100644 --- a/Sources/Uno/AuthenticationCodeGenerator.swift +++ b/Sources/Uno/AuthenticationCodeGenerator.swift @@ -13,6 +13,9 @@ public protocol AuthenticationCodeGenerator { /// The amount of digits composing the authentication code. var codeLength: Int { get } + + /// The hash function used to generate the authentication code's hash. + var algorithm: Algorithm { get } } // MARK: - Helper Functions diff --git a/Sources/Uno/CounterBasedGenerator.swift b/Sources/Uno/CounterBasedGenerator.swift index 0a9b5bb..b386b93 100644 --- a/Sources/Uno/CounterBasedGenerator.swift +++ b/Sources/Uno/CounterBasedGenerator.swift @@ -25,12 +25,15 @@ public struct CounterBasedGenerator: AuthenticationCodeGenerator { public let secret: Secret public let codeLength: Int + + public let algorithm: Algorithm // MARK: - Init - public init(secret: Secret, codeLength: Int = 6) { + public init(secret: Secret, codeLength: Int = 6, algorithm: Algorithm = .sha1) { self.secret = secret self.codeLength = codeLength + self.algorithm = algorithm } } @@ -42,23 +45,48 @@ extension CounterBasedGenerator { /// - counter: A variable number that acts as a seed for the generator. /// - Returns: A `String` representing the generated one-time password. public func generate(from counter: UInt64) throws -> String { - let hash = try generateHMACHash(from: counter) - let code = hash.dynamicallyTrimmed(numberOfDigits: codeLength) + let hmac = try generateHMAC(from: counter) + let code = hmac.dynamicallyTrimmed(numberOfDigits: codeLength) return code } /// Generates the HMAC hash from a secret payload and a moving factor. /// - Parameters: /// - counter: A variable number that acts as a seed for the generator. - /// - Returns: A `MessageAuthenticationCode` representing the generated hash. - func generateHMACHash(from counter: UInt64) throws -> some MessageAuthenticationCode { + /// - Returns: A `Data` payload representing a HMAC. + func generateHMAC(from counter: UInt64) throws -> Data { guard isCodeLengthValid else { throw Error.codeLengthNotSupported } + guard secret.isValid(for: algorithm) else { + throw Algorithm.Error.invalidMinimumKeySize + } + var counter = counter.bigEndian let counterData = Data(bytes: &counter, count: MemoryLayout.size) - - return HMAC.authenticationCode(for: counterData, using: secret.symmetricKey) + + return generateHMAC(from: secret.symmetricKey, data: counterData) + } + + /// Generates the HMAC from a secret key and data payload. + /// - Note: Implementation works around a limitation for protocols with associated types + /// by returning a `Data` object instead of a more specific `MessageAuthenticationCode`. + /// This issue might be fixed in future implementations. + /// - Parameters: + /// - key: The symmetric cryptographic key to use as seed. + /// - data: The data payload to use as seed. + /// - Returns: A `Data` payload representing a HMAC. + func generateHMAC(from key: SymmetricKey, data: Data) -> Data { + switch algorithm { + case .sha1: + return Data(HMAC.authenticationCode(for: data, using: key)) + + case .sha256: + return Data(HMAC.authenticationCode(for: data, using: key)) + + case .sha512: + return Data(HMAC.authenticationCode(for: data, using: key)) + } } } diff --git a/Sources/Uno/Extensions/Data+Extensions.swift b/Sources/Uno/Extensions/Data+Extensions.swift index 7dc2cdd..360c75b 100644 --- a/Sources/Uno/Extensions/Data+Extensions.swift +++ b/Sources/Uno/Extensions/Data+Extensions.swift @@ -7,6 +7,34 @@ import Foundation extension Data { + /// The hexadecimal representation of the dynamically trimmed. + var dynamicallyTrimmedHexadecimals: String { + String(dynamicallyTrimmedHash, radix: 16) + } + + /// The dynamically trimmed hash. + var dynamicallyTrimmedHash: UInt64 { + let lastByte = last ?? 0x00 + // Get the last 4 bits of the packet. + let offset = Int(lastByte & 0x0f) + + var hash: UInt64 = 0 + hash |= UInt64(self[offset] & 0x7f) << 24 + hash |= UInt64(self[offset + 1] & 0xff) << 16 + hash |= UInt64(self[offset + 2] & 0xff) << 8 + hash |= UInt64(self[offset + 3] & 0xff) + return hash + } + + /// The hexadecimal representation of the data payload. + var hexString: String { + hexadecimals.joined() + } + + /// The array of the hexadecimal representation of the bytes. + var hexadecimals: [String] { + map { String(format: "%02hhx", $0) } + } // MARK: - Init @@ -40,15 +68,16 @@ extension Data { self.init(bytes) } - // MARK: - Functions - - /// An hexadecimal representation of the bytes. - /// - Parameter forcingZeroPadding: Whether or not leading zeroes should be added to the string. - /// - Returns: A `String` representing the bytes sequence in hexadecimal format. - func hexString(forcingZeroPadding shouldForcePadding: Bool = true) -> String { - let hexDigitFormat = shouldForcePadding ? "02" : "" - return map { String(format: "%\(hexDigitFormat)hhx", $0) }.joined() + + /// Trims the authentication code using *dynamic truncation*. + /// - Parameter numberOfDigits: The amount of digits the truncated authentication code should be composed of. + /// - Returns: A `String` representing the truncated authentication code suitable for user authentication. + func dynamicallyTrimmed(numberOfDigits: Int) -> String { + let decimalPosition = UInt64(pow(10, Double(numberOfDigits))) + let code = dynamicallyTrimmedHash % decimalPosition + + return String.formatAuthenticationCode(code, numberOfDigits: numberOfDigits) } } diff --git a/Sources/Uno/Extensions/MessageAuthenticationCode+Extensions.swift b/Sources/Uno/Extensions/MessageAuthenticationCode+Extensions.swift deleted file mode 100644 index 7cc55e4..0000000 --- a/Sources/Uno/Extensions/MessageAuthenticationCode+Extensions.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// Uno -// -// Created by Gaetano Matonti on 27/08/21. -// - -#if canImport(CryptoKit) -import CryptoKit -#else -import Crypto -#endif - -import Foundation - -extension MessageAuthenticationCode { - /// The dynamically trimmed hash in decimal format. - var dynamicallyTrimmedDecimals: UInt64 { - UInt64(dynamicallyTrimmedHexadecimals, radix: 16) ?? 0 - } - - /// The dynamically trimmed hash in hexadecimal format. - var dynamicallyTrimmedHexadecimals: String { - dynamicallyTrimmedHash.hexString(forcingZeroPadding: false) - } - - /// The dynamically trimmed hash bytes. - var dynamicallyTrimmedHash: Data { - let data = Data(self) - let lastByte = data.last ?? 0x00 - // Get the last 4 bits of the packet. - let startIndex = Int(lastByte & 0x0f) - // Get the end index needed for a 32 bit stride. - let endIndex = startIndex + 3 - // Create a new `Data` object to prevent "offset index" errors. - var trimmedPacket = Data(data[startIndex...endIndex]) - // Truncate the first bit (the packet should contain 31 bits). - trimmedPacket[0] &= 0x7f - return trimmedPacket - } - - /// An hexadecimal representation of the code hash. - var hexString: String { - Data(self).hexString() - } - - // MARK: - Functions - - /// Trims the authentication code using *dynamic truncation*. - /// - Parameter numberOfDigits: The amount of digits the truncated authentication code should be composed of. - /// - Returns: A `String` representing the truncated authentication code suitable for user authentication. - func dynamicallyTrimmed(numberOfDigits: Int) -> String { - let decimalPosition = UInt64(pow(10, Double(numberOfDigits))) - let code = dynamicallyTrimmedDecimals % decimalPosition - - return String.formatAuthenticationCode(code, numberOfDigits: numberOfDigits) - } -} diff --git a/Sources/Uno/Secret.swift b/Sources/Uno/Secret.swift index bb7d11e..b61454c 100644 --- a/Sources/Uno/Secret.swift +++ b/Sources/Uno/Secret.swift @@ -46,6 +46,17 @@ public struct Secret { } } +// MARK: - Helpers + +public extension Secret { + /// Checks whether the secret is valid for use with the specified algorithm. + /// - Parameter algorithm: The algorithm to check on. + /// - Returns: A `Bool` indicating whether the secret is valid. + func isValid(for algorithm: Algorithm) -> Bool { + data.count >= algorithm.minimumKeySize + } +} + // MARK: - Errors public extension Secret { diff --git a/Sources/Uno/TimeBasedGenerator.swift b/Sources/Uno/TimeBasedGenerator.swift index 2d78b9b..f1c7e64 100644 --- a/Sources/Uno/TimeBasedGenerator.swift +++ b/Sources/Uno/TimeBasedGenerator.swift @@ -29,6 +29,8 @@ public struct TimeBasedGenerator: AuthenticationCodeGenerator { public let codeLength: Int + public let algorithm: Algorithm + /// The period of validity of the authentication code expressed in seconds. public let timestep: TimeInterval @@ -37,11 +39,12 @@ public struct TimeBasedGenerator: AuthenticationCodeGenerator { // MARK: - Init - public init(secret: Secret, codeLength: Int = 6, timestep: TimeInterval) { + public init(secret: Secret, codeLength: Int = 6, algorithm: Algorithm = .sha1, timestep: TimeInterval) { self.secret = secret self.codeLength = codeLength + self.algorithm = algorithm self.timestep = timestep - self.counterBasedGenerator = CounterBasedGenerator(secret: secret, codeLength: codeLength) + self.counterBasedGenerator = CounterBasedGenerator(secret: secret, codeLength: codeLength, algorithm: algorithm) } } diff --git a/Tests/UnoTests/CounterBasedGeneratorTests.swift b/Tests/UnoTests/CounterBasedGeneratorTests.swift index 4f3f142..ed1e0b4 100644 --- a/Tests/UnoTests/CounterBasedGeneratorTests.swift +++ b/Tests/UnoTests/CounterBasedGeneratorTests.swift @@ -49,7 +49,7 @@ final class CounterBasedGeneratorTests: XCTestCase { ] for index in testHashes.indices { - let generatedHash = try sut.generateHMACHash(from: UInt64(index)) + let generatedHash = try sut.generateHMAC(from: UInt64(index)) XCTAssertEqual(generatedHash.hexString, testHashes[index]) } } @@ -69,7 +69,7 @@ final class CounterBasedGeneratorTests: XCTestCase { ] for index in trimmedHashes.indices { - let generatedHash = try sut.generateHMACHash(from: UInt64(index)) + let generatedHash = try sut.generateHMAC(from: UInt64(index)) XCTAssertEqual(generatedHash.dynamicallyTrimmedHexadecimals, trimmedHashes[index]) } } @@ -89,8 +89,8 @@ final class CounterBasedGeneratorTests: XCTestCase { ] for index in trimmedDecimals.indices { - let generatedHash = try sut.generateHMACHash(from: UInt64(index)) - XCTAssertEqual(generatedHash.dynamicallyTrimmedDecimals, trimmedDecimals[index]) + let generatedHash = try sut.generateHMAC(from: UInt64(index)) + XCTAssertEqual(generatedHash.dynamicallyTrimmedHash, trimmedDecimals[index]) } } @@ -113,4 +113,9 @@ final class CounterBasedGeneratorTests: XCTestCase { XCTAssertEqual(generatedOTP, otps[index]) } } + + func testSecretValidForSHA256ShouldThrow() throws { + let sut = CounterBasedGenerator(secret: secret, algorithm: .sha256) + XCTAssertThrowsError(try sut.generate(from: 0)) + } } diff --git a/Tests/UnoTests/TimeBasedGeneratorTests.swift b/Tests/UnoTests/TimeBasedGeneratorTests.swift index 0f12107..102dee5 100644 --- a/Tests/UnoTests/TimeBasedGeneratorTests.swift +++ b/Tests/UnoTests/TimeBasedGeneratorTests.swift @@ -14,12 +14,15 @@ final class TimerBasedGeneratorTests: XCTestCase { // MARK: - Stored Properties - /// The secret to use for tests. - private var secret: Secret! + /// The secret to use for SHA1 tests. + private var secretSHA1: Secret! - /// The `CounterBasedGenerator` under test. - private var sut: TimeBasedGenerator! + /// The secret to use for SHA256 tests. + private var secretSHA256: Secret! + /// The secret to use for SHA512 tests. + private var secretSHA512: Secret! + /// The seconds data set. private let testSeconds: [TimeInterval] = [ 59, @@ -33,13 +36,16 @@ final class TimerBasedGeneratorTests: XCTestCase { // MARK: - Test Case Functions override func setUpWithError() throws { - secret = try Secret(ascii: "12345678901234567890") - sut = TimeBasedGenerator(secret: secret, codeLength: 8, timestep: 30) + secretSHA1 = try Secret(ascii: "12345678901234567890") + secretSHA256 = try Secret(ascii: "12345678901234567890123456789012") + secretSHA512 = try Secret(ascii: "1234567890123456789012345678901234567890123456789012345678901234") } // MARK: - Tests func testCounterConversionShouldBeCorrect() throws { + let sut = TimeBasedGenerator(secret: secretSHA1, codeLength: 8, timestep: 30) + let expectedResults: [UInt64] = [ 0x0000000000000001, 0x00000000023523EC, @@ -55,7 +61,9 @@ final class TimerBasedGeneratorTests: XCTestCase { } } - func testGeneratedOTPsShouldBeCorrect() throws { + func testGeneratedOTPsSHA1ShouldBeCorrect() throws { + let sut = TimeBasedGenerator(secret: secretSHA1, codeLength: 8, timestep: 30) + let expectedResults = [ "94287082", "07081804", @@ -70,4 +78,40 @@ final class TimerBasedGeneratorTests: XCTestCase { XCTAssertEqual(counter, expectedResults[index]) } } + + func testGeneratedOTPsSHA256ShouldBeCorrect() throws { + let sut = TimeBasedGenerator(secret: secretSHA256, codeLength: 8, algorithm: .sha256, timestep: 30) + + let expectedResults = [ + "46119246", + "68084774", + "67062674", + "91819424", + "90698825", + "77737706" + ] + + for index in testSeconds.indices { + let counter = try sut.generate(from: testSeconds[index]) + XCTAssertEqual(counter, expectedResults[index]) + } + } + + func testGeneratedOTPsSHA512ShouldBeCorrect() throws { + let sut = TimeBasedGenerator(secret: secretSHA512, codeLength: 8, algorithm: .sha512, timestep: 30) + + let expectedResults = [ + "90693936", + "25091201", + "99943326", + "93441116", + "38618901", + "47863826" + ] + + for index in testSeconds.indices { + let counter = try sut.generate(from: testSeconds[index]) + XCTAssertEqual(counter, expectedResults[index]) + } + } } From 350abfe006716dec0bdb405a347ea594f509350f Mon Sep 17 00:00:00 2001 From: Gaetano Matonti Date: Mon, 6 Sep 2021 11:40:18 +0200 Subject: [PATCH 06/10] Update .gitignore (#17) * Update .gitignore * Add eof --- .gitignore | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 93c28b4..f0d9c72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,35 @@ -# Created by https://www.toptal.com/developers/gitignore/api/swiftpm,code -# Edit at https://www.toptal.com/developers/gitignore?templates=swiftpm,code -#!! ERROR: code is undefined. Use list command to see defined gitignore types !!# +# Created by https://www.toptal.com/developers/gitignore/api/swiftpm,macos +# Edit at https://www.toptal.com/developers/gitignore?templates=swiftpm,macos + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk ### SwiftPM ### Packages @@ -12,4 +40,4 @@ DerivedData/ .swiftpm -# End of https://www.toptal.com/developers/gitignore/api/swiftpm,code \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/swiftpm,macos From 02da3ef1f9b1f791e9705578098a5c6f78e1e0f9 Mon Sep 17 00:00:00 2001 From: Gaetano Matonti Date: Mon, 6 Sep 2021 11:40:49 +0200 Subject: [PATCH 07/10] Add Open Source Documentation (#15) * Create LICENSE * Update README.md * Update issue templates * Add code of conduct * Add CONTRIBUTING.md --- .github/CONTRIBUTING.md | 76 +++++++++++++ .github/ISSUE_TEMPLATE/bug_report.md | 36 ++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 ++++ CODE_OF_CONDUCT.md | 128 ++++++++++++++++++++++ LICENSE | 21 ++++ README.md | 16 ++- 6 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 LICENSE diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..9e5d075 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing to Uno +## Code Guidelines +We believe in the power of sharing knowledge with other members of the community, so writing clean code is a crucial part of getting a point across in a clear manner. + +### Indentation +Code is indented using 2 spaces or tabs in order to use less space and to be more readable on smaller screens. + +### Order +The order of your code is very important. This may also very depending on the situation. +Each type or extension should be split up in the following blocks: +- Constants (or static properties) +- Stored properties/variables +- Computed properties +- Init +- Functions + +Each of these blocks should be marked with the correspond mark: +- `// MARK: Constants` +- `// MARK: Stored Properties` +- `// MARK: Computed Properties` +- `// MARK: Init` +- `// MARK: Functions` + +Marks are not necessary in protocols – as long as they are short and concise – or in types if only one type of block is present. + +Additional marks can be added for `Error` types definitions and `Helper` properties or functions. + +Additionally, the content of each block should be sorted by access control: public at the top, private at the bottom. + +## Documentation +Documentation is a very important part of your code and is needed for others to properly understand how to use it. +As such we write documentation for every single component of our code. + +Functions require a description of what it does, what its parameters are and, if it returns a value, a description of its return type. + +## Comments +Comments in code are really useful to explain your intentions and the reason why something has been done – they are also reminders for our future selves. + +There may be some occasions when our code might get complicated. In those occasions comments are strongly encouraged – although we prefer our code to be self explanatory. + +Commented code is prohibited – if you don't need it delete it. + +## Git +The preferred workflow when working with git is [Gitflow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow). + +- The `main` branch mirrors the current release version. +- The `develop` branch is the main working branch. You do not work directly on this branch. + +#### Task branches +The following branches should always be created from `develop` and should be merged into `develop` when work is done. +- The `feature` branches contain code for a new feature. These branches are prefixed with `feature/`. +- The `bugfix` branches contain code for bug fixes. + +The `realease` branches contain code for a new release. This is the only instance when a branch should be merged into `main`. + +## Pull Requests +When work on a `feature` or `bugfix` is completed a new pull request should be opened. +Pull request should be assigned to the person who mainly worked on the code, tagged with the correct tag, and linked to the corresponding issue (if any). +Make sure to write a good description of the goal of the pull request. + +Code review is required from at least one of the code owners. +Each time a pull request is open, a Github Action will build and test the code – this check must be successful in order for the pull request to be approved. + +Lastly, pull requests should be reasonably sized – limit is around 600 changes. If you can't fit into this limit, more than a pull request is required. In this case work should be split up in subtasks. + +### Example +The `Task` feature will be split up into two subtask called `Subtask 1` and `Subtask 2`. + +``` +Feature: Task + -- Subtask 1 + -- Subtask 2 +``` + +You should create a first branch for the task `feature/task` and two for the subtasks – `feature/task@subtask-1` and `feature/task@subtask-2`. A pull request should be opened for each subtask feature and should be marked with the `subtask` tag. +Subtasks pull requests will be merged into the `feature/task` branch. Once every subtask is merged a pull request for the task should be opened. This pull request should reference all its subtask pull request in the description. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..e2c62b3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG]" +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b323fd6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[REQUEST]" +labels: feature request +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c914d1b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[gaetanomatonti@icloud.com](mailto:gaetanomatonti@icloud.com). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d96a9f5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Gaetano Matonti + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 6a7dda1..b0e905e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,21 @@ A simple OTP generator written in Swift. -## One-Time Password +# Installation +Currently this package supports Swift Package Manager only. More dependency managers may be supported in future releases. + +## Swift Package Manger +[Swift Package Manager](https://swift.org/package-manager/#conceptual-overview) is a tool for managing the distribution of Swift Code. It's integrated in the Swift build system and can be used to automate the dependency management. + +To add this package as a dependency of another Swift package you just need to add it to the `dependencies` value of your package manifest (the `Package.swift` file). + +```swift +dependencies: [ + .package(url: "https://github.com/gaetanomatonti/Uno.git", from: "0.1.0"), +] +``` + +# One-Time Password An OTP (One-Time Password) is an authentication code that can only be used once. As such, it is often used in combination with a regular password to provide an extra layer of security (multi-factor authentication). ### HMAC-based One-Time Password From f7fb962e2e6027221f5bbf94309f43e8a68fcad9 Mon Sep 17 00:00:00 2001 From: Gaetano Matonti Date: Mon, 6 Sep 2021 18:57:29 +0200 Subject: [PATCH 08/10] Add Support for Base32 Encoded Secrets (#18) --- Package.resolved | 9 +++++++++ Package.swift | 6 ++++-- Sources/Uno/Secret.swift | 20 ++++++++++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Package.resolved b/Package.resolved index 318e73f..20df983 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { "object": { "pins": [ + { + "package": "FiveBits", + "repositoryURL": "https://github.com/gaetanomatonti/FiveBits.git", + "state": { + "branch": null, + "revision": "6135958239c3071fe8a36e04df75541b73c3a23d", + "version": "0.1.0" + } + }, { "package": "swift-crypto", "repositoryURL": "https://github.com/apple/swift-crypto.git", diff --git a/Package.swift b/Package.swift index 7e2506e..c4ef804 100644 --- a/Package.swift +++ b/Package.swift @@ -17,13 +17,15 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/apple/swift-crypto.git", from: "1.1.0"), + .package(url: "https://github.com/apple/swift-crypto.git", .upToNextMajor(from: "1.1.0")), + .package(url: "https://github.com/gaetanomatonti/FiveBits.git", .upToNextMajor(from: "0.1.0")) ], targets: [ .target( name: "Uno", dependencies: [ - .product(name: "Crypto", package: "swift-crypto") + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "FiveBits", package: "FiveBits") ] ), .testTarget( diff --git a/Sources/Uno/Secret.swift b/Sources/Uno/Secret.swift index b61454c..185c5a7 100644 --- a/Sources/Uno/Secret.swift +++ b/Sources/Uno/Secret.swift @@ -10,6 +10,7 @@ import CryptoKit import Crypto #endif +import FiveBits import Foundation /// An object containing information for a one-time password hash secret. @@ -29,8 +30,8 @@ public struct Secret { // MARK: - Init - /// Creates an instance of `Secret` from an ASCII encoded String. - /// - Parameter ascii: The ASCII encoded String. + /// Creates an instance of `Secret` from an ASCII encoded `String`. + /// - Parameter ascii: The ASCII encoded `String`. public init(ascii string: String) throws { guard let data = string.data(using: .ascii) else { throw Error.asciiConversionToDataFailed @@ -39,6 +40,16 @@ public struct Secret { self.data = data } + /// Creates an instance of `Secret` from a Base32 encoded `String`. + /// - Parameter base32String: The Base32 encoded `String`. + public init(base32Encoded base32String: String) throws { + guard let data = Data(base32Encoded: base32String) else { + throw Error.base32DecodingFailed + } + + self.data = data + } + /// Creates an instance of `Secret` from an ASCII encoded String. /// - Parameter string: The hexadecimal `String` representation of the secret. public init(hex string: String) throws { @@ -65,10 +76,15 @@ public extension Secret { /// The conversion from ASCII to data bytes failed. case asciiConversionToDataFailed + case base32DecodingFailed + public var errorDescription: String? { switch self { case .asciiConversionToDataFailed: return "The conversion from ASCII to data bytes failed." + + case .base32DecodingFailed: + return "The decoding of the Base32 string failed." } } } From 623f1f40d77ab41730f130352e3b6335e6dfa96c Mon Sep 17 00:00:00 2001 From: Gaetano Matonti Date: Thu, 9 Sep 2021 19:15:56 +0200 Subject: [PATCH 09/10] Add One Time Password Metadata Type (#20) --- Sources/Uno/Algorithm.swift | 51 ---------- Sources/Uno/AuthenticationCodeGenerator.swift | 35 ------- Sources/Uno/Extensions/Data+Extensions.swift | 9 +- .../AuthenticationCodeGenerator.swift | 19 ++++ .../CounterBasedGenerator.swift | 25 ++--- .../{ => Generator}/TimeBasedGenerator.swift | 48 +++++++--- Sources/Uno/OneTimePassword/Algorithm.swift | 60 ++++++++++++ Sources/Uno/OneTimePassword/Kind.swift | 18 ++++ Sources/Uno/OneTimePassword/Length.swift | 86 +++++++++++++++++ Sources/Uno/OneTimePassword/Metadata.swift | 37 ++++++++ .../Uno/OneTimePassword/OneTimePassword.swift | 8 ++ Sources/Uno/OneTimePassword/Secret.swift | 94 +++++++++++++++++++ Sources/Uno/Secret.swift | 91 ------------------ .../UnoTests/CounterBasedGeneratorTests.swift | 9 +- Tests/UnoTests/TimeBasedGeneratorTests.swift | 20 ++-- 15 files changed, 386 insertions(+), 224 deletions(-) delete mode 100644 Sources/Uno/Algorithm.swift delete mode 100644 Sources/Uno/AuthenticationCodeGenerator.swift create mode 100644 Sources/Uno/Generator/AuthenticationCodeGenerator.swift rename Sources/Uno/{ => Generator}/CounterBasedGenerator.swift (82%) rename Sources/Uno/{ => Generator}/TimeBasedGenerator.swift (72%) create mode 100644 Sources/Uno/OneTimePassword/Algorithm.swift create mode 100644 Sources/Uno/OneTimePassword/Kind.swift create mode 100644 Sources/Uno/OneTimePassword/Length.swift create mode 100644 Sources/Uno/OneTimePassword/Metadata.swift create mode 100644 Sources/Uno/OneTimePassword/OneTimePassword.swift create mode 100644 Sources/Uno/OneTimePassword/Secret.swift delete mode 100644 Sources/Uno/Secret.swift diff --git a/Sources/Uno/Algorithm.swift b/Sources/Uno/Algorithm.swift deleted file mode 100644 index 604706e..0000000 --- a/Sources/Uno/Algorithm.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Uno -// -// Created by Gaetano Matonti on 29/08/21. -// - -#if canImport(CryptoKit) -import CryptoKit -#else -import Crypto -#endif - -/// The hash function used to generate the HMAC. -public enum Algorithm { - /// The SHA1 hash function. This is the most frequently used albeit insecure. - case sha1 - - /// The SHA256 hash function. - case sha256 - - /// The SHA512 hash function. - case sha512 -} - -// MARK: - Helpers - -extension Algorithm { - /// The minimum size of the symmetric key in bytes. - var minimumKeySize: Int { - switch self { - case .sha1: - return 20 - - case .sha256: - return 32 - - case .sha512: - return 64 - } - } -} - -// MARK: - Errors - -public extension Algorithm { - /// The possible errors regarding the hash functions. - enum Error: Swift.Error { - /// The minimum size of the symmetric key does not match the requirement. - case invalidMinimumKeySize - } -} diff --git a/Sources/Uno/AuthenticationCodeGenerator.swift b/Sources/Uno/AuthenticationCodeGenerator.swift deleted file mode 100644 index daa868f..0000000 --- a/Sources/Uno/AuthenticationCodeGenerator.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Uno -// -// Created by Gaetano Matonti on 28/08/21. -// - -import Foundation - -/// A protocol the represents the requirements for a one-time password generator. -public protocol AuthenticationCodeGenerator { - /// The secret to seed into the generator. - var secret: Secret { get } - - /// The amount of digits composing the authentication code. - var codeLength: Int { get } - - /// The hash function used to generate the authentication code's hash. - var algorithm: Algorithm { get } -} - -// MARK: - Helper Functions - -public extension AuthenticationCodeGenerator { - /// The range describing the supported length of the authentication code. - /// - Note: As required by [RFC-4226](https://datatracker.ietf.org/doc/html/rfc4226) - /// an authentication code should have a minimum length of 6 and a maximum of 8 digits. - static var supportedCodeLengthRange: ClosedRange { - 6...8 - } - - /// Whether the specified length of the code is valid. - var isCodeLengthValid: Bool { - Self.supportedCodeLengthRange.contains(codeLength) - } -} diff --git a/Sources/Uno/Extensions/Data+Extensions.swift b/Sources/Uno/Extensions/Data+Extensions.swift index 360c75b..b397db1 100644 --- a/Sources/Uno/Extensions/Data+Extensions.swift +++ b/Sources/Uno/Extensions/Data+Extensions.swift @@ -85,8 +85,15 @@ extension Data { extension Data { /// The possible errors regarding the `Data` type. - enum Error: Swift.Error { + enum Error: Swift.Error, LocalizedError { /// The count of the converted bytes doesn't match the expected byte count. case bytesCountMismatch + + var errorDescription: String? { + switch self { + case .bytesCountMismatch: + return "The count of the converted bytes doesn't match the expected byte count." + } + } } } diff --git a/Sources/Uno/Generator/AuthenticationCodeGenerator.swift b/Sources/Uno/Generator/AuthenticationCodeGenerator.swift new file mode 100644 index 0000000..6c7a71d --- /dev/null +++ b/Sources/Uno/Generator/AuthenticationCodeGenerator.swift @@ -0,0 +1,19 @@ +// +// Uno +// +// Created by Gaetano Matonti on 28/08/21. +// + +import Foundation + +/// A protocol the represents the requirements for a one-time password generator. +public protocol AuthenticationCodeGenerator { + /// The secret to seed into the generator. + var secret: OneTimePassword.Secret { get } + + /// The amount of digits composing the authentication code. + var codeLength: OneTimePassword.Length { get } + + /// The hash function used to generate the authentication code's hash. + var algorithm: OneTimePassword.Algorithm { get } +} diff --git a/Sources/Uno/CounterBasedGenerator.swift b/Sources/Uno/Generator/CounterBasedGenerator.swift similarity index 82% rename from Sources/Uno/CounterBasedGenerator.swift rename to Sources/Uno/Generator/CounterBasedGenerator.swift index b386b93..e039d29 100644 --- a/Sources/Uno/CounterBasedGenerator.swift +++ b/Sources/Uno/Generator/CounterBasedGenerator.swift @@ -14,23 +14,22 @@ import Foundation /// A type that represents an HMAC-based one-time password. public struct CounterBasedGenerator: AuthenticationCodeGenerator { - /// The possible errors thrown in `CounterBasedGenerator`. - public enum Error: Swift.Error { - /// The length of the authentication code is not supported. - case codeLengthNotSupported - } // MARK: - Stored Properties - public let secret: Secret + public let secret: OneTimePassword.Secret - public let codeLength: Int + public let codeLength: OneTimePassword.Length - public let algorithm: Algorithm + public let algorithm: OneTimePassword.Algorithm // MARK: - Init - public init(secret: Secret, codeLength: Int = 6, algorithm: Algorithm = .sha1) { + public init( + secret: OneTimePassword.Secret, + codeLength: OneTimePassword.Length = .six, + algorithm: OneTimePassword.Algorithm = .sha1 + ) { self.secret = secret self.codeLength = codeLength self.algorithm = algorithm @@ -46,7 +45,7 @@ extension CounterBasedGenerator { /// - Returns: A `String` representing the generated one-time password. public func generate(from counter: UInt64) throws -> String { let hmac = try generateHMAC(from: counter) - let code = hmac.dynamicallyTrimmed(numberOfDigits: codeLength) + let code = hmac.dynamicallyTrimmed(numberOfDigits: codeLength.rawValue) return code } @@ -55,12 +54,8 @@ extension CounterBasedGenerator { /// - counter: A variable number that acts as a seed for the generator. /// - Returns: A `Data` payload representing a HMAC. func generateHMAC(from counter: UInt64) throws -> Data { - guard isCodeLengthValid else { - throw Error.codeLengthNotSupported - } - guard secret.isValid(for: algorithm) else { - throw Algorithm.Error.invalidMinimumKeySize + throw OneTimePassword.Algorithm.Error.invalidMinimumKeySize } var counter = counter.bigEndian diff --git a/Sources/Uno/TimeBasedGenerator.swift b/Sources/Uno/Generator/TimeBasedGenerator.swift similarity index 72% rename from Sources/Uno/TimeBasedGenerator.swift rename to Sources/Uno/Generator/TimeBasedGenerator.swift index f1c7e64..fdd17cb 100644 --- a/Sources/Uno/TimeBasedGenerator.swift +++ b/Sources/Uno/Generator/TimeBasedGenerator.swift @@ -14,22 +14,14 @@ import Foundation /// A type that represents a Time-based one-time password. public struct TimeBasedGenerator: AuthenticationCodeGenerator { - /// The possible errors thrown in the `TimeBasedGenerator`. - public enum Error: Swift.Error { - /// The provided timestep is not valid. - case timestepInvalid - - /// The time for counter computation is invalid. - case timeInvalid - } // MARK: - Stored Properties - public let secret: Secret + public let secret: OneTimePassword.Secret - public let codeLength: Int + public let codeLength: OneTimePassword.Length - public let algorithm: Algorithm + public let algorithm: OneTimePassword.Algorithm /// The period of validity of the authentication code expressed in seconds. public let timestep: TimeInterval @@ -39,7 +31,12 @@ public struct TimeBasedGenerator: AuthenticationCodeGenerator { // MARK: - Init - public init(secret: Secret, codeLength: Int = 6, algorithm: Algorithm = .sha1, timestep: TimeInterval) { + public init( + secret: OneTimePassword.Secret, + codeLength: OneTimePassword.Length = .six, + algorithm: OneTimePassword.Algorithm = .sha1, + timestep: TimeInterval + ) { self.secret = secret self.codeLength = codeLength self.algorithm = algorithm @@ -63,7 +60,7 @@ extension TimeBasedGenerator { /// - Returns: A `String` representing the generated one-time password. public func generate(from secondsSince1970: TimeInterval) throws -> String { guard secondsSince1970 >= 0 else { - throw Error.timeInvalid + throw Error.invalidTime } let counterFromSeconds = try counter(from: secondsSince1970) @@ -75,7 +72,7 @@ extension TimeBasedGenerator { /// - Returns: A `UInt64` representing the counter factor for HOTP generation. func counter(from secondsSince1970: TimeInterval) throws -> UInt64 { guard timestep >= 0 else { - throw Error.timestepInvalid + throw Error.invalidTimestep } // Round down and remove the fractional part. @@ -84,3 +81,26 @@ extension TimeBasedGenerator { return UInt64(counter) } } + +// MARK: - Errors + +public extension TimeBasedGenerator { + /// The possible errors thrown in the `TimeBasedGenerator`. + enum Error: Swift.Error, LocalizedError { + /// The provided timestep for OTP generation is not valid. + case invalidTimestep + + /// The time for counter computation is invalid. + case invalidTime + + public var errorDescription: String? { + switch self { + case .invalidTimestep: + return "The provided timestep for OTP generation is not valid." + + case .invalidTime: + return "The time for counter computation is invalid." + } + } + } +} diff --git a/Sources/Uno/OneTimePassword/Algorithm.swift b/Sources/Uno/OneTimePassword/Algorithm.swift new file mode 100644 index 0000000..a2f2d9f --- /dev/null +++ b/Sources/Uno/OneTimePassword/Algorithm.swift @@ -0,0 +1,60 @@ +// +// Uno +// +// Created by Gaetano Matonti on 29/08/21. +// + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +import Foundation + +public extension OneTimePassword { + /// The hash function used to generate the HMAC. + enum Algorithm { + /// The SHA1 hash function. This is the most frequently used albeit insecure. + case sha1 + + /// The SHA256 hash function. + case sha256 + + /// The SHA512 hash function. + case sha512 + + // MARK: - Computed Properties + + /// The minimum size of the symmetric key in bytes. + var minimumKeySize: Int { + switch self { + case .sha1: + return 20 + + case .sha256: + return 32 + + case .sha512: + return 64 + } + } + } +} + +// MARK: - Errors + +public extension OneTimePassword.Algorithm { + /// The possible errors regarding the hash functions. + enum Error: Swift.Error, LocalizedError { + /// The minimum size of the symmetric key does not match the requirement. + case invalidMinimumKeySize + + public var errorDescription: String? { + switch self { + case .invalidMinimumKeySize: + return "The minimum size of the symmetric key does not match the requirement." + } + } + } +} diff --git a/Sources/Uno/OneTimePassword/Kind.swift b/Sources/Uno/OneTimePassword/Kind.swift new file mode 100644 index 0000000..ced489d --- /dev/null +++ b/Sources/Uno/OneTimePassword/Kind.swift @@ -0,0 +1,18 @@ +// +// Uno +// +// Created by Gaetano Matonti on 05/09/21. +// + +import Foundation + +public extension OneTimePassword { + /// The supported types of One Time Passwords. + enum Kind { + /// An OTP generated from a counter-based generator (HOTP). + case counterBased(counter: UInt64) + + /// An OTP generated from a time-based generator (TOTP). + case timeBased(timestep: TimeInterval) + } +} diff --git a/Sources/Uno/OneTimePassword/Length.swift b/Sources/Uno/OneTimePassword/Length.swift new file mode 100644 index 0000000..49e66c3 --- /dev/null +++ b/Sources/Uno/OneTimePassword/Length.swift @@ -0,0 +1,86 @@ +// +// Uno +// +// Created by Gaetano Matonti on 06/09/21. +// + +import Foundation + +public extension OneTimePassword { + /// The possible OTP codes length expressed in digits. + enum Length { + /// A six digits code. + case six + + /// A seven digits code. + case seven + + /// A eight digits code. + case eight + + // MARK: - Computed Properties + + /// The value representing the number of digits of the OTP code. + var rawValue: Int { + switch self { + case .six: + return 6 + + case .seven: + return 7 + + case .eight: + return 8 + } + } + } +} + +// MARK: - Helpers + +extension OneTimePassword.Length { + /// Gets the `Length` of an OTP from its integer value. + /// - Parameter value: The `Int` value of the OTP's length in digits. + /// - Returns: The `Length` representing the number of digits forming the OTP code. + static func from(_ rawValue: Int) throws -> OneTimePassword.Length { + switch rawValue { + case 6: + return .six + + case 7: + return .seven + + case 8: + return .eight + + case 9...12: + throw Error.codeLengthNotSupported + + default: + throw Error.invalidCodeLength + } + } +} + +// MARK: - Errors + +public extension OneTimePassword.Length { + /// The possible errors of `Length`. + enum Error: Swift.Error, LocalizedError { + /// The code length is not supported. + case codeLengthNotSupported + + /// The code length is not valid and does not meet the specifications' requirements. + case invalidCodeLength + + public var errorDescription: String? { + switch self { + case .codeLengthNotSupported: + return "The code length is not supported." + + case .invalidCodeLength: + return "The code length is not valid and does not meet the specifications' requirements." + } + } + } +} diff --git a/Sources/Uno/OneTimePassword/Metadata.swift b/Sources/Uno/OneTimePassword/Metadata.swift new file mode 100644 index 0000000..ce2e2ef --- /dev/null +++ b/Sources/Uno/OneTimePassword/Metadata.swift @@ -0,0 +1,37 @@ +// +// Uno +// +// Created by Gaetano Matonti on 05/09/21. +// + +public extension OneTimePassword { + /// An object containing the information necessary to generate an OTP to authenticate to a service. + struct Metadata { + /// The secret to seed into the generator. + let secret: Secret + + /// The amount of digits composing the authentication code. + let codeLength: Int + + /// The hash function used to generate the authentication code's hash. + let algorithm: Algorithm + + /// The kind of One Time Password. + let kind: Kind + + // MARK: - Init + + /// Creates an instance of the `Metadata` object + /// - Parameters: + /// - secret: The secret to seed into the generator. + /// - codeLength: The amount of digits composing the authentication code. + /// - algorithm: The hash function used to generate the authentication code's hash. + /// - kind: The kind of One Time Password. + public init(secret: Secret, codeLength: Int, algorithm: Algorithm, kind: Kind) { + self.secret = secret + self.codeLength = codeLength + self.algorithm = algorithm + self.kind = kind + } + } +} diff --git a/Sources/Uno/OneTimePassword/OneTimePassword.swift b/Sources/Uno/OneTimePassword/OneTimePassword.swift new file mode 100644 index 0000000..5938a22 --- /dev/null +++ b/Sources/Uno/OneTimePassword/OneTimePassword.swift @@ -0,0 +1,8 @@ +// +// Uno +// +// Created by Gaetano Matonti on 05/09/21. +// + +/// A namespace for all types related to a One Time Password. +public enum OneTimePassword {} diff --git a/Sources/Uno/OneTimePassword/Secret.swift b/Sources/Uno/OneTimePassword/Secret.swift new file mode 100644 index 0000000..fbd4cc1 --- /dev/null +++ b/Sources/Uno/OneTimePassword/Secret.swift @@ -0,0 +1,94 @@ +// +// Uno +// +// Created by Gaetano Matonti on 28/08/21. +// + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +import FiveBits +import Foundation + +public extension OneTimePassword { + /// An object containing information for a one-time password hash secret. + struct Secret { + + // MARK: - Stored Properties + + /// The bytes of the secret. + let data: Data + + // MARK: - Computed Properties + + /// The symmetric cryptographic key enclosing the secret. + var symmetricKey: SymmetricKey { + SymmetricKey(data: data) + } + + // MARK: - Init + + /// Creates an instance of `Secret` from an ASCII encoded `String`. + /// - Parameter ascii: The ASCII encoded `String`. + public init(ascii string: String) throws { + guard let data = string.data(using: .ascii) else { + throw Error.asciiConversionToDataFailed + } + + self.data = data + } + + /// Creates an instance of `Secret` from a Base32 encoded `String`. + /// - Parameter base32String: The Base32 encoded `String`. + public init(base32Encoded base32String: String) throws { + guard let data = Data(base32Encoded: base32String) else { + throw Error.base32DecodingFailed + } + + self.data = data + } + + /// Creates an instance of `Secret` from an ASCII encoded String. + /// - Parameter string: The hexadecimal `String` representation of the secret. + public init(hex string: String) throws { + self.data = try Data(hex: string) + } + } +} + +// MARK: - Helpers + +extension OneTimePassword.Secret { + /// Checks whether the secret is valid for use with the specified algorithm. + /// - Parameter algorithm: The algorithm to check on. + /// - Returns: A `Bool` indicating whether the secret is valid. + func isValid(for algorithm: OneTimePassword.Algorithm) -> Bool { + data.count >= algorithm.minimumKeySize + } +} + +// MARK: - Errors + +public extension OneTimePassword.Secret { + /// The possible errors regarding a `Secret`. + enum Error: Swift.Error, LocalizedError { + /// The conversion from ASCII to data bytes failed. + case asciiConversionToDataFailed + + /// The decoding of the Base32 encoded string failed. + case base32DecodingFailed + + public var errorDescription: String? { + switch self { + case .asciiConversionToDataFailed: + return "The conversion from ASCII to data bytes failed." + + case .base32DecodingFailed: + return "The decoding of the Base32 string failed." + } + } + } +} diff --git a/Sources/Uno/Secret.swift b/Sources/Uno/Secret.swift deleted file mode 100644 index 185c5a7..0000000 --- a/Sources/Uno/Secret.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// Uno -// -// Created by Gaetano Matonti on 28/08/21. -// - -#if canImport(CryptoKit) -import CryptoKit -#else -import Crypto -#endif - -import FiveBits -import Foundation - -/// An object containing information for a one-time password hash secret. -public struct Secret { - - // MARK: - Stored Properties - - /// The bytes of the secret. - let data: Data - - // MARK: - Computed Properties - - /// The symmetric cryptographic key enclosing the secret. - var symmetricKey: SymmetricKey { - SymmetricKey(data: data) - } - - // MARK: - Init - - /// Creates an instance of `Secret` from an ASCII encoded `String`. - /// - Parameter ascii: The ASCII encoded `String`. - public init(ascii string: String) throws { - guard let data = string.data(using: .ascii) else { - throw Error.asciiConversionToDataFailed - } - - self.data = data - } - - /// Creates an instance of `Secret` from a Base32 encoded `String`. - /// - Parameter base32String: The Base32 encoded `String`. - public init(base32Encoded base32String: String) throws { - guard let data = Data(base32Encoded: base32String) else { - throw Error.base32DecodingFailed - } - - self.data = data - } - - /// Creates an instance of `Secret` from an ASCII encoded String. - /// - Parameter string: The hexadecimal `String` representation of the secret. - public init(hex string: String) throws { - self.data = try Data(hex: string) - } -} - -// MARK: - Helpers - -public extension Secret { - /// Checks whether the secret is valid for use with the specified algorithm. - /// - Parameter algorithm: The algorithm to check on. - /// - Returns: A `Bool` indicating whether the secret is valid. - func isValid(for algorithm: Algorithm) -> Bool { - data.count >= algorithm.minimumKeySize - } -} - -// MARK: - Errors - -public extension Secret { - /// The possible errors regarding a `Secret`. - enum Error: Swift.Error, LocalizedError { - /// The conversion from ASCII to data bytes failed. - case asciiConversionToDataFailed - - case base32DecodingFailed - - public var errorDescription: String? { - switch self { - case .asciiConversionToDataFailed: - return "The conversion from ASCII to data bytes failed." - - case .base32DecodingFailed: - return "The decoding of the Base32 string failed." - } - } - } -} diff --git a/Tests/UnoTests/CounterBasedGeneratorTests.swift b/Tests/UnoTests/CounterBasedGeneratorTests.swift index ed1e0b4..bad0ad0 100644 --- a/Tests/UnoTests/CounterBasedGeneratorTests.swift +++ b/Tests/UnoTests/CounterBasedGeneratorTests.swift @@ -15,7 +15,7 @@ final class CounterBasedGeneratorTests: XCTestCase { // MARK: - Stored Properties /// The secret to use for tests. - private var secret: Secret! + private var secret: OneTimePassword.Secret! /// The `CounterBasedGenerator` under test. private var sut: CounterBasedGenerator! @@ -23,17 +23,12 @@ final class CounterBasedGeneratorTests: XCTestCase { // MARK: - Test Case Functions override func setUpWithError() throws { - secret = try Secret(ascii: "12345678901234567890") + secret = try OneTimePassword.Secret(ascii: "12345678901234567890") sut = CounterBasedGenerator(secret: secret) } // MARK: - Tests - func testCodeGenerationShouldThrow() { - let hotp = CounterBasedGenerator(secret: secret, codeLength: 4) - XCTAssertThrowsError(try hotp.generate(from: 0)) - } - func testGeneratedHashesShouldBeCorrect() throws { let testHashes = [ "cc93cf18508d94934c64b65d8ba7667fb7cde4b0", diff --git a/Tests/UnoTests/TimeBasedGeneratorTests.swift b/Tests/UnoTests/TimeBasedGeneratorTests.swift index 102dee5..de59a53 100644 --- a/Tests/UnoTests/TimeBasedGeneratorTests.swift +++ b/Tests/UnoTests/TimeBasedGeneratorTests.swift @@ -15,13 +15,13 @@ final class TimerBasedGeneratorTests: XCTestCase { // MARK: - Stored Properties /// The secret to use for SHA1 tests. - private var secretSHA1: Secret! + private var secretSHA1: OneTimePassword.Secret! /// The secret to use for SHA256 tests. - private var secretSHA256: Secret! + private var secretSHA256: OneTimePassword.Secret! /// The secret to use for SHA512 tests. - private var secretSHA512: Secret! + private var secretSHA512: OneTimePassword.Secret! /// The seconds data set. private let testSeconds: [TimeInterval] = [ @@ -36,15 +36,15 @@ final class TimerBasedGeneratorTests: XCTestCase { // MARK: - Test Case Functions override func setUpWithError() throws { - secretSHA1 = try Secret(ascii: "12345678901234567890") - secretSHA256 = try Secret(ascii: "12345678901234567890123456789012") - secretSHA512 = try Secret(ascii: "1234567890123456789012345678901234567890123456789012345678901234") + secretSHA1 = try OneTimePassword.Secret(ascii: "12345678901234567890") + secretSHA256 = try OneTimePassword.Secret(ascii: "12345678901234567890123456789012") + secretSHA512 = try OneTimePassword.Secret(ascii: "1234567890123456789012345678901234567890123456789012345678901234") } // MARK: - Tests func testCounterConversionShouldBeCorrect() throws { - let sut = TimeBasedGenerator(secret: secretSHA1, codeLength: 8, timestep: 30) + let sut = TimeBasedGenerator(secret: secretSHA1, codeLength: .eight, timestep: 30) let expectedResults: [UInt64] = [ 0x0000000000000001, @@ -62,7 +62,7 @@ final class TimerBasedGeneratorTests: XCTestCase { } func testGeneratedOTPsSHA1ShouldBeCorrect() throws { - let sut = TimeBasedGenerator(secret: secretSHA1, codeLength: 8, timestep: 30) + let sut = TimeBasedGenerator(secret: secretSHA1, codeLength: .eight, timestep: 30) let expectedResults = [ "94287082", @@ -80,7 +80,7 @@ final class TimerBasedGeneratorTests: XCTestCase { } func testGeneratedOTPsSHA256ShouldBeCorrect() throws { - let sut = TimeBasedGenerator(secret: secretSHA256, codeLength: 8, algorithm: .sha256, timestep: 30) + let sut = TimeBasedGenerator(secret: secretSHA256, codeLength: .eight, algorithm: .sha256, timestep: 30) let expectedResults = [ "46119246", @@ -98,7 +98,7 @@ final class TimerBasedGeneratorTests: XCTestCase { } func testGeneratedOTPsSHA512ShouldBeCorrect() throws { - let sut = TimeBasedGenerator(secret: secretSHA512, codeLength: 8, algorithm: .sha512, timestep: 30) + let sut = TimeBasedGenerator(secret: secretSHA512, codeLength: .eight, algorithm: .sha512, timestep: 30) let expectedResults = [ "90693936", From c343fa31f2561c9327188774600ccc335c5cb19e Mon Sep 17 00:00:00 2001 From: Gaetano Matonti Date: Mon, 13 Sep 2021 23:15:36 +0200 Subject: [PATCH 10/10] Add `otpauth` URI Parsing (#22) * Add new namespace and Metadata type * Fix missing Secret init * Add URIParser model and logic * Add Length object * Refactor errors * Add URIParser and Kind tests * Update Sources/Uno/OneTimePassword/Length.swift Co-authored-by: Fabrizio Brancati * Rename value to rawValue * Add missing doc * Update URLQueryItem+Extensions.swift * Update URLQueryItem+Extensions.swift * Update Algorithm.swift Co-authored-by: Fabrizio Brancati --- .../Extensions/URLQueryItem+Extensions.swift | 25 +++ Sources/Uno/OneTimePassword/Algorithm.swift | 21 +- Sources/Uno/OneTimePassword/Kind.swift | 24 +++ Sources/Uno/OneTimePassword/Metadata.swift | 19 +- Sources/Uno/URIParser.swift | 182 ++++++++++++++++++ Tests/UnoTests/OneTimePasswordKindTests.swift | 30 +++ Tests/UnoTests/URIParserTests.swift | 83 ++++++++ 7 files changed, 377 insertions(+), 7 deletions(-) create mode 100644 Sources/Uno/Extensions/URLQueryItem+Extensions.swift create mode 100644 Sources/Uno/URIParser.swift create mode 100644 Tests/UnoTests/OneTimePasswordKindTests.swift create mode 100644 Tests/UnoTests/URIParserTests.swift diff --git a/Sources/Uno/Extensions/URLQueryItem+Extensions.swift b/Sources/Uno/Extensions/URLQueryItem+Extensions.swift new file mode 100644 index 0000000..2d41e6e --- /dev/null +++ b/Sources/Uno/Extensions/URLQueryItem+Extensions.swift @@ -0,0 +1,25 @@ +// +// Uno +// +// Created by Gaetano Matonti on 06/09/21. +// + +import Foundation + +extension Array where Element == URLQueryItem { + /// Accesses the value of the `URLQueryItem` for the specified `URIParser.ItemKey`. + /// - Parameter key: The `URIParser.ItemKey` representing the name of the `URLQueryItem`. + /// - Returns: An optional `String` representing the value of the query item. `nil` if a value couldn't be found for the specified key. + subscript(_ key: URIParser.ItemKey) -> String? { + value(for: key) + } + + /// Gets the value of a `URLQueryItem` from its key. + /// - Parameter key: The `ItemKey` representing the name of the query item. + /// - Returns: An optional `String` representing the value of the query item. `nil` if a value couldn't be found for the specified key. + func value(for key: URIParser.ItemKey) -> String? { + first { + $0.name == key.rawValue + }?.value + } +} diff --git a/Sources/Uno/OneTimePassword/Algorithm.swift b/Sources/Uno/OneTimePassword/Algorithm.swift index a2f2d9f..672ba1d 100644 --- a/Sources/Uno/OneTimePassword/Algorithm.swift +++ b/Sources/Uno/OneTimePassword/Algorithm.swift @@ -14,15 +14,15 @@ import Foundation public extension OneTimePassword { /// The hash function used to generate the HMAC. - enum Algorithm { + enum Algorithm: String { /// The SHA1 hash function. This is the most frequently used albeit insecure. - case sha1 + case sha1 = "SHA1" /// The SHA256 hash function. - case sha256 + case sha256 = "SHA256" /// The SHA512 hash function. - case sha512 + case sha512 = "SHA512" // MARK: - Computed Properties @@ -42,6 +42,19 @@ public extension OneTimePassword { } } +// MARK: - Helpers + +extension OneTimePassword.Algorithm { + /// Gets the `Algorithm` from its name. + /// + /// - Note: If no algorithm could be found for the specified `String`, the default SHA1 algorithm will be used. + /// - Parameter value: The `String` value of the algorithm's name. + /// - Returns: A `Algorithm` used to generate the OTP. + static func from(_ value: String) -> OneTimePassword.Algorithm { + OneTimePassword.Algorithm(rawValue: value) ?? .sha1 + } +} + // MARK: - Errors public extension OneTimePassword.Algorithm { diff --git a/Sources/Uno/OneTimePassword/Kind.swift b/Sources/Uno/OneTimePassword/Kind.swift index ced489d..ec57401 100644 --- a/Sources/Uno/OneTimePassword/Kind.swift +++ b/Sources/Uno/OneTimePassword/Kind.swift @@ -9,6 +9,15 @@ import Foundation public extension OneTimePassword { /// The supported types of One Time Passwords. enum Kind { + /// The key of the OTP type. + enum Key: String { + /// The key for the HOTP. + case hotp + + /// The key for the TOTP. + case totp + } + /// An OTP generated from a counter-based generator (HOTP). case counterBased(counter: UInt64) @@ -16,3 +25,18 @@ public extension OneTimePassword { case timeBased(timestep: TimeInterval) } } + +extension OneTimePassword.Kind: Equatable { + public static func ==(lhs: OneTimePassword.Kind, rhs: OneTimePassword.Kind) -> Bool { + switch (lhs, rhs) { + case let (.counterBased(lhsCounter), .counterBased(rhsCounter)): + return lhsCounter == rhsCounter + + case let (.timeBased(lhsTimestep), .timeBased(rhsTimestep)): + return lhsTimestep == rhsTimestep + + case (_, _): + return false + } + } +} diff --git a/Sources/Uno/OneTimePassword/Metadata.swift b/Sources/Uno/OneTimePassword/Metadata.swift index ce2e2ef..8537f4d 100644 --- a/Sources/Uno/OneTimePassword/Metadata.swift +++ b/Sources/Uno/OneTimePassword/Metadata.swift @@ -4,6 +4,8 @@ // Created by Gaetano Matonti on 05/09/21. // +import Foundation + public extension OneTimePassword { /// An object containing the information necessary to generate an OTP to authenticate to a service. struct Metadata { @@ -11,7 +13,7 @@ public extension OneTimePassword { let secret: Secret /// The amount of digits composing the authentication code. - let codeLength: Int + let codeLength: Length /// The hash function used to generate the authentication code's hash. let algorithm: Algorithm @@ -21,17 +23,28 @@ public extension OneTimePassword { // MARK: - Init - /// Creates an instance of the `Metadata` object + /// Creates an instance of the `Metadata` object. /// - Parameters: /// - secret: The secret to seed into the generator. /// - codeLength: The amount of digits composing the authentication code. /// - algorithm: The hash function used to generate the authentication code's hash. /// - kind: The kind of One Time Password. - public init(secret: Secret, codeLength: Int, algorithm: Algorithm, kind: Kind) { + public init(secret: Secret, codeLength: Length, algorithm: Algorithm, kind: Kind) { self.secret = secret self.codeLength = codeLength self.algorithm = algorithm self.kind = kind } + + /// Creates an instance of the `Metadata` object from a `otpauth` URI. + /// - Parameter uri: The `String` of the `otpauth` URI. + public init(uri: String) throws { + let parser = try URIParser(uri: uri) + + secret = parser.secret + codeLength = parser.codeLength + algorithm = parser.algorithm + kind = parser.kind + } } } diff --git a/Sources/Uno/URIParser.swift b/Sources/Uno/URIParser.swift new file mode 100644 index 0000000..1b57cfc --- /dev/null +++ b/Sources/Uno/URIParser.swift @@ -0,0 +1,182 @@ +// +// Uno +// +// Created by Gaetano Matonti on 06/09/21. +// + +import Foundation + +/// An object that parses and validates `otpauth` URIs. +struct URIParser { + /// The keys representing the names of `URLQueryItem`s in the URI. + enum ItemKey: String { + /// The algorithm item key. + case algorithm + + /// The counter item key. + case counter + + /// The digits item key. + case digits + + /// The period item key. + case period + + /// The secret item key. + case secret + } + + // MARK: - Constants + + /// The scheme of the `otpauth` URI. + private static let scheme = "otpauth" + + // MARK: - Stored Properties + + /// The algorithm used by the service to generate OTPs. + let algorithm: OneTimePassword.Algorithm + + /// The number of digits of the OTP. + let codeLength: OneTimePassword.Length + + /// The kind of OTP generated by the service. + let kind: OneTimePassword.Kind + + /// The secret used to authenticate the user with the service. + let secret: OneTimePassword.Secret + + // MARK: - Init + + /// Creates an instance of `URIParser` from a URI `String`. + /// - Parameter uri: The `String` of the `otpauth` URI. + init(uri: String) throws { + guard let components = URLComponents(string: uri) else { + throw Error.invalidURI + } + + guard let scheme = components.scheme else { + throw Error.missingScheme + } + + guard URIParser.isValidScheme(scheme) else { + throw Error.invalidScheme + } + + guard let otpType = components.host else { + throw Error.missingOTPType + } + + guard let queryItems = components.queryItems else { + throw Error.missingQueryItems + } + + self.kind = try URIParser.kind(from: otpType, items: queryItems) + + guard let encodedSecret = queryItems[.secret] else { + throw Error.missingSecret + } + + self.secret = try OneTimePassword.Secret(base32Encoded: encodedSecret) + + // The following values are optional and have fallback values. + self.algorithm = URIParser.algorithm(for: queryItems[.algorithm]) + self.codeLength = try URIParser.codeLength(for: queryItems[.digits]) + } +} + +// MARK: - Helpers + +extension URIParser { + /// Checks whether the scheme of the URI is valid. + /// - Parameter value: The value of the URI scheme. + /// - Returns: A `Bool` indicating whether the URI has a valid scheme. + static func isValidScheme(_ value: String) -> Bool { + value == scheme + } + + /// Gets the kind of the OTP from the URI. + /// + /// - Note: This value is required to generate an OTP. + /// A counter value is also required for counter based generators, same goes for a timestep (or period) value for time based generators. + /// - Parameters: + /// - otpType: The `String` value of the otp type in the URI. + /// - items: The query items of the URI. + /// - Returns: A `Kind` describing the type of OTP. + static func kind(from otpType: String, items: [URLQueryItem]) throws -> OneTimePassword.Kind { + guard let kindKey = OneTimePassword.Kind.Key(rawValue: otpType) else { + throw Error.invalidOTPType + } + + switch kindKey { + case .hotp: + guard let counterValue = items[.counter], let counter = UInt64(counterValue) else { + throw Error.missingCounter + } + + return .counterBased(counter: counter) + + case .totp: + guard let periodValue = items[.period], let period = TimeInterval(periodValue) else { + throw Error.missingPeriod + } + + return .timeBased(timestep: period) + } + } + + /// Gets the algorithm to use to generate the OTP. + /// - Parameter value: The `String` value representing the name of the algorithm. + /// - Returns: A `Algorithm` object describint the algorithm to use for OTP generation. + static func algorithm(for value: String?) -> OneTimePassword.Algorithm { + guard let value = value else { + return .sha1 + } + + return OneTimePassword.Algorithm.from(value) + } + + /// Gets the length in digits of the OTP that should be generated. + /// - Parameter value: The `String` value representing the number of digits. + /// - Returns: A `Int` representing the number of digits forming the OTP code. + static func codeLength(for value: String?) throws -> OneTimePassword.Length { + guard let value = value, let digitsCount = Int(value) else { + return .six + } + + return try OneTimePassword.Length.from(digitsCount) + } +} + +// MARK: - Errors + +extension URIParser { + /// The possible errors of `URIParser`. + enum Error: Swift.Error { + /// The URI is invalid. + case invalidURI + + /// The URI is missing a scheme. + case missingScheme + + /// The scheme of the URI is invalid. + case invalidScheme + + /// The URI is missing the type of OTP. + case missingOTPType + + /// The OTP type in the URI is invalid. + case invalidOTPType + + /// The URI is missing its query items. + case missingQueryItems + + /// The URI query items are missing the secret string. + case missingSecret + + /// The URI query items are missing the counter value. Counter is required for counter based generators, which generate HOTPs. + case missingCounter + + /// The URI query items are missing the period value. Period is required for time based generators, which generate TOTPs. + case missingPeriod + } +} diff --git a/Tests/UnoTests/OneTimePasswordKindTests.swift b/Tests/UnoTests/OneTimePasswordKindTests.swift new file mode 100644 index 0000000..650bb4a --- /dev/null +++ b/Tests/UnoTests/OneTimePasswordKindTests.swift @@ -0,0 +1,30 @@ +// +// Uno +// +// Created by Gaetano Matonti on 06/09/21. +// + +import XCTest +@testable import Uno + +final class OneTimePasswordKindTests: XCTestCase { + func testCounterShouldBeEqual() { + XCTAssertEqual(OneTimePassword.Kind.counterBased(counter: 0), OneTimePassword.Kind.counterBased(counter: 0)) + } + + func testCounterShouldNotBeEqual() { + XCTAssertNotEqual(OneTimePassword.Kind.counterBased(counter: 0), OneTimePassword.Kind.counterBased(counter: 10)) + } + + func testTimestepShouldBeEqual() { + XCTAssertEqual(OneTimePassword.Kind.timeBased(timestep: 30), OneTimePassword.Kind.timeBased(timestep: 30)) + } + + func testTimestepShouldNotBeEqual() { + XCTAssertNotEqual(OneTimePassword.Kind.timeBased(timestep: 30), OneTimePassword.Kind.timeBased(timestep: 60)) + } + + func testKindsShouldNotBeEqual() { + XCTAssertNotEqual(OneTimePassword.Kind.counterBased(counter: 0), OneTimePassword.Kind.timeBased(timestep: 60)) + } +} diff --git a/Tests/UnoTests/URIParserTests.swift b/Tests/UnoTests/URIParserTests.swift new file mode 100644 index 0000000..5035c9a --- /dev/null +++ b/Tests/UnoTests/URIParserTests.swift @@ -0,0 +1,83 @@ +// +// Uno +// +// Created by Gaetano Matonti on 06/09/21. +// + +import XCTest +@testable import Uno + +final class URIParserTests: XCTestCase { + func testMissingSchemeShouldThrow() { + let sut = "randomstring" + XCTAssertThrowsError(try URIParser(uri: sut)) { error in + XCTAssertEqual(error as! URIParser.Error, URIParser.Error.missingScheme) + } + } + + func testInvalidSchemeShouldThrow() { + let sut = "https:" + XCTAssertThrowsError(try URIParser(uri: sut)) { error in + XCTAssertEqual(error as! URIParser.Error, URIParser.Error.invalidScheme) + } + } + + func testMissingQueryItemsShouldThrow() { + let sut = "otpauth://totp/Uno:john.doe@email.com" + XCTAssertThrowsError(try URIParser(uri: sut)) { error in + XCTAssertEqual(error as! URIParser.Error, URIParser.Error.missingQueryItems) + } + } + + func testInvalidOTPTypeShouldThrow() { + let sut = "otpauth://otp/Uno:john.doe@email.com?issuer=Uno" + XCTAssertThrowsError(try URIParser(uri: sut)) { error in + XCTAssertEqual(error as! URIParser.Error, URIParser.Error.invalidOTPType) + } + } + + func testMissingPeriodShouldThrow() { + let sut = "otpauth://totp/Uno:john.doe@email.com?issuer=Uno" + XCTAssertThrowsError(try URIParser(uri: sut)) { error in + XCTAssertEqual(error as! URIParser.Error, URIParser.Error.missingPeriod) + } + } + + func testMissingCounterShouldThrow() { + let sut = "otpauth://hotp/Uno:john.doe@email.com?issuer=Uno" + XCTAssertThrowsError(try URIParser(uri: sut)) { error in + XCTAssertEqual(error as! URIParser.Error, URIParser.Error.missingCounter) + } + } + + func testHOTPTypeShouldBeCorrect() throws { + let sut = "otpauth://hotp/Uno:john.doe@email.com?issuer=Uno&secret=JBSWY3DPEHPK3PXP&counter=0" + let parser = try URIParser(uri: sut) + XCTAssertEqual(parser.kind, .counterBased(counter: 0)) + } + + func testTOTPTypeShouldBeCorrect() throws { + let sut = "otpauth://totp/Uno:john.doe@email.com?issuer=Uno&secret=JBSWY3DPEHPK3PXP&period=30" + let parser = try URIParser(uri: sut) + XCTAssertEqual(parser.kind, .timeBased(timestep: 30)) + } + + func testSecretShouldBeCorrect() throws { + let sut = "otpauth://totp/Uno:john.doe@email.com?issuer=Uno&secret=JBSWY3DPEHPK3PXP&period=30" + let parser = try URIParser(uri: sut) + let expectedResult = Data([0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x21, 0xDE, 0xAD, 0xBE, 0xEF]) + XCTAssertEqual(parser.secret.data, expectedResult) + } + + func testAlgorithmShouldBeCorrect() throws { + let sut = "otpauth://totp/Uno:john.doe@email.com?issuer=Uno&secret=JBSWY3DPEHPK3PXP&period=30&algorithm=SHA1" + let parser = try URIParser(uri: sut) + XCTAssertEqual(parser.algorithm, .sha1) + } + + func testCodeLengthShouldBeCorrect() throws { + let sut = "otpauth://totp/Uno:john.doe@email.com?issuer=Uno&secret=JBSWY3DPEHPK3PXP&period=30&algorithm=SHA1&digits=8" + let parser = try URIParser(uri: sut) + XCTAssertEqual(parser.codeLength, .eight) + } +}