[Valet 4.0] Convert APIs to throw rather than return booleans or enums #198
Conversation
| /// Migration failed because the keychain query was not valid. | ||
| case invalidQuery | ||
| /// Migration failed because no items to migrate were found. | ||
| case noItemsToMigrateFound |
dfed
Dec 26, 2019
Author
Collaborator
Instead we'll throw KeychainError.itemNotFound.
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 |
dfed
Dec 26, 2019
Author
Collaborator
Instead we'll throw KeychainError.couldNotAccessKeychain.
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 | |||
dfed
Dec 26, 2019
Author
Collaborator
This error was never used.
This error was never used.
| 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 { |
dfed
Dec 26, 2019
Author
Collaborator
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.
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: |
dfed
Dec 26, 2019
Author
Collaborator
This error handling got consolidated in SecItem.deleteItems
This error handling got consolidated in SecItem.deleteItems
| } | ||
|
|
||
|
|
||
| internal final class SecItem { | ||
|
|
||
| // MARK: Internal Enum | ||
| internal enum DataResult<SuccessType> { |
dfed
Dec 26, 2019
Author
Collaborator
Excited that we can get rid of these types.
Excited that we can get rid of these types.
dfed
Dec 29, 2019
Author
Collaborator
(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)
(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") |
dfed
Dec 26, 2019
Author
Collaborator
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.
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? { |
dfed
Dec 26, 2019
Author
Collaborator
these methods are no longer necessary, because the error parameter will have the information to determine if a user cancelled.
these methods are no longer necessary, because the error parameter will have the information to determine if a user cancelled.
| @@ -101,17 +101,17 @@ The Accessibility enum is used to determine when your secrets can be accessed. I | |||
|
|
|||
| ```swift | |||
| let username = "Skroob" | |||
| myValet.set(string: "12345", forKey: username) | |||
| try? myValet.setString("12345", forKey: username) | |||
dfed
Dec 26, 2019
Author
Collaborator
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.
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.
NickEntin
Jan 19, 2020
Collaborator
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.
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 { |
dfed
Dec 26, 2019
Author
Collaborator
we need to use a do/catch block because we're within an async () -> Void block that can't throw.
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 { |
NickEntin
Jan 20, 2020
Collaborator
Should we be exposing OSStatus in the interface here? Seems like Keychain abstracts away the underlying error codes everywhere else except here.
Should we be exposing OSStatus in the interface here? Seems like Keychain abstracts away the underlying error codes everywhere else except here.
dfed
Jan 20, 2020
Author
Collaborator
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.
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.
dfed
Jan 20, 2020
Author
Collaborator
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.
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.
NickEntin
Jan 20, 2020
Collaborator
Yeah, that's fair. Given it's all internal, it seems alright.
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 |
NickEntin
Jan 20, 2020
Collaborator
Should this throw a different error depending on what containsObject(...) returns?
Should this throw a different error depending on what containsObject(...) returns?
dfed
Jan 20, 2020
Author
Collaborator
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 🙂
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. |
NickEntin
Jan 20, 2020
Collaborator
Would all of the other errors mean that this succeeded? Seems like errSecSuccess is the only one that means it actually got deleted.
Would all of the other errors mean that this succeeded? Seems like errSecSuccess is the only one that means it actually got deleted.
dfed
Jan 20, 2020
Author
Collaborator
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.
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.
NickEntin
Jan 21, 2020
Collaborator
Ahh okay, I thought this was a change in behavior given that it previously returned .error(status). If this was existing, then LGTM.
Ahh okay, I thought this was a change in behavior given that it previously returned .error(status). If this was existing, then LGTM.
fdiaz
Jan 21, 2020
Collaborator
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?
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?
dfed
Jan 21, 2020
Author
Collaborator
I like making it explicit. I think that makes it more clear, and removes the need for a comment. Good thinking!
Addressed in 4e9eb8b
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. |
NickEntin
Jan 21, 2020
Collaborator
Ahh okay, I thought this was a change in behavior given that it previously returned .error(status). If this was existing, then LGTM.
Ahh okay, I thought this was a change in behavior given that it previously returned .error(status). If this was existing, then LGTM.
|
Mostly nits, this looks great! |
| @@ -48,216 +48,173 @@ internal final class Keychain { | |||
| var secItemQuery = attributes | |||
| secItemQuery[kSecAttrAccount as String] = canaryKey | |||
| secItemQuery[kSecValueData as String] = Data(canaryValue.utf8) | |||
| _ = SecItem.add(attributes: secItemQuery) | |||
| try? SecItem.add(attributes: secItemQuery) | |||
fdiaz
Jan 21, 2020
Collaborator
Unrelated to this PR but it feels like a method called canAccess wouldn't have side-effects.
Unrelated to this PR but it feels like a method called canAccess wouldn't have side-effects.
dfed
Jan 21, 2020
Author
Collaborator
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.
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) |
fdiaz
Jan 21, 2020
Collaborator
Much nicer 👍
Much nicer
| } | ||
|
|
||
| // 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 { |
fdiaz
Jan 21, 2020
Collaborator
nit: osStatus(forKey?
nit: osStatus(forKey?
fdiaz
Jan 21, 2020
Collaborator
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 == errSecSuccess
and making this method throw an KeychainError?
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?
fdiaz
Jan 21, 2020
Collaborator
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?
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?
dfed
Jan 21, 2020
Author
Collaborator
Fair that the name doesn't match the return type. Thoughts on:
Suggested change
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.
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.
dfed
Jan 21, 2020
Author
Collaborator
Or maybe
Suggested change
internal static func containsObject(forKey key: String, options: [String : AnyHashable]) -> OSStatus {
internal static func copyItemStatus(forKey key: String, options: [String : AnyHashable]) -> OSStatus {
Or maybe
| internal static func containsObject(forKey key: String, options: [String : AnyHashable]) -> OSStatus { | |
| internal static func copyItemStatus(forKey key: String, options: [String : AnyHashable]) -> OSStatus { |
NickEntin
Jan 21, 2020
Collaborator
My vote is for performCopy(matching:). The fact is returns a status is implied by the return type.
My vote is for performCopy(matching:). The fact is returns a status is implied by the return type.
fdiaz
Jan 21, 2020
Collaborator
Works for me! Thanks for addressing.
Works for me! Thanks for addressing.
| } | ||
| } | ||
|
|
||
| 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. |
fdiaz
Jan 21, 2020
Collaborator
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?
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`. |
fdiaz
Jan 21, 2020
Collaborator
Let's use Throws instead of Note for the KeychainError comment.
Let's use Throws instead of Note for the KeychainError comment.
| @@ -283,7 +257,7 @@ extension SecureEnclaveValet { | |||
| } | |||
| return valet(with: identifier, accessControl: accessControl) | |||
| } | |||
|
|
|||
fdiaz
Jan 21, 2020
Collaborator
nit: Unnecessary empty space
nit: Unnecessary empty space
| @@ -20,16 +20,11 @@ | |||
| import Foundation | |||
|
|
|||
|
|
|||
fdiaz
Jan 21, 2020
Collaborator
nit: Empty extra space?
nit: Empty extra space?
dfed
Jan 21, 2020
Author
Collaborator
Welcome to Square's style guide. Two empty newlines between imports and code 😉
Welcome to Square's style guide. Two empty newlines between imports and code

Formed in 2009, the Archive Team (not to be confused with the archive.org Archive-It Team) is a rogue archivist collective dedicated to saving copies of rapidly dying or deleted websites for the sake of history and digital heritage. The group is 100% composed of volunteers and interested parties, and has expanded into a large amount of related projects for saving online and digital history.

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.