diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 01012e3..2ef7c50 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,6 +23,10 @@ jobs:
- name: Set up Android SDK
uses: android-actions/setup-android@v3
+ with:
+ # The action's default list includes the legacy 'tools' package, which current
+ # SDK repositories no longer carry, so sdkmanager exits 1 before anything builds.
+ packages: platform-tools
- name: Set up Codename One Build Client
shell: bash
diff --git a/android/src/main/java/com/codename1/fingerprint/impl/InternalFingerprintImpl.java b/android/src/main/java/com/codename1/fingerprint/impl/InternalFingerprintImpl.java
index a7798a2..f2cf538 100644
--- a/android/src/main/java/com/codename1/fingerprint/impl/InternalFingerprintImpl.java
+++ b/android/src/main/java/com/codename1/fingerprint/impl/InternalFingerprintImpl.java
@@ -604,6 +604,14 @@ private boolean getPassword29(final int requestId, final String reason, final St
InternalCallback.requestError(requestId,"The specified item could not be found in the keychain.");
return false;
}
+ // Before the availability check: when the user deleted EVERY biometric, nothing
+ // is enrolled, so that check fails and the password would be reported as "not
+ // available" -- although Android has already deleted its key and it can never be
+ // read again, even after re-enrolment. A stored password with no key is revoked.
+ if (getSecretKey() == null && keyRevoked) {
+ InternalCallback.requestKeyRevokedError(requestId, "Key has been invalidated");
+ return false;
+ }
if (isBiometricAuthAvailable()) {
SecretKey secretKey = getSecretKey();
@@ -736,6 +744,14 @@ private boolean getPassword(final int requestId, final String reason, final Stri
InternalCallback.requestError(requestId,"The specified item could not be found in the keychain.");
return false;
}
+ // Before the availability check: when the user deleted EVERY biometric, nothing
+ // is enrolled, so that check fails and the password would be reported as "not
+ // available" -- although Android has already deleted its key and it can never be
+ // read again, even after re-enrolment. A stored password with no key is revoked.
+ if (getSecretKey() == null && keyRevoked) {
+ InternalCallback.requestKeyRevokedError(requestId, "Key has been invalidated");
+ return false;
+ }
if (isFingerprintAuthAvailable()) {
SecretKey secretKey = getSecretKey();
@@ -918,6 +934,14 @@ private SecretKey getSecretKey() {
try {
mKeyStore().load(null);
key = (SecretKey) mKeyStore.getKey(KEY_ID, null);
+ if (key == null) {
+ // Android deletes a biometric-bound key outright when the user removes
+ // EVERY enrolled biometric, instead of failing with
+ // KeyPermanentlyInvalidatedException. Both callers that read keyRevoked
+ // return first when no password is stored, so an absent key there means
+ // a stored password whose key was taken away: revoked, not "No key found".
+ keyRevoked = true;
+ }
} catch (KeyStoreException e) {
errorMessage = getSecretKeyExceptionErrorPrefix + "KeyStoreException";
} catch (CertificateException e) {
diff --git a/fingerprint-scanner-test/pom.xml b/fingerprint-scanner-test/pom.xml
index 1f418ac..fb86418 100644
--- a/fingerprint-scanner-test/pom.xml
+++ b/fingerprint-scanner-test/pom.xml
@@ -19,8 +19,8 @@
common
- 7.0.228
- 7.0.228
+ 7.0.281
+ 7.0.281
UTF-8
1.8
11
@@ -32,6 +32,20 @@
myappname
1.1.4
+
+
+
+ codenameone
+ https://repo.codenameone.com/maven2
+
+
+
+
+ codenameone
+ https://repo.codenameone.com/maven2
+
+
@@ -107,8 +121,6 @@
-
-
javascript
diff --git a/ios/src/main/objectivec/com_codename1_fingerprint_impl_InternalFingerprintImpl.m b/ios/src/main/objectivec/com_codename1_fingerprint_impl_InternalFingerprintImpl.m
index 7bf9c4e..2ddfb33 100644
--- a/ios/src/main/objectivec/com_codename1_fingerprint_impl_InternalFingerprintImpl.m
+++ b/ios/src/main/objectivec/com_codename1_fingerprint_impl_InternalFingerprintImpl.m
@@ -257,6 +257,27 @@ -(void)deletePassword:(int)requestId param1:(NSString*)reason param2:(NSString*)
}
});
}
+// Attributes only: not guarded by the access-control list, so no prompt, and an item whose
+// biometric binding was invalidated is still found.
+-(BOOL)itemExists:(NSString*)account {
+ NSMutableDictionary *q = [NSMutableDictionary dictionary];
+ [q setObject:(__bridge id)kSecClassGenericPassword forKey:(__bridge id)kSecClass];
+ [q setObject:account forKey:(__bridge id)kSecAttrAccount];
+ [q setObject:[self getAppName] forKey:(__bridge id)kSecAttrService];
+ [q setObject:@YES forKey:(__bridge id)kSecReturnAttributes];
+ [q setObject:(__bridge id)kSecMatchLimitOne forKey:(__bridge id)kSecMatchLimit];
+ NSString* accessGroup = [self getKeychainAccessGroup];
+ if (accessGroup != nil) {
+ [q setObject:accessGroup forKey:(__bridge id)kSecAttrAccessGroup];
+ }
+ CFTypeRef attrs = NULL;
+ OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)q, &attrs);
+ if (attrs != NULL) {
+ CFRelease(attrs);
+ }
+ return status == errSecSuccess;
+}
+
-(void)getPassword:(int)requestId param1:(NSString*)reason param2:(NSString*)account {
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
@@ -284,6 +305,21 @@ -(void)getPassword:(int)requestId param1:(NSString*)reason param2:(NSString*)acc
} else if (status == errSecUserCanceled) {
JAVA_OBJECT jErrorMessage = fromNSString(getThreadLocalData(), @"__CANCELLED__");
com_codename1_fingerprint_impl_InternalCallback_requestError___int_java_lang_String(getThreadLocalData(), requestId, jErrorMessage);
+ } else if (status == errSecItemNotFound && [self itemExists:account]) {
+ // Stored under kSecAccessControlTouchIDCurrentSet: a change to the enrolled
+ // Face ID / Touch ID set invalidates the item. Its attributes remain but the
+ // data read answers errSecItemNotFound. Report it as revoked, as Android does,
+ // and delete it, since it can never be read again and would break a later add.
+ NSMutableDictionary *del = [NSMutableDictionary dictionary];
+ [del setObject:(__bridge id)kSecClassGenericPassword forKey:(__bridge id)kSecClass];
+ [del setObject:account forKey:(__bridge id)kSecAttrAccount];
+ [del setObject:[self getAppName] forKey:(__bridge id)kSecAttrService];
+ if (accessGroup != nil) {
+ [del setObject:accessGroup forKey:(__bridge id)kSecAttrAccessGroup];
+ }
+ SecItemDelete((__bridge CFDictionaryRef)del);
+ JAVA_OBJECT jErrorMessage = fromNSString(getThreadLocalData(), @"Key has been invalidated");
+ com_codename1_fingerprint_impl_InternalCallback_requestKeyRevokedError___int_java_lang_String(getThreadLocalData(), requestId, jErrorMessage);
} else {
NSString* errorMessage = [self errorString:status];
JAVA_OBJECT jErrorMessage = fromNSString(getThreadLocalData(), errorMessage);