[Valet 4.0] Convert APIs to throw rather than return booleans or enums#198
[Valet 4.0] Convert APIs to throw rather than return booleans or enums#198dfed merged 77 commits intodevelop--4.0from
Conversation
fb98f75 to
f44c6e0
Compare
| /// Migration failed because the keychain query was not valid. | ||
| case invalidQuery | ||
| /// Migration failed because no items to migrate were found. | ||
| case noItemsToMigrateFound |
There was a problem hiding this comment.
Instead we'll throw KeychainError.itemNotFound.
| /// Migration failed because no items to migrate were found. | ||
| case noItemsToMigrateFound | ||
| /// Migration failed because the keychain could not be read. | ||
| case couldNotReadKeychain |
There was a problem hiding this comment.
Instead we'll throw KeychainError.couldNotAccessKeychain.
| @@ -38,8 +31,6 @@ public enum MigrationResult: Int, Equatable { | |||
| case duplicateKeyInQueryResult | |||
| /// Migration failed because a key in the keychain duplicates a key already managed by Valet. | |||
| case keyInQueryResultAlreadyExistsInValet | |||
| /// Migration failed because writing to the keychain failed. | |||
| case couldNotWriteToKeychain | |||
There was a problem hiding this comment.
This error was never used.
README.md
Outdated
| NSString *const myLuggageCombination = [myValet stringForKey:username]; | ||
| ``` | ||
|
|
||
| In addition to allowing the storage of strings, Valet allows the storage of `Data` objects via `set(object: Data, forKey key: Key)` and `-objectForKey:`. Valets created with a different class type, via a different initializer, or with a different accessibility attribute will not be able to read or modify values in `myValet`. |
| import Foundation | ||
|
|
||
|
|
||
| public final class ErrorHandler { |
There was a problem hiding this comment.
We no longer needed a hot-swappable ErrorHandler. We now throw when an error occurs. We also have assertionFailure in a few internal methods where the error would very much be a programmer error within Valet.
|
|
||
| case let .error(status): | ||
| switch status { | ||
| case errSecInteractionNotAllowed, errSecMissingEntitlement: |
There was a problem hiding this comment.
This error handling got consolidated in SecItem.deleteItems
|
|
||
| // MARK: Internal Enum | ||
|
|
||
| internal enum DataResult<SuccessType> { |
There was a problem hiding this comment.
Excited that we can get rid of these types.
There was a problem hiding this comment.
(Note we could still get rid of these even if we didn’t move to a throws API, since Swift 5 has a built-in Result type)
| return .error(errSecParam) | ||
| internal static func copy<DesiredType>(matching query: [String : AnyHashable]) throws -> DesiredType { | ||
| if query.isEmpty { | ||
| assertionFailure("Must provide a query with at least one item") |
There was a problem hiding this comment.
We assert, and then continue here. We'll end up throwing a couldNotAccessKeychain later in this method. But if we hit this assertion, we have problems within Valet.
| /// - returns: The data currently stored in the keychain for the provided key. Returns `nil` if no object exists in the keychain for the specified key, or if the keychain is inaccessible. | ||
| @available(swift, obsoleted: 1.0) | ||
| @objc(objectForKey:userPrompt:userCancelled:) | ||
| public func 🚫swift_object(forKey key: String, withPrompt userPrompt: String, userCancelled: UnsafeMutablePointer<ObjCBool>?) -> Data? { |
There was a problem hiding this comment.
these methods are no longer necessary, because the error parameter will have the information to determine if a user cancelled.
| ```swift | ||
| let username = "Skroob" | ||
| myValet.set(string: "12345", forKey: username) | ||
| try? myValet.setString("12345", forKey: username) |
There was a problem hiding this comment.
Thoughts on whether we should show the error being ignored? Both here and in the Objective-C code below. Previously we were discarding the return type, so the current sample-code maintains the prior behavior.
There was a problem hiding this comment.
Seems reasonable to keep the example simple. The presence of try? implies that there is an error case that could (should) be handled in real usage.
| setQueue.async { XCTAssertTrue(self.valet.set(string: self.passcode, forKey: self.key)) } | ||
| removeQueue.async { XCTAssertTrue(self.valet.removeObject(forKey: self.key)) } | ||
| setQueue.async { | ||
| do { |
There was a problem hiding this comment.
we need to use a do/catch block because we're within an async () -> Void block that can't throw.
|
Thanks @dfed |
…s yet. Documentation missing.
|
I believe Nick's feedback has been fully addressed. This PR should be ready for review again. |
| // MARK: Contains | ||
|
|
||
| internal static func containsObject(forKey key: String, options: [String : AnyHashable]) -> SecItem.Result { | ||
| internal static func containsObject(forKey key: String, options: [String : AnyHashable]) -> OSStatus { |
There was a problem hiding this comment.
Should we be exposing OSStatus in the interface here? Seems like Keychain abstracts away the underlying error codes everywhere else except here.
There was a problem hiding this comment.
This is an internal method. The call-sites need to inspect the status code in order to make informed decisions. Specifically, the SecureEnclaveValet and SinglePromptSecureEnclaveValet need to test if an authentication failure error occured, whereas the regular Valet does not.
There was a problem hiding this comment.
Previously, we included the necessary information in SecItem.Result. I could theoretically create a new intermediary type (we've deleted SecItem.Result in this PR), but since we're not exposing anything publicly that seemed like overkill? I agree that this is a bit of a sharp edge in an otherwise very simple system.
There was a problem hiding this comment.
Yeah, that's fair. Given it's all internal, it seems alright.
| if Keychain.containsObject(forKey: key, options: destinationAttributes) == errSecItemNotFound { | ||
| keysToMigrate.insert(key) | ||
| } else { | ||
| throw MigrationError.keyInQueryResultAlreadyExistsInValet |
There was a problem hiding this comment.
Should this throw a different error depending on what containsObject(...) returns?
There was a problem hiding this comment.
I don't think so? Note that we're currently preserving existing behavior here (we previously returned a keyInQueryResultAlreadyExistsInValet, but now we're throwing). If we want to update this logic, let's do that in a separate PR 🙂
| throw KeychainError.missingEntitlement | ||
|
|
||
| default: | ||
| // We succeeded as long as we can confirm that the item is not in the keychain. |
There was a problem hiding this comment.
Would all of the other errors mean that this succeeded? Seems like errSecSuccess is the only one that means it actually got deleted.
There was a problem hiding this comment.
This preserves existing behavior that was previously in Keychain.swift. Let's consider improving this code in a separate pass to reduce churn in this PR.
There was a problem hiding this comment.
Ahh okay, I thought this was a change in behavior given that it previously returned .error(status). If this was existing, then LGTM.
There was a problem hiding this comment.
Let's add a comment here. This does indeed look weird when we know KeychainError has more errors that it could handle. Also, should we make this explicit by handling all cases instead of using default?
There was a problem hiding this comment.
I like making it explicit. I think that makes it more clear, and removes the need for a comment. Good thinking!
Addressed in 4e9eb8b
This reverts commit cadeb20.
| throw KeychainError.missingEntitlement | ||
|
|
||
| default: | ||
| // We succeeded as long as we can confirm that the item is not in the keychain. |
There was a problem hiding this comment.
Ahh okay, I thought this was a change in behavior given that it previously returned .error(status). If this was existing, then LGTM.
40e8082 to
efeced6
Compare
fdiaz
left a comment
There was a problem hiding this comment.
Mostly nits, this looks great!
| secItemQuery[kSecAttrAccount as String] = canaryKey | ||
| secItemQuery[kSecValueData as String] = Data(canaryValue.utf8) | ||
| _ = SecItem.add(attributes: secItemQuery) | ||
| try? SecItem.add(attributes: secItemQuery) |
There was a problem hiding this comment.
Unrelated to this PR but it feels like a method called canAccess wouldn't have side-effects.
There was a problem hiding this comment.
the side effect is invisible to the customer. I have a test to prove that the sentinel I'm writing doesn't appear in allKeys.
You're right that this is weird, but I think it's okay because the customer never has to know it happened.
| } | ||
|
|
||
| return setObject(data, forKey: key, options: options) | ||
| try setObject(data, forKey: key, options: options) |
| // MARK: Contains | ||
|
|
||
| internal static func containsObject(forKey key: String, options: [String : AnyHashable]) -> SecItem.Result { | ||
| internal static func containsObject(forKey key: String, options: [String : AnyHashable]) -> OSStatus { |
There was a problem hiding this comment.
Or alternative, delete the OSStatus return type, making this throw and replace the return type for a Bool by changing:
return SecItem.containsObject(matching: secItemQuery)to
let status = SecItem.containsObject(matching: secItemQuery)
guard status == errSecSuccess {
throw KeychainError(status: status)
}
return status == errSecSuccessand making this method throw an KeychainError?
There was a problem hiding this comment.
Nevermind, I saw below your reply to @NickEntin and also I saw how you're using this and I think it makes sense to keep this not throwing. Maybe we do want to change the method name though since containsObject sounds like something that will return a Bool?
There was a problem hiding this comment.
Fair that the name doesn't match the return type. Thoughts on:
| internal static func containsObject(forKey key: String, options: [String : AnyHashable]) -> OSStatus { | |
| internal static func performCopy(matchingKey key: String, options: [String : AnyHashable]) -> OSStatus { |
I'm honestly struggling with this one. What this method does under the hood is perform a SecItemCopyMatching, but doesn't retrieve the data. It just needs the OSStatus return type from the copy action.
There was a problem hiding this comment.
Or maybe
| internal static func containsObject(forKey key: String, options: [String : AnyHashable]) -> OSStatus { | |
| internal static func copyItemStatus(forKey key: String, options: [String : AnyHashable]) -> OSStatus { |
There was a problem hiding this comment.
My vote is for performCopy(matching:). The fact is returns a status is implied by the return type.
There was a problem hiding this comment.
Works for me! Thanks for addressing.
Sources/Valet/Internal/SecItem.swift
Outdated
|
|
||
| internal static func containsObject(matching query: [String : AnyHashable]) -> Result { | ||
| internal static func containsObject(matching query: [String : AnyHashable]) -> OSStatus { | ||
| guard query.count > 0 else { |
| throw KeychainError.missingEntitlement | ||
|
|
||
| default: | ||
| // We succeeded as long as we can confirm that the item is not in the keychain. |
There was a problem hiding this comment.
Let's add a comment here. This does indeed look weird when we know KeychainError has more errors that it could handle. Also, should we make this explicit by handling all cases instead of using default?
| } | ||
| return SecureEnclave.containsObject(forKey: key, options: keychainQuery) | ||
| /// - Returns: `true` if a value has been set for the given key, `false` otherwise. | ||
| /// - Note: Will never prompt the user for Face ID, Touch ID, or password. Method will throw a `KeychainError`. |
There was a problem hiding this comment.
Let's use Throws instead of Note for the KeychainError comment.
There was a problem hiding this comment.
Ah! Missed one! Nice catch 🙂.
Resolved in e7023c9
| return valet(with: identifier, accessControl: accessControl) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
nit: Unnecessary empty space
|
|
||
| import Foundation | ||
|
|
||
|
|
There was a problem hiding this comment.
Welcome to Square's style guide. Two empty newlines between imports and code 😉
This PR tries to make Valet more semantic Swift by relying on
throwsrather thanBoolorenumreturn types. It's a bit of a massive change, but IMO this API feels a whole lot nicer to use.