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

Split authenticationValidityDurationSeconds between android and iOS #77

Merged
merged 7 commits into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import mu.KotlinLogging
import java.io.File
import java.io.IOException
import javax.crypto.Cipher
import kotlin.time.Duration

private val logger = KotlinLogging.logger {}

data class InitOptions(
val authenticationValidityDurationSeconds: Int = -1,
val androidAuthenticationValidityDuration: Duration? = null,
val authenticationRequired: Boolean = true,
val androidBiometricOnly: Boolean = true
)
Expand Down Expand Up @@ -44,20 +45,22 @@ class BiometricStorageFile(
setIsStrongBoxBacked(useStrongBox)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (options.authenticationValidityDurationSeconds == -1) {
if (options.androidAuthenticationValidityDuration == null) {
setUserAuthenticationParameters(
0,
KeyProperties.AUTH_BIOMETRIC_STRONG
)
} else {
setUserAuthenticationParameters(
options.authenticationValidityDurationSeconds,
options.androidAuthenticationValidityDuration.inWholeSeconds.toInt(),
KeyProperties.AUTH_DEVICE_CREDENTIAL or KeyProperties.AUTH_BIOMETRIC_STRONG
)
}
} else {
@Suppress("DEPRECATION")
setUserAuthenticationValidityDurationSeconds(options.authenticationValidityDurationSeconds)
setUserAuthenticationValidityDurationSeconds(
options.androidAuthenticationValidityDuration?.inWholeSeconds?.toInt() ?: -1
)
}
}

Expand All @@ -74,8 +77,8 @@ class BiometricStorageFile(
}

private fun validateOptions() {
if (options.authenticationValidityDurationSeconds == -1 && !options.androidBiometricOnly) {
throw IllegalArgumentException("when authenticationValidityDurationSeconds is -1, androidBiometricOnly must be true")
if (options.androidAuthenticationValidityDuration == null && !options.androidBiometricOnly) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we also validate that the user doesn't set androidAuthenticationValidityDuration to a negative number (especially since the similar old API gave special meaning to -1)?

throw IllegalArgumentException("when androidAuthenticationValidityDuration is null, androidBiometricOnly must be true")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import java.io.StringWriter
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import javax.crypto.Cipher
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

private val logger = KotlinLogging.logger {}

Expand Down Expand Up @@ -176,7 +178,7 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
CipherMode.Decrypt -> cipherForDecrypt()
}

val cipher = if (options.authenticationValidityDurationSeconds > -1) {
val cipher = if (options.androidAuthenticationValidityDuration != null) {
null
} else try {
cipherForMode()
Expand Down Expand Up @@ -222,7 +224,7 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler {

val options = call.argument<Map<String, Any>>("options")?.let { it ->
InitOptions(
authenticationValidityDurationSeconds = it["authenticationValidityDurationSeconds"] as Int,
androidAuthenticationValidityDuration = (it["androidAuthenticationValidityDurationSeconds"] as Int?)?.seconds,
authenticationRequired = it["authenticationRequired"] as Boolean,
androidBiometricOnly = it["androidBiometricOnly"] as Boolean,
)
Expand Down Expand Up @@ -398,9 +400,9 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
promptBuilder.setAllowedAuthenticators(DEVICE_CREDENTIAL or BIOMETRIC_STRONG)
}

if (cipher == null || options.authenticationValidityDurationSeconds >= 0) {
// if authenticationValidityDurationSeconds is not -1 we can't use a CryptoObject
logger.debug { "Authenticating without cipher. ${options.authenticationValidityDurationSeconds}" }
if (cipher == null || options.androidAuthenticationValidityDuration != null) {
// if androidAuthenticationValidityDuration is not null we can't use a CryptoObject
logger.debug { "Authenticating without cipher. ${options.androidAuthenticationValidityDuration}" }
prompt.authenticate(promptBuilder.build())
} else {
prompt.authenticate(promptBuilder.build(), BiometricPrompt.CryptoObject(cipher))
Expand Down
46 changes: 42 additions & 4 deletions lib/src/biometric_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,45 @@ class AuthException implements Exception {

class StorageFileInitOptions {
StorageFileInitOptions({
Duration? androidAuthenticationValidityDuration,
Duration? iosTouchIDAuthenticationAllowableReuseDuration,
this.iosTouchIDAuthenticationForceReuseContextDuration,
this.authenticationValidityDurationSeconds = -1,
this.authenticationRequired = true,
this.androidBiometricOnly = true,
});

}) : androidAuthenticationValidityDuration =
androidAuthenticationValidityDuration ??
(authenticationValidityDurationSeconds <= 0
? null
: Duration(seconds: authenticationValidityDurationSeconds)),
iosTouchIDAuthenticationAllowableReuseDuration =
iosTouchIDAuthenticationAllowableReuseDuration ??
(authenticationValidityDurationSeconds <= 0
? null
: Duration(seconds: authenticationValidityDurationSeconds));

@Deprecated('use androidAuthenticationValidityDuration instead')
Copy link
Contributor

Choose a reason for hiding this comment

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

At various times this has affected iOS behaviour too so maybe:
'use androidAuthenticationValidityDuration, iosTouchIDAuthenticationAllowableReuseDuration or iosTouchIDAuthenticationForceReuseContextDuration instead'

final int authenticationValidityDurationSeconds;

/// see https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder#setUserAuthenticationParameters(int,%20int)
final Duration? androidAuthenticationValidityDuration;

/// see https://developer.apple.com/documentation/localauthentication/lacontext/1622329-touchidauthenticationallowablere
/// > If the user unlocks the device using Touch ID within the specified time interval, then authentication for the receiver succeeds automatically, without prompting the user for Touch ID. This bypasses a scenario where the user unlocks the device and then is almost immediately prompted for another fingerprint.
/// and https://developer.apple.com/documentation/localauthentication/accessing_keychain_items_with_face_id_or_touch_id
/// > Note that this grace period applies specifically to device unlock with Touch ID, not keychain retrieval authentications
///
/// If you want to avoid requiring authentication after a successful
/// keychain retrieval see [iosTouchIDAuthenticationForceReuseContextDuration]
final Duration? iosTouchIDAuthenticationAllowableReuseDuration;

/// To prevent forcing the user to authenticate again after unlocking once
/// we can reuse the `LAContext` object for the given amount of time.
/// see https://github.com/authpass/biometric_storage/pull/73
/// This is pretty much undocumented behavior, but works similar to
/// `androidAuthenticationValidityDuration`.
final Duration? iosTouchIDAuthenticationForceReuseContextDuration;

/// Whether an authentication is required. if this is
/// false NO BIOMETRIC CHECK WILL BE PERFORMED! and the value
/// will simply be save encrypted. (default: true)
Expand All @@ -97,14 +129,20 @@ class StorageFileInitOptions {
/// On Android < 30 this will always be ignored. (always `true`)
/// https://github.com/authpass/biometric_storage/issues/12#issuecomment-900358154
///
/// Also: this **must** be `true` if [authenticationValidityDurationSeconds]
/// is `-1`.
/// Also: this **must** be `true` if [androidAuthenticationValidityDuration]
/// is null.
/// https://github.com/authpass/biometric_storage/issues/12#issuecomment-902508609
final bool androidBiometricOnly;

Map<String, dynamic> toJson() => <String, dynamic>{
'authenticationValidityDurationSeconds':
authenticationValidityDurationSeconds,
'androidAuthenticationValidityDurationSeconds':
androidAuthenticationValidityDuration?.inSeconds,
'iosTouchIDAuthenticationAllowableReuseDurationSeconds':
iosTouchIDAuthenticationAllowableReuseDuration?.inSeconds,
'iosTouchIDAuthenticationForceReuseContextDurationSeconds':
iosTouchIDAuthenticationForceReuseContextDuration?.inSeconds,
'authenticationRequired': authenticationRequired,
'androidBiometricOnly': androidBiometricOnly,
};
Expand Down
44 changes: 32 additions & 12 deletions macos/Classes/BiometricStorageImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ struct StorageMethodCall {

class InitOptions {
init(params: [String: Any]) {
authenticationValidityDurationSeconds = params["authenticationValidityDurationSeconds"] as? Int
iosTouchIDAuthenticationAllowableReuseDuration = params["iosTouchIDAuthenticationAllowableReuseDurationSeconds"] as? Int
iosTouchIDAuthenticationForceReuseContextDuration = params["iosTouchIDAuthenticationForceReuseContextDurationSeconds"] as? Int
authenticationRequired = params["authenticationRequired"] as? Bool
}
let authenticationValidityDurationSeconds: Int!
let iosTouchIDAuthenticationAllowableReuseDuration: Int?
let iosTouchIDAuthenticationForceReuseContextDuration: Int?
let authenticationRequired: Bool!
}

Expand Down Expand Up @@ -148,23 +150,41 @@ class BiometricStorageImpl {
}
}

typealias StoredContext = (context: LAContext, expireAt: Date)

class BiometricStorageFile {
private let name: String
private let initOptions: InitOptions
private var context: LAContext { get {
let context = LAContext()
if (initOptions.authenticationRequired) {
if initOptions.authenticationValidityDurationSeconds > 0 {
if #available(OSX 10.12, *) {
context.touchIDAuthenticationAllowableReuseDuration = Double(initOptions.authenticationValidityDurationSeconds)
private var _context: StoredContext?
private var context: LAContext {
get {
if let context = _context {
if context.expireAt.timeIntervalSinceNow < 0 {
// already expired.
_context = nil
} else {
// Fallback on earlier versions
hpdebug("Pre OSX 10.12 no touchIDAuthenticationAllowableReuseDuration available. ignoring.")
return context.context
}
}

let context = LAContext()
if (initOptions.authenticationRequired) {
if let duration = initOptions.iosTouchIDAuthenticationAllowableReuseDuration {
if #available(OSX 10.12, *) {
context.touchIDAuthenticationAllowableReuseDuration = Double(duration)
} else {
// Fallback on earlier versions
hpdebug("Pre OSX 10.12 no touchIDAuthenticationAllowableReuseDuration available. ignoring.")
}
}

if let duration = initOptions.iosTouchIDAuthenticationForceReuseContextDuration {
_context = (context: context, expireAt: Date(timeIntervalSinceNow: Double(duration)))
Copy link
Contributor

Choose a reason for hiding this comment

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

The problem with assigning the expiry time when creating the context is that this will not reflect the actual time since successful keychain authentication occurred. That will be particularly noticeable any time a reasonably low (<30 seconds?) iosTouchIDAuthenticationForceReuseContextDuration is used and the user has to attempt authentication a number of times before the biometrics are confirmed.

}
}
return context
}
return context
} }
}
private let storageError: StorageError

init(name: String, initOptions: InitOptions, storageError: @escaping StorageError) {
Expand Down