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 mentioned this pull request Dec 26, 2019
10 tasks
@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 December 26, 2019 02:01
/// Migration failed because the keychain query was not valid.
case invalidQuery
/// Migration failed because no items to migrate were found.
case noItemsToMigrateFound
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error handling got consolidated in SecItem.deleteItems

}


internal final class SecItem {

// MARK: Internal Enum

internal enum DataResult<SuccessType> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excited that we can get rid of these types.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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")
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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? {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to use a do/catch block because we're within an async () -> Void block that can't throw.

@dfed
Copy link
Collaborator Author

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 commented Dec 29, 2019

Thanks @dfed

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

dfed commented Jan 20, 2020

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

Tests/ValetIntegrationTests/ValetIntegrationTests.swift Outdated Show resolved Hide resolved
Tests/ValetIntegrationTests/ValetIntegrationTests.swift Outdated Show resolved Hide resolved
Tests/ValetIntegrationTests/ValetIntegrationTests.swift Outdated Show resolved Hide resolved
Tests/ValetIntegrationTests/ValetIntegrationTests.swift Outdated Show resolved Hide resolved
Tests/ValetIntegrationTests/ValetIntegrationTests.swift Outdated Show resolved Hide resolved
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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be exposing OSStatus in the interface here? Seems like Keychain abstracts away the underlying error codes everywhere else except here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would all of the other errors mean that this succeeded? Seems like errSecSuccess is the only one that means it actually got deleted.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh okay, I thought this was a change in behavior given that it previously returned .error(status). If this was existing, then LGTM.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh okay, I thought this was a change in behavior given that it previously returned .error(status). If this was existing, then LGTM.

Tests/ValetIntegrationTests/ValetIntegrationTests.swift Outdated Show resolved Hide resolved
Tests/ValetIntegrationTests/ValetIntegrationTests.swift Outdated Show resolved Hide resolved
Tests/ValetIntegrationTests/ValetIntegrationTests.swift Outdated Show resolved Hide resolved
Sources/Valet/KeychainError.swift Outdated Show resolved Hide resolved
@dfed dfed force-pushed the dfed--throws-api branch from 40e8082 to efeced6 Compare January 21, 2020 01:32
Copy link
Collaborator

@fdiaz fdiaz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: osStatus(forKey?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fd5feba

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in 249188f

throw KeychainError.missingEntitlement

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! Missed one! Nice catch 🙂.

Resolved in e7023c9

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

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Unnecessary empty space

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in d1b2d80

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

import Foundation


Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Empty extra space?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Tests/ValetTests/KeychainErrorTests.swift Outdated Show resolved Hide resolved
@dfed dfed merged commit 46924d8 into develop--4.0 Jan 21, 2020
@dfed dfed deleted the dfed--throws-api branch January 21, 2020 16:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants