The Wayback Machine - https://web.archive.org/web/20210107105558/https://github.com/square/Valet/pull/198
Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Valet 4.0] Convert APIs to throw rather than return booleans or enums #198

Merged
merged 77 commits into from Jan 21, 2020

Conversation

@dfed
Copy link
Collaborator

@dfed dfed commented Dec 25, 2019

This PR tries to make Valet more semantic Swift by relying on throws rather than Bool or enum return types. It's a bit of a massive change, but IMO this API feels a whole lot nicer to use.

@dfed dfed force-pushed the dfed--throws-api branch from fb98f75 to f44c6e0 Dec 26, 2019
@dfed dfed mentioned this pull request Dec 26, 2019
9 of 10 tasks complete
@dfed dfed changed the title [WIP][Valet 4.0] Convert APIs to throw rather than return booleans or enums [Valet 4.0] Convert APIs to throw rather than return booleans or enums Dec 26, 2019
@dfed dfed marked this pull request as ready for review Dec 26, 2019
/// Migration failed because the keychain query was not valid.
case invalidQuery
/// Migration failed because no items to migrate were found.
case noItemsToMigrateFound

This comment has been minimized.

@dfed

dfed Dec 26, 2019
Author Collaborator

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

This comment has been minimized.

@dfed

dfed Dec 26, 2019
Author Collaborator

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

This comment has been minimized.

@dfed

dfed Dec 26, 2019
Author Collaborator

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`.

This comment has been minimized.

@dfed

dfed Dec 26, 2019
Author Collaborator

welp. This line needed some help. Key was part of #80, and was removed in e54f35b. Also we'd left in the Objective-C description of the method, rather than the Swift one.

import Foundation


public final class ErrorHandler {

This comment has been minimized.

@dfed

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.


case let .error(status):
switch status {
case errSecInteractionNotAllowed, errSecMissingEntitlement:

This comment has been minimized.

@dfed

dfed Dec 26, 2019
Author Collaborator

This error handling got consolidated in SecItem.deleteItems

}


internal final class SecItem {

// MARK: Internal Enum
internal enum DataResult<SuccessType> {

This comment has been minimized.

@dfed

dfed Dec 26, 2019
Author Collaborator

Excited that we can get rid of these types.

This comment has been minimized.

@dfed

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)

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

This comment has been minimized.

@dfed

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.

/// - 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? {

This comment has been minimized.

@dfed

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.

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

This comment has been minimized.

@dfed

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.

This comment has been minimized.

@NickEntin

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.

setQueue.async { XCTAssertTrue(self.valet.set(string: self.passcode, forKey: self.key)) }
removeQueue.async { XCTAssertTrue(self.valet.removeObject(forKey: self.key)) }
setQueue.async {
do {

This comment has been minimized.

@dfed

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.

@dfed dfed requested review from EricMuller22, NickEntin and gfontenot Dec 26, 2019
@dfed
Copy link
Collaborator Author

@dfed dfed commented Dec 26, 2019

cc @bachand @fdiaz @jqsilver in case y'all are interested. Trying to modernize the API a bit before Airbnb adopts.

@bachand
Copy link

@bachand bachand commented Dec 29, 2019

Thanks @dfed

@dfed dfed changed the base branch from dfed--remove-always-accessibility-specifier to develop--4.0 Dec 30, 2019
@dfed
Copy link
Collaborator Author

@dfed dfed commented Jan 20, 2020

I believe Nick's feedback has been fully addressed. This PR should be ready for review again.

Sources/Valet/Internal/Keychain.swift Outdated Show resolved Hide resolved
}

// 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 {

This comment has been minimized.

@NickEntin

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.

This comment has been minimized.

@dfed

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 comment has been minimized.

@dfed

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.

This comment has been minimized.

@NickEntin

NickEntin Jan 20, 2020
Collaborator

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

This comment has been minimized.

@NickEntin

NickEntin Jan 20, 2020
Collaborator

Should this throw a different error depending on what containsObject(...) returns?

This comment has been minimized.

@dfed

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 🙂

Sources/Valet/Internal/SecItem.swift Outdated Show resolved Hide resolved
throw KeychainError.missingEntitlement

default:
// We succeeded as long as we can confirm that the item is not in the keychain.

This comment has been minimized.

@NickEntin

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.

This comment has been minimized.

@dfed

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 comment has been minimized.

@NickEntin

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.

This comment has been minimized.

@fdiaz

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?

This comment has been minimized.

@dfed

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

throw KeychainError.missingEntitlement

default:
// We succeeded as long as we can confirm that the item is not in the keychain.

This comment has been minimized.

@NickEntin

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.

Sources/Valet/KeychainError.swift Outdated Show resolved Hide resolved
@dfed dfed force-pushed the dfed--throws-api branch from 40e8082 to efeced6 Jan 21, 2020
@fdiaz
fdiaz approved these changes Jan 21, 2020
Copy link
Collaborator

@fdiaz fdiaz left a comment

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)

This comment has been minimized.

@fdiaz

fdiaz Jan 21, 2020
Collaborator

Unrelated to this PR but it feels like a method called canAccess wouldn't have side-effects.

This comment has been minimized.

@dfed

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.

}

return setObject(data, forKey: key, options: options)
try setObject(data, forKey: key, options: options)

This comment has been minimized.

@fdiaz

fdiaz Jan 21, 2020
Collaborator

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 {

This comment has been minimized.

@fdiaz

fdiaz Jan 21, 2020
Collaborator

nit: osStatus(forKey?

This comment has been minimized.

@fdiaz

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?

This comment has been minimized.

@fdiaz

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?

This comment has been minimized.

@dfed

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.

This comment has been minimized.

@dfed

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 {

This comment has been minimized.

@NickEntin

NickEntin Jan 21, 2020
Collaborator

My vote is for performCopy(matching:). The fact is returns a status is implied by the return type.

This comment has been minimized.

@dfed

dfed Jan 21, 2020
Author Collaborator

Addressed in fd5feba

This comment has been minimized.

@fdiaz

fdiaz Jan 21, 2020
Collaborator

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 {

This comment has been minimized.

This comment has been minimized.

@dfed

dfed Jan 21, 2020
Author Collaborator

addressed in 249188f

throw KeychainError.missingEntitlement

default:
// We succeeded as long as we can confirm that the item is not in the keychain.

This comment has been minimized.

@fdiaz

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?

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

This comment has been minimized.

@fdiaz

fdiaz Jan 21, 2020
Collaborator

Let's use Throws instead of Note for the KeychainError comment.

This comment has been minimized.

@dfed

dfed Jan 21, 2020
Author Collaborator

Ah! Missed one! Nice catch 🙂.

Resolved in e7023c9

@@ -283,7 +257,7 @@ extension SecureEnclaveValet {
}
return valet(with: identifier, accessControl: accessControl)
}

This comment has been minimized.

@fdiaz

fdiaz Jan 21, 2020
Collaborator

nit: Unnecessary empty space

This comment has been minimized.

@dfed

dfed Jan 21, 2020
Author Collaborator

Resolved in d1b2d80

Tests/ValetTests/MigrationErrorTests.swift Outdated Show resolved Hide resolved
@@ -20,16 +20,11 @@
import Foundation


This comment has been minimized.

@fdiaz

fdiaz Jan 21, 2020
Collaborator

nit: Empty extra space?

This comment has been minimized.

@dfed

dfed Jan 21, 2020
Author Collaborator

Welcome to Square's style guide. Two empty newlines between imports and code 😉

Tests/ValetTests/KeychainErrorTests.swift Outdated Show resolved Hide resolved
dfed added 7 commits Jan 21, 2020
@dfed dfed merged commit 46924d8 into develop--4.0 Jan 21, 2020
2 checks passed
2 checks passed
continuous-integration/travis-ci/pr The Travis CI build passed
Details
license/cla Contributor License Agreement is signed.
Details
@dfed dfed deleted the dfed--throws-api branch Jan 21, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
5 participants
You can’t perform that action at this time.