square / Valet

Valet lets you securely store data in the iOS, tvOS, watchOS, or macOS Keychain without knowing a thing about how the Keychain works. It’s easy. We promise.

Repository from Github https://github.comsquare/ValetRepository from Github https://github.comsquare/Valet

Enable customization of biometrics prompt with application-specific fallback

allisonmoyer opened this issue Β· comments

In order have more control when retrieving something secured by biometrics in the secure enclave, an option is to first create an LAContext and call evaluatePolicy with it, and then, on success, pass that authenticated context when looking up the item. However, this is not currently possible with the public interface to SecureEnclaveValet.

See more context here under "Restricting keychain item access to Touch ID and Face ID"

Proposition to fix is to add new public methods to SecureEnclaveValet to allow a context to be provided:

@objc
public func string(forKey key: String, withPrompt userPrompt: String, context: LAContext) throws -> String {
    try execute(in: lock) {
        var keychainQuery = baseKeychainQuery
        keychainQuery[kSecUseAuthenticationContext as String] = context
        return try SecureEnclave.string(forKey: key, withPrompt: userPrompt, options: keychainQuery)
    }
}

and

@objc
public func object(forKey key: String, withPrompt userPrompt: String, context: LAContext) throws -> Data {
    try execute(in: lock) {
        var keychainQuery = baseKeychainQuery
        keychainQuery[kSecUseAuthenticationContext as String] = context
        return try SecureEnclave.string(forKey: key, withPrompt: userPrompt, options: keychainQuery)
    }
}

Can you help me understand the explicit use case you're trying to support? I'd be curious to know how this use case differs from the use cases supported by SinglePromptSecureEnclaveValet.

Sorry about closing! Accidentally hit "Close with comment" instead of "Comment" πŸ€¦β€β™‚οΈ

@dfed Yep! I'm trying to store a password token in the SecureEnclaveValet, but on retrieval, I want the fallback option that evaluatePolicy allows for, to instruct the user to instead enter their password manually

Possibly a dumb question, but why can't that be accomplished with a SinglePromptSecureEnclaveValet (or even a SecureEnclaveValet) with an accessControl set to userPresence? Last I checked, userPresence has a passcode fallback.

Is the UI different if you use evaluatePolicy directly? Is that the issue?

Correct, it's mainly a UI issue. And it's not the phone passcode we want to allow them to fallback to (we explicitly don't want that as a fallback), it's their password with our app that we want to allow them to fallback to enter.

Ah. If the fallback for authenticating is an app-specific password, couldn't an LAContext be created and managed by the application without changing Valet's API? You could use an LAContext to check for user presence, and then only read from a Valet instance when the customer's biometrics or app password have been successfully entered.

I also wasn't aware that an LAContext could be unlocked with an app-specific password – looking at the policies available, they all involve authentication with biometrics, watch, or device passcode.

To clarify what's happening:

We have a part of the app that is password-protected. We want to allow customers to enter that part with biometrics instead of entering their password if possible because it's easier and more secure, but we want to allow them to fall back to entering the password if they don't have biometrics or fail biometrics.

So, the biometric entry is meant to be a password replacement, and how that will work is we'll store a password token in Valet, and if we can successfully retrieve it, we'll send that to our server to authenticate instead of asking for a password.

Which can be accomplished in the way you described: guard the lookup to Valet behind an LAContext.evaluatePolicy.

I was hoping to make the storage and lookup of the token more secure though by also storing it guarded by biometrics. So, in that case, we would use SecureEnclaveValet, but the biometric prompt when attempting to retrieve something via SecureEnclaveValet doesn't allow for a fallback, which is where this change comes in.

You can see the difference below - the first image is the prompt generated by attempting a lookup in SecureEnclaveValet, and the second is the prompt generated by LAContext.evaluatePolicy.

So I want to use LAContext.evaluatePolicy, but I want the outcome to be something retrieved from SecureEnclaveValet

IMG_0471 IMG_0472

Thanks for that explanation! I'm still a little unclear on how a LAContext can be satisfied with the app-specific password – can you give me a simple code example? That'd help me think about how we could craft API that doesn't surface keychain implementation details.

I was hoping to make the storage and lookup of the token more secure though by also storing it guarded by biometrics.

I'm not convinced that having Apple's security library (i.e. Keychain) evaluate whether a LAContext's policy has been fulfilled is any more secure than evaluating that within your application. But let's posit that having Keychain utilize a LAContext with an fallback to in-app logic is indeed more secure.

I think this use case can be stated as: provide API to protect keychain items with accessControl while allowing an in-app logic fallback. If that's right, we could potentially add the ability to fallback to in-app logic in our data retrieval methods. I'm not sure what that'd look like just yet though.

The LAContext wouldn't be satisfied with the app password. It's just a UI nice-ity that allows us to show a button other than Cancel that we can handle differently. In the above screenshot, when Enter Cash Password is tapped, we receive an error in the callback to evaluatePolicy with error code LAErrorUserFallback.

I definitely don't know all of the ins and outs of Keychain, but assumed that this would be an extra layer of security in that if the Keychain was ever compromised, or if the biometrics on the device were changed, storing the item in the Keychain guarded by biometryCurrentSet would offer additional protection.

And yeah, that's the correct use case, but it's more about the UI of allowing in-app logic fallback than actually implementing any such logic. It's the difference in UI in using the automatic LAContext evaluation from the Keychain lookup vs performing our own first. (And this post also called out that the automatic LAContext evaluation is synchronous and would block the thread calling SecItemCopyMatching block during authentication)

Once you get LAErrorUserFallback and then execute in-app logic to confirm the app-specific password, how would you use the API proposed to access the keychain value? We still need the injected LAContext's policy to be fulfilled before the keychain would return the requested value. To break this down in pseudocode:

do {
  let context = <...>
  let keychainValue = try secureEnclaveValet.string(forKey: "myKey", userPrompt: "Unlock to allow access", context: context)
  // we have the value because the customer authenticated with biometrics or device passcode
} catch KeychainError.userFallback { // new keychain error that means customer bailed out of biometrics. Thrown when LAContext throws a `LAErrorUserFallback` error.
  // user didn't allow biometrics and fell back to app passcode.
  // how do we get the value? The SecureEnclaveValet won't give us the keychain value unless the LAContext is fulfilled.
}

In short: I'm having trouble understanding how the proposed API would enable your use case.

Okay maybe I misunderstood. In the fallback case, do you need to retrieve data from the keychain still? If not, I think I get it.

Yeah exactly, no access to the keychain in the fallback case. We just send the password directly to the server. In the case that biometrics do succeed, we send the password token to the server instead.

Got it. Do you think you could take another pass at designing this API? I'd like to make the API explicit for the use-case, and not expose the underlying objects from the Security framework. Maybe something like:

@objc
public func object(forKey key: String, withPrompt userPrompt: String, fallbackText: String) throws -> Data

And then we'd need to add a new case to KeychainError to handle the user selecting the fallback option? I think that might be it but I could be missing something we'd need to make this work.

I think we can add API like this. It's a bit of a niche use-case, and writing the documentation for it is going to be tricky. But I'm happy to review a PR that has an API like the above. cc @NickEntin in case you've got ideas on how to improve the above.

@dfed I agree adding an explicit API for this use case is the right move.

I wonder if treating the fallback case as an error is quite right though. I think something like this might be a more accurate representation of the use case:

public enum UserAuthenticationResult<DataType> {
    case authenticated(DataType)
    case selectedFallback
}

public func object(forKey key: String, withPrompt userPrompt: String, fallbackText: String) throws -> UserAuthenticationResult<Data>

public func string(forKey key: String, withPrompt userPrompt: String, fallbackText: String) throws -> UserAuthenticationResult<String>

The main problem with that is it isn't compatible with Objective-C. Maybe there's a middle ground that will work, or separate APIs for Swift/ObjC? Don't feel strongly, but something to consider.

I'm not too worried about Objective-C support for an API that isn't core.

I'd suggested treating the fallback case as an error to avoid requiring both try and switch at the call site to get access to the stored value. I think reducing the tri-state (success or fallback or error) into a bi-state (success or error) will simplify control-flow at the call-site.

Thinking about it more, KeychainError may be the wrong error classification though. Maybe a new public struct FallbackSelected: Error {}? I think this control flow at the call site feels pretty good:

do {
  let secret = try valet.object(...)
  // do stuff with secret
} catch FallbackSelected {
  // handle fallback flow
} catch KeychainError.itemNotFound {
  // handle fallback flow
} catch KeychainError.userCancelled {
  // cancel flow
} catch {
  // alert customer that an error occurred. Cancel flow.
}

I've updated the title of this issue to better reflect where we're landing. Allison, please feel free to further edit if I'm not fully capturing the request.

Fair point, try plus switch isn't a great developer experience. I like the idea of using a separate FallbackSelected error type.

Yep, I think that captures it @dfed, thanks. I'll try out your suggestion locally and let you know if something like that could work for this πŸ‘

Looked at this again. The difference is that using evaluatePolicy is an async operation. So here's an option providing a completion:

    public func string(
        forKey key: String,
        withPrompt userPrompt: String,
        withFallbackTitle fallbackTitle: String,
        completion: @escaping (Result<String, Error>) -> Void
    ) {
        let context = LAContext()
        context.localizedFallbackTitle = fallbackTitle
        context.evaluatePolicy(
            accessControl.policy,
            localizedReason: userPrompt,
            reply: { [unowned self] success, error in
                DispatchQueue.main.async {
                    if success {
                        execute(in: lock) {
                            do {
                                var keychainQuery = baseKeychainQuery
                                keychainQuery[kSecUseAuthenticationContext as String] = context
                                let string = try SecureEnclave.string(
                                    forKey: key,
                                    withPrompt: userPrompt,
                                    options: keychainQuery
                                )
                                completion(.success(string))

                            } catch {
                                completion(.failure(error))
                            }
                        }

                    } else if let error = error {
                        completion(.failure(error))

                    } else {
                        // Unexpected to get here
                        completion(.failure(KeychainError.couldNotAccessKeychain))
                    }
                }
            }
        )
    }

where accessControl.policy is

extension SecureEnclaveAccessControl {

    var policy: LAPolicy {
        switch self {
        case .userPresence, .devicePasscode:
            return .deviceOwnerAuthentication
        case .biometricAny, .biometricCurrentSet:
            return .deviceOwnerAuthenticationWithBiometrics
        }
    }

}

Got it! That API make sense to me. I wonder though if we could make the API synchronous with the use of a semaphore or dispatch group – could we block the calling queue until the completion block is called after calling evaluatePolicy? I imagine the policy evaluation runs out of process, so blocking the current queue (which could be main) likely wouldn't be problematic.

If possible, I'd like to keep the API synchronous. But if that's not possible I think the above is reasonable.

As an aside... I have absolutely no idea how we could unit test this method πŸ˜…. Something to think about.

Ah I didn't think about trying to keep it synchronous like that. Yeah, it seems like if the system prompts for keychain lookup can be synchronous, should be safe for this to be synchronous as well. I'll play around with it, and will definitely include unit tests. Will tag you on a PR once I have something. Thanks for the input!

@dfed I have something that works for my use case, but having trouble pushing my local branch. Are there any special permissions I need?

@allisonmoyer just sent you an invite to get write permissions on this repo! Normally folk create their own fork and create PRs from there, but since you're on Cash might as well open up access πŸ™‚. Lemme know if you run into issues.

Ah, makes sense, thanks!

Closing this out since the issue is quite stale, we haven't seen duplicates for this request, and no one has recently tried to take this work over the line.