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