From 2acbab46fa283d42147246f22688ed6ef2a8973a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 19 May 2026 21:35:36 +0300 Subject: [PATCH 01/12] Add com.codename1.security: biometric auth + secure storage Promotes biometric authentication (Touch ID, Face ID, Android BiometricPrompt) from the FingerprintScanner cn1lib into core so it is available alongside Location, Capture, and the other first-class device APIs. Public surface mirrors Flutter's local_auth: typed BiometricType list, typed BiometricError codes, AuthenticationOptions builder, and a stopAuthentication() cancel. iOS port wraps LocalAuthentication.framework + Security.framework (SecItemAdd / SecItemCopyMatching / SecItemDelete). Android port keeps the cn1lib's dual path -- FingerprintManager on API 23-28 and BiometricPrompt on API 29+; the BiometricPrompt + BiometricManager calls go through a reflection adapter (BiometricsApi29) because the cn1-binaries android.jar predates API 28. JavaSE port adds a Simulate -> Biometric Simulation submenu (Available toggle, per-modality enrollment, configurable next-call outcome) so apps can be exercised in the simulator. The Maven plugin always links LocalAuthentication.framework on iOS and injects USE_BIOMETRIC / USE_FINGERPRINT permissions on Android so apps don't need build hint surgery. The existing FingerprintScanner cn1lib continues to work unchanged for projects that depend on it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../impl/CodenameOneImplementation.java | 20 + .../security/AuthenticationOptions.java | 163 +++++++ .../codename1/security/BiometricError.java | 68 +++ .../security/BiometricException.java | 54 +++ .../com/codename1/security/BiometricType.java | 41 ++ .../com/codename1/security/Biometrics.java | 144 ++++++ .../com/codename1/security/SecureStorage.java | 101 ++++ .../codename1/security/StubBiometrics.java | 68 +++ .../codename1/security/StubSecureStorage.java | 64 +++ CodenameOne/src/com/codename1/ui/Display.java | 17 + .../impl/android/AndroidBiometrics.java | 366 ++++++++++++++ .../impl/android/AndroidImplementation.java | 19 + .../impl/android/AndroidSecureStorage.java | 457 ++++++++++++++++++ .../impl/android/BiometricsApi29.java | 212 ++++++++ .../impl/javase/JavaSEBiometrics.java | 269 +++++++++++ .../com/codename1/impl/javase/JavaSEPort.java | 102 ++++ .../impl/javase/JavaSESecureStorage.java | 98 ++++ Ports/iOSPort/nativeSources/IOSNative.m | 268 ++++++++++ .../com/codename1/impl/ios/IOSBiometrics.java | 194 ++++++++ .../codename1/impl/ios/IOSImplementation.java | 19 + .../src/com/codename1/impl/ios/IOSNative.java | 36 ++ .../codename1/impl/ios/IOSSecureStorage.java | 190 ++++++++ .../builders/AndroidGradleBuilder.java | 12 + .../com/codename1/builders/IPhoneBuilder.java | 9 + 24 files changed, 2991 insertions(+) create mode 100644 CodenameOne/src/com/codename1/security/AuthenticationOptions.java create mode 100644 CodenameOne/src/com/codename1/security/BiometricError.java create mode 100644 CodenameOne/src/com/codename1/security/BiometricException.java create mode 100644 CodenameOne/src/com/codename1/security/BiometricType.java create mode 100644 CodenameOne/src/com/codename1/security/Biometrics.java create mode 100644 CodenameOne/src/com/codename1/security/SecureStorage.java create mode 100644 CodenameOne/src/com/codename1/security/StubBiometrics.java create mode 100644 CodenameOne/src/com/codename1/security/StubSecureStorage.java create mode 100644 Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java create mode 100644 Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java create mode 100644 Ports/Android/src/com/codename1/impl/android/BiometricsApi29.java create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/JavaSEBiometrics.java create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/JavaSESecureStorage.java create mode 100644 Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java create mode 100644 Ports/iOSPort/src/com/codename1/impl/ios/IOSSecureStorage.java diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index a9cc64b2b8..47d5c9acc3 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -51,6 +51,8 @@ import com.codename1.payment.Purchase; import com.codename1.payment.PurchaseCallback; import com.codename1.push.PushCallback; +import com.codename1.security.Biometrics; +import com.codename1.security.SecureStorage; import com.codename1.ui.BrowserComponent; import com.codename1.ui.BrowserWindow; import com.codename1.ui.Button; @@ -6550,6 +6552,24 @@ public LocationManager getLocationManager() { return null; } + /// Returns the port-specific biometric authentication entry point. Default + /// implementation returns {@code null}; ports that support biometrics + /// override this to return a cached singleton. Application code should + /// use {@link com.codename1.security.Biometrics#getInstance()} instead + /// of calling this directly --- it transparently substitutes a no-op + /// fallback when the port returns {@code null}. + public Biometrics getBiometrics() { + return null; + } + + /// Returns the port-specific biometric-gated secure storage. Default + /// implementation returns {@code null}; ports that back the keychain + /// override this. Application code should call + /// {@link com.codename1.security.SecureStorage#getInstance()} instead. + public SecureStorage getSecureStorage() { + return null; + } + /// Allows buggy implementations (Android) to release image objects /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/security/AuthenticationOptions.java b/CodenameOne/src/com/codename1/security/AuthenticationOptions.java new file mode 100644 index 0000000000..dca001613e --- /dev/null +++ b/CodenameOne/src/com/codename1/security/AuthenticationOptions.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security; + +/** + * Configures a single call to {@link Biometrics#authenticate(AuthenticationOptions)}. + * Setters return {@code this} for fluent chaining; only {@link #setReason(String)} + * is required (it maps to the iOS {@code localizedReason} and the Android + * BiometricPrompt title fallback). + * + *

Not every option is honored on every platform — the JavaDoc on each + * setter notes the platforms where the value is consulted. Unrecognized + * options are silently ignored, so callers can set the union without + * platform-checking.

+ */ +public final class AuthenticationOptions { + + private String reason; + private String title; + private String subtitle; + private String description; + private String negativeButtonText = "Cancel"; + private boolean biometricOnly; + private boolean sensitiveTransaction; + private boolean stickyAuth; + private boolean showDialogOnAndroid = true; + + public AuthenticationOptions() { + } + + public String getReason() { + return reason; + } + + /** + * The user-facing reason for prompting. On iOS this is the + * {@code localizedReason} passed to {@code LAContext.evaluatePolicy}; + * on Android it is used as the BiometricPrompt title if + * {@link #setTitle(String)} is unset. + */ + public AuthenticationOptions setReason(String reason) { + this.reason = reason; + return this; + } + + public String getTitle() { + return title; + } + + /** Android BiometricPrompt title. Ignored on iOS. */ + public AuthenticationOptions setTitle(String title) { + this.title = title; + return this; + } + + public String getSubtitle() { + return subtitle; + } + + /** Android BiometricPrompt subtitle. Ignored on iOS. */ + public AuthenticationOptions setSubtitle(String subtitle) { + this.subtitle = subtitle; + return this; + } + + public String getDescription() { + return description; + } + + /** Android BiometricPrompt description body. Ignored on iOS. */ + public AuthenticationOptions setDescription(String description) { + this.description = description; + return this; + } + + public String getNegativeButtonText() { + return negativeButtonText; + } + + /** Android BiometricPrompt negative button label (defaults to "Cancel"). */ + public AuthenticationOptions setNegativeButtonText(String text) { + this.negativeButtonText = text == null ? "Cancel" : text; + return this; + } + + public boolean isBiometricOnly() { + return biometricOnly; + } + + /** + * If {@code true}, the OS prompt rejects device-credential fallback (PIN + * / pattern / passcode). Honored on both platforms; on Android this maps + * to {@code setAllowedAuthenticators(BIOMETRIC_STRONG)} or its legacy + * equivalent. + */ + public AuthenticationOptions setBiometricOnly(boolean biometricOnly) { + this.biometricOnly = biometricOnly; + return this; + } + + public boolean isSensitiveTransaction() { + return sensitiveTransaction; + } + + /** + * Hints that the operation guards a sensitive action and a class-3 + * ("strong") biometric should be required where the platform exposes the + * distinction. Affects Android API 30+; advisory on iOS. + */ + public AuthenticationOptions setSensitiveTransaction(boolean sensitive) { + this.sensitiveTransaction = sensitive; + return this; + } + + public boolean isStickyAuth() { + return stickyAuth; + } + + /** + * If {@code true}, the in-progress authentication survives the app being + * backgrounded and resumes on foreground (Android sticky-auth semantics). + * No effect on iOS. + */ + public AuthenticationOptions setStickyAuth(boolean stickyAuth) { + this.stickyAuth = stickyAuth; + return this; + } + + public boolean isShowDialogOnAndroid() { + return showDialogOnAndroid; + } + + /** + * Controls whether the legacy {@code FingerprintManager} path (Android + * 6-9) draws a Codename One Dialog over the system prompt. The modern + * BiometricPrompt path (Android 10+) provides its own UI and ignores + * this flag. + */ + public AuthenticationOptions setShowDialogOnAndroid(boolean show) { + this.showDialogOnAndroid = show; + return this; + } +} diff --git a/CodenameOne/src/com/codename1/security/BiometricError.java b/CodenameOne/src/com/codename1/security/BiometricError.java new file mode 100644 index 0000000000..4ea75c50ea --- /dev/null +++ b/CodenameOne/src/com/codename1/security/BiometricError.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security; + +/** + * Typed error codes returned by {@link Biometrics} and {@link SecureStorage} + * when an asynchronous operation fails. Callers branch on these codes via + * {@link BiometricException#getError()} instead of string-matching error + * messages, which makes localization and recovery logic straightforward. + */ +public enum BiometricError { + /** Biometric hardware is not present, or is disabled by policy. */ + NOT_AVAILABLE, + + /** Hardware is present but the user has not enrolled any biometrics. */ + NOT_ENROLLED, + + /** Too many failed attempts; biometric prompt is temporarily disabled. */ + LOCKED_OUT, + + /** + * Too many failed attempts and the user must unlock with their device + * passcode or PIN before biometrics can be used again. + */ + PERMANENTLY_LOCKED_OUT, + + /** The device has no passcode / PIN / pattern configured. */ + PASSCODE_NOT_SET, + + /** The user explicitly cancelled the prompt. */ + USER_CANCELED, + + /** The OS cancelled the prompt (app backgrounded, system pre-empted, etc.). */ + SYSTEM_CANCELED, + + /** Authentication completed but the user was not recognized. */ + AUTHENTICATION_FAILED, + + /** + * A previously-stored {@link SecureStorage} entry can no longer be decrypted + * because the user enrolled new biometrics or disabled device security since + * the entry was written. Callers must re-prompt and re-write the entry. + */ + KEY_REVOKED, + + /** Anything not covered by the more specific codes. */ + UNKNOWN +} diff --git a/CodenameOne/src/com/codename1/security/BiometricException.java b/CodenameOne/src/com/codename1/security/BiometricException.java new file mode 100644 index 0000000000..b600646b3e --- /dev/null +++ b/CodenameOne/src/com/codename1/security/BiometricException.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security; + +/** + * Thrown via the failure path of an {@code AsyncResource} returned by + * {@link Biometrics} or {@link SecureStorage} when the underlying biometric or + * keychain operation fails. {@link #getError()} returns a typed + * {@link BiometricError} code so callers can react without string-matching. + */ +public class BiometricException extends Exception { + + private final BiometricError error; + + public BiometricException(BiometricError error) { + super(error == null ? "UNKNOWN" : error.name()); + this.error = error == null ? BiometricError.UNKNOWN : error; + } + + public BiometricException(BiometricError error, String message) { + super(message); + this.error = error == null ? BiometricError.UNKNOWN : error; + } + + public BiometricException(BiometricError error, String message, Throwable cause) { + super(message, cause); + this.error = error == null ? BiometricError.UNKNOWN : error; + } + + /** Typed error code describing the failure. Never {@code null}. */ + public BiometricError getError() { + return error; + } +} diff --git a/CodenameOne/src/com/codename1/security/BiometricType.java b/CodenameOne/src/com/codename1/security/BiometricType.java new file mode 100644 index 0000000000..288a53caa5 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/BiometricType.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security; + +/** + * Enumerates the biometric authentication modalities that may be available on a + * device. Returned from {@link Biometrics#getAvailableBiometrics()}. + * + *

{@link #FINGERPRINT} and {@link #FACE} are populated on both iOS and + * Android. {@link #IRIS} only appears on Android devices whose hardware + * advertises {@code PackageManager.FEATURE_IRIS}. {@link #STRONG} and + * {@link #WEAK} reflect Android's BiometricManager authenticator class tiers + * (class 3 and class 2) and are only populated on Android API 30+.

+ */ +public enum BiometricType { + FINGERPRINT, + FACE, + IRIS, + STRONG, + WEAK +} diff --git a/CodenameOne/src/com/codename1/security/Biometrics.java b/CodenameOne/src/com/codename1/security/Biometrics.java new file mode 100644 index 0000000000..dd745143bf --- /dev/null +++ b/CodenameOne/src/com/codename1/security/Biometrics.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security; + +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.util.List; + +/** + * Entry point for biometric authentication (Touch ID, Face ID, fingerprint, + * Android BiometricPrompt). Obtain the platform implementation via + * {@link #getInstance()}; the returned subclass is owned by the active port. + * + *

A typical unlock flow:

+ * + *
{@code
+ * Biometrics b = Biometrics.getInstance();
+ * if (!b.canAuthenticate()) {
+ *     // Fall back to password
+ *     return;
+ * }
+ * b.authenticate("Unlock your account").onResult((success, err) -> {
+ *     if (err != null) {
+ *         BiometricError code = ((BiometricException) err).getError();
+ *         // branch on code
+ *     } else {
+ *         // success
+ *     }
+ * });
+ * }
+ * + *

{@link #authenticate(AuthenticationOptions)} returns an + * {@link AsyncResource} whose failure path completes with a + * {@link BiometricException} so callers can branch on the typed + * {@link BiometricError}.

+ * + *

This class is the parallel of Flutter's {@code local_auth} surface. + * On platforms without biometric support (desktop simulator with the + * "Available" simulator menu item unchecked, or older Android devices), + * {@link #canAuthenticate()} returns {@code false} and + * {@link #authenticate(AuthenticationOptions)} completes with + * {@link BiometricError#NOT_AVAILABLE}.

+ */ +public abstract class Biometrics { + + private static Biometrics fallback; + + /** Subclasses are constructed by the port; not for application use. */ + protected Biometrics() { + } + + /** + * Returns the platform-specific singleton owned by the current port. + * Ports that do not implement biometrics get a no-op fallback that + * reports {@link BiometricError#NOT_AVAILABLE}. + */ + public static Biometrics getInstance() { + Biometrics b = Display.getInstance().getBiometrics(); + if (b != null) { + return b; + } + if (fallback == null) { + fallback = new StubBiometrics(); + } + return fallback; + } + + /** + * Returns {@code true} when biometric hardware exists on the device, + * regardless of whether the user has enrolled biometrics. Combine with + * {@link #canAuthenticate()} to gate UI affordances: show the "Use + * biometrics" toggle when {@code isSupported()} is true, but only invoke + * {@link #authenticate(AuthenticationOptions)} when {@code canAuthenticate()} + * is also true. + */ + public abstract boolean isSupported(); + + /** + * Returns {@code true} when the device is ready to authenticate right now: + * hardware present, at least one biometric enrolled, and not in a + * locked-out state. + */ + public abstract boolean canAuthenticate(); + + /** + * Lists the biometric modalities currently enrolled. On iOS this is + * {@link BiometricType#FINGERPRINT} or {@link BiometricType#FACE}; on + * Android the list may contain {@link BiometricType#IRIS} as well, and + * Android API 30+ adds {@link BiometricType#STRONG} / {@link BiometricType#WEAK} + * authenticator class tags. + * + * @return an empty list when nothing is enrolled or the device is unsupported + */ + public abstract List getAvailableBiometrics(); + + /** + * Prompts the user to authenticate. The returned {@link AsyncResource} + * completes with {@code true} on success, or with a + * {@link BiometricException} on failure (consult + * {@link BiometricException#getError()} for the typed code). + * + * @param opts non-null configuration; {@link AuthenticationOptions#setReason(String)} + * should be set + */ + public abstract AsyncResource authenticate(AuthenticationOptions opts); + + /** + * Convenience for {@code authenticate(new AuthenticationOptions().setReason(reason))}. + */ + public AsyncResource authenticate(String reason) { + return authenticate(new AuthenticationOptions().setReason(reason)); + } + + /** + * Cancels an in-flight {@link #authenticate(AuthenticationOptions)} call + * if one is running. The pending {@link AsyncResource} completes with + * {@link BiometricError#USER_CANCELED}. + * + * @return {@code true} when a call was cancelled; {@code false} when no + * authentication was pending + */ + public abstract boolean stopAuthentication(); +} diff --git a/CodenameOne/src/com/codename1/security/SecureStorage.java b/CodenameOne/src/com/codename1/security/SecureStorage.java new file mode 100644 index 0000000000..ae31f80bc5 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/SecureStorage.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security; + +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +/** + * Biometric-gated secure storage backed by the platform keychain. + * Reading an entry prompts the user for biometric authentication; writing or + * deleting may or may not, depending on the platform. + * + *

Entries are bound to the current set of enrolled biometrics. If the user + * adds a fingerprint, enrolls a new face, or disables device security, every + * stored entry is automatically invalidated and subsequent + * {@link #get(String, String)} calls fail with + * {@link BiometricError#KEY_REVOKED}. The application must then re-prompt the + * user for the original value and {@link #set(String, String, String)} it + * again.

+ * + *

Use this for short, secret strings (auth tokens, refresh tokens, + * encryption keys). For larger data, encrypt with a key stored here.

+ */ +public abstract class SecureStorage { + + private static SecureStorage fallback; + + /** Subclasses are constructed by the port; not for application use. */ + protected SecureStorage() { + } + + /** + * Returns the platform-specific singleton owned by the current port. + * Ports that do not implement secure storage get a no-op fallback that + * reports {@link BiometricError#NOT_AVAILABLE}. + */ + public static SecureStorage getInstance() { + SecureStorage s = Display.getInstance().getSecureStorage(); + if (s != null) { + return s; + } + if (fallback == null) { + fallback = new StubSecureStorage(); + } + return fallback; + } + + /** + * Retrieves a previously-stored entry, prompting for biometric + * authentication. The returned {@link AsyncResource} completes with the + * value, or with a {@link BiometricException} on failure (including + * {@link BiometricError#KEY_REVOKED} when biometrics have been re-enrolled + * since the entry was written). + */ + public abstract AsyncResource get(String reason, String account); + + /** + * Stores or overwrites a value for the given account. On iOS the user is + * typically not prompted (Apple's keychain accepts writes without + * re-authenticating); on Android the user is prompted because the + * underlying cipher requires biometric authentication. + */ + public abstract AsyncResource set(String reason, String account, String value); + + /** + * Removes a previously-stored entry. No authentication is required since + * deletion does not reveal the value. + */ + public abstract AsyncResource remove(String reason, String account); + + /** + * Configures the iOS keychain access group for sharing entries between + * the main app and its extensions. The argument must include the Team ID + * prefix (e.g. {@code "ABCDE12345.group.com.example.app"}). Pass + * {@code null} or empty to clear. Ignored on non-iOS platforms. + * + *

The {@code ios.keychainAccessGroup} build hint must declare the same + * group in the app's entitlements for this to work.

+ */ + public abstract void setKeychainAccessGroup(String group); +} diff --git a/CodenameOne/src/com/codename1/security/StubBiometrics.java b/CodenameOne/src/com/codename1/security/StubBiometrics.java new file mode 100644 index 0000000000..8024420d71 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/StubBiometrics.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security; + +import com.codename1.util.AsyncResource; + +import java.util.Collections; +import java.util.List; + +/** + * No-op Biometrics returned by {@code CodenameOneImplementation} when a port + * has not overridden {@code getBiometrics()}. Reports the device as + * unsupported and fails every authentication with + * {@link BiometricError#NOT_AVAILABLE}. + */ +final class StubBiometrics extends Biometrics { + + StubBiometrics() { + } + + @Override + public boolean isSupported() { + return false; + } + + @Override + public boolean canAuthenticate() { + return false; + } + + @Override + public List getAvailableBiometrics() { + return Collections.emptyList(); + } + + @Override + public AsyncResource authenticate(AuthenticationOptions opts) { + AsyncResource r = new AsyncResource(); + r.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "Biometric authentication is not available on this platform")); + return r; + } + + @Override + public boolean stopAuthentication() { + return false; + } +} diff --git a/CodenameOne/src/com/codename1/security/StubSecureStorage.java b/CodenameOne/src/com/codename1/security/StubSecureStorage.java new file mode 100644 index 0000000000..06042d9099 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/StubSecureStorage.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security; + +import com.codename1.util.AsyncResource; + +/** + * No-op SecureStorage returned by {@code CodenameOneImplementation} when a + * port has not overridden {@code getSecureStorage()}. + */ +final class StubSecureStorage extends SecureStorage { + + StubSecureStorage() { + } + + @Override + public AsyncResource get(String reason, String account) { + AsyncResource r = new AsyncResource(); + r.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "Secure storage is not available on this platform")); + return r; + } + + @Override + public AsyncResource set(String reason, String account, String value) { + AsyncResource r = new AsyncResource(); + r.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "Secure storage is not available on this platform")); + return r; + } + + @Override + public AsyncResource remove(String reason, String account) { + AsyncResource r = new AsyncResource(); + r.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "Secure storage is not available on this platform")); + return r; + } + + @Override + public void setKeychainAccessGroup(String group) { + // No-op on the stub. + } +} diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index e9e08943f5..a01d28bc1a 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -37,6 +37,8 @@ import com.codename1.io.Util; import com.codename1.l10n.L10NManager; import com.codename1.location.LocationManager; +import com.codename1.security.Biometrics; +import com.codename1.security.SecureStorage; import com.codename1.media.Media; import com.codename1.media.MediaRecorderBuilder; import com.codename1.messaging.Message; @@ -4269,6 +4271,21 @@ public LocationManager getLocationManager() { return impl.getLocationManager(); } + /// Returns the platform biometric authentication entry point. Prefer + /// {@link com.codename1.security.Biometrics#getInstance()} in application + /// code --- it handles the fallback to a no-op stub when the current port + /// does not implement biometrics. + public Biometrics getBiometrics() { + return impl.getBiometrics(); + } + + /// Returns the platform biometric-gated secure storage. Prefer + /// {@link com.codename1.security.SecureStorage#getInstance()} in + /// application code. + public SecureStorage getSecureStorage() { + return impl.getSecureStorage(); + } + /// This method tries to invoke the device native camera to capture images. /// The method returns immediately and the response will be sent asynchronously /// to the given ActionListener Object diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java new file mode 100644 index 0000000000..b67ec7ba36 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java @@ -0,0 +1,366 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +import android.Manifest; +import android.app.Activity; +import android.content.pm.PackageManager; +import android.hardware.fingerprint.FingerprintManager; +import android.os.Build; +import android.os.CancellationSignal; +import android.os.Looper; + +import com.codename1.io.Log; +import com.codename1.security.AuthenticationOptions; +import com.codename1.security.BiometricError; +import com.codename1.security.BiometricException; +import com.codename1.security.BiometricType; +import com.codename1.security.Biometrics; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.List; + +/** + * Android backing for {@link Biometrics}. Uses + * {@code BiometricPrompt} on API 29+ (via reflection — the cn1-binaries + * android.jar predates API 28 so direct calls would not compile) and the + * legacy {@code FingerprintManager} on API 23-28. Mirrors the dual-path + * behaviour of the historical {@code FingerprintScanner} cn1lib but completes + * per-call {@link AsyncResource} instances instead of a shared static + * callback. + * + *

FingerprintManager error codes documented at + * developer.android.com; + * the constants missing from the compile-time android.jar are inlined below.

+ */ +public final class AndroidBiometrics extends Biometrics { + + // FingerprintManager constants not in the cn1-binaries android.jar. + private static final int FINGERPRINT_ERROR_NO_FINGERPRINTS = 11; + private static final int FINGERPRINT_ERROR_HW_NOT_PRESENT = 12; + + // BiometricPrompt error codes (API 28+) — values are stable per AOSP. + static final int BIOMETRIC_ERROR_HW_UNAVAILABLE = 1; + static final int BIOMETRIC_ERROR_HW_NOT_PRESENT = 12; + static final int BIOMETRIC_ERROR_LOCKOUT = 7; + static final int BIOMETRIC_ERROR_LOCKOUT_PERMANENT = 9; + static final int BIOMETRIC_ERROR_NO_BIOMETRICS = 11; + static final int BIOMETRIC_ERROR_USER_CANCELED = 10; + static final int BIOMETRIC_ERROR_NEGATIVE_BUTTON = 13; + static final int BIOMETRIC_ERROR_CANCELED = 5; + static final int BIOMETRIC_ERROR_NO_DEVICE_CREDENTIAL = 14; + + private CancellationSignal cancellationSignal; + private AsyncResource pending; + + AndroidBiometrics() { + } + + @Override + public boolean isSupported() { + if (Build.VERSION.SDK_INT < 23) { + return false; + } + PackageManager pm = AndroidNativeUtil.getActivity().getPackageManager(); + if (pm.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) { + return true; + } + if (Build.VERSION.SDK_INT >= 29) { + if (pm.hasSystemFeature("android.hardware.biometrics.face") + || pm.hasSystemFeature("android.hardware.biometrics.iris")) { + return true; + } + } + return false; + } + + @Override + public boolean canAuthenticate() { + if (Build.VERSION.SDK_INT < 23) { + return false; + } + return !getAvailableBiometrics().isEmpty(); + } + + @Override + public List getAvailableBiometrics() { + final List out = new ArrayList(); + if (Build.VERSION.SDK_INT < 23) { + return out; + } + runOnUi(new Runnable() { + @Override + public void run() { + try { + Activity act = AndroidNativeUtil.getActivity(); + PackageManager pm = act.getPackageManager(); + boolean okBio = false; + if (Build.VERSION.SDK_INT >= 29) { + if (!AndroidNativeUtil.checkForPermission("android.permission.USE_BIOMETRIC", + "Authorize using biometrics")) { + return; + } + okBio = BiometricsApi29.canAuthenticate(act); + } else { + if (!AndroidNativeUtil.checkForPermission(Manifest.permission.USE_FINGERPRINT, + "Authorize using fingerprint")) { + return; + } + FingerprintManager fpm = (FingerprintManager) + act.getSystemService(Activity.FINGERPRINT_SERVICE); + okBio = fpm != null && fpm.isHardwareDetected() + && fpm.hasEnrolledFingerprints(); + } + if (!okBio) { + return; + } + if (pm.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) { + FingerprintManager fpm = (FingerprintManager) + act.getSystemService(Activity.FINGERPRINT_SERVICE); + if (fpm != null && fpm.hasEnrolledFingerprints()) { + out.add(BiometricType.FINGERPRINT); + } + } + if (Build.VERSION.SDK_INT >= 29) { + if (pm.hasSystemFeature("android.hardware.biometrics.face")) { + out.add(BiometricType.FACE); + } + if (pm.hasSystemFeature("android.hardware.biometrics.iris")) { + out.add(BiometricType.IRIS); + } + } + } catch (Throwable t) { + Log.e(t); + } + } + }); + return out; + } + + @Override + public AsyncResource authenticate(final AuthenticationOptions opts) { + final AsyncResource result = new AsyncResource(); + if (Build.VERSION.SDK_INT < 23) { + result.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "Android API 23 (Marshmallow) required for biometric authentication")); + return result; + } + final String reason = opts == null || opts.getReason() == null + ? "Authenticate" : opts.getReason(); + final String title = opts == null || opts.getTitle() == null + ? reason : opts.getTitle(); + final String negative = opts == null || opts.getNegativeButtonText() == null + ? "Cancel" : opts.getNegativeButtonText(); + final String subtitle = opts == null ? null : opts.getSubtitle(); + final String description = opts == null ? null : opts.getDescription(); + + pending = result; + if (Build.VERSION.SDK_INT >= 29) { + runOnUi(new Runnable() { + @Override + public void run() { + if (cancellationSignal != null) { + cancellationSignal.cancel(); + } + cancellationSignal = new CancellationSignal(); + BiometricsApi29.authenticate(AndroidNativeUtil.getActivity(), + title, subtitle, description, negative, + cancellationSignal, + new BiometricsApi29.AuthCallback() { + @Override + public void onSuccess() { + completeSuccess(result); + } + + @Override + public void onError(int code, String msg) { + completeError(result, mapBiometricPromptError(code), msg); + } + }); + } + }); + } else { + authenticateLegacy(result); + } + return result; + } + + private void authenticateLegacy(final AsyncResource result) { + runOnUi(new Runnable() { + @Override + public void run() { + if (!AndroidNativeUtil.checkForPermission(Manifest.permission.USE_FINGERPRINT, + "Authorize using fingerprint")) { + completeError(result, BiometricError.NOT_AVAILABLE, + "USE_FINGERPRINT permission denied"); + return; + } + FingerprintManager fpm = (FingerprintManager) + AndroidNativeUtil.getActivity() + .getSystemService(Activity.FINGERPRINT_SERVICE); + if (fpm == null || !fpm.isHardwareDetected()) { + completeError(result, BiometricError.NOT_AVAILABLE, + "No fingerprint hardware"); + return; + } + if (!fpm.hasEnrolledFingerprints()) { + completeError(result, BiometricError.NOT_ENROLLED, "No fingerprints enrolled"); + return; + } + if (cancellationSignal != null) { + cancellationSignal.cancel(); + } + final CancellationSignal cs = new CancellationSignal(); + cancellationSignal = cs; + FingerprintManager.AuthenticationCallback cb = new FingerprintManager.AuthenticationCallback() { + int failures; + + @Override + public void onAuthenticationError(int errorCode, CharSequence errString) { + completeError(result, mapFingerprintManagerError(errorCode), + errString == null ? "" : errString.toString()); + } + + @Override + public void onAuthenticationSucceeded(FingerprintManager.AuthenticationResult r) { + cs.cancel(); + completeSuccess(result); + } + + @Override + public void onAuthenticationFailed() { + if (failures++ > 5) { + cs.cancel(); + completeError(result, BiometricError.AUTHENTICATION_FAILED, + "Authentication failed"); + } + } + }; + fpm.authenticate(null, cs, 0, cb, null); + } + }); + } + + void completeSuccess(final AsyncResource result) { + if (pending != result) { + return; + } + pending = null; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (!result.isDone()) { + result.complete(Boolean.TRUE); + } + } + }); + } + + void completeError(final AsyncResource result, + final BiometricError err, final String msg) { + if (pending != result) { + return; + } + pending = null; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (!result.isDone()) { + result.error(new BiometricException(err, msg)); + } + } + }); + } + + static BiometricError mapBiometricPromptError(int code) { + switch (code) { + case BIOMETRIC_ERROR_HW_UNAVAILABLE: + case BIOMETRIC_ERROR_HW_NOT_PRESENT: + return BiometricError.NOT_AVAILABLE; + case BIOMETRIC_ERROR_LOCKOUT: + return BiometricError.LOCKED_OUT; + case BIOMETRIC_ERROR_LOCKOUT_PERMANENT: + return BiometricError.PERMANENTLY_LOCKED_OUT; + case BIOMETRIC_ERROR_NO_BIOMETRICS: + return BiometricError.NOT_ENROLLED; + case BIOMETRIC_ERROR_USER_CANCELED: + case BIOMETRIC_ERROR_NEGATIVE_BUTTON: + return BiometricError.USER_CANCELED; + case BIOMETRIC_ERROR_CANCELED: + return BiometricError.SYSTEM_CANCELED; + case BIOMETRIC_ERROR_NO_DEVICE_CREDENTIAL: + return BiometricError.PASSCODE_NOT_SET; + default: + return BiometricError.UNKNOWN; + } + } + + static BiometricError mapFingerprintManagerError(int code) { + switch (code) { + case FingerprintManager.FINGERPRINT_ERROR_HW_UNAVAILABLE: + case FINGERPRINT_ERROR_HW_NOT_PRESENT: + return BiometricError.NOT_AVAILABLE; + case FingerprintManager.FINGERPRINT_ERROR_LOCKOUT: + return BiometricError.LOCKED_OUT; + case FingerprintManager.FINGERPRINT_ERROR_LOCKOUT_PERMANENT: + return BiometricError.PERMANENTLY_LOCKED_OUT; + case FINGERPRINT_ERROR_NO_FINGERPRINTS: + return BiometricError.NOT_ENROLLED; + case FingerprintManager.FINGERPRINT_ERROR_USER_CANCELED: + return BiometricError.USER_CANCELED; + case FingerprintManager.FINGERPRINT_ERROR_CANCELED: + return BiometricError.SYSTEM_CANCELED; + default: + return BiometricError.UNKNOWN; + } + } + + @Override + public boolean stopAuthentication() { + final AsyncResource p = pending; + if (p == null) { + return false; + } + runOnUi(new Runnable() { + @Override + public void run() { + if (cancellationSignal != null) { + cancellationSignal.cancel(); + cancellationSignal = null; + } + } + }); + completeError(p, BiometricError.USER_CANCELED, "Authentication cancelled by app"); + return true; + } + + static void runOnUi(Runnable r) { + if (Looper.getMainLooper().getThread() == Thread.currentThread()) { + r.run(); + } else { + AndroidNativeUtil.getActivity().runOnUiThread(r); + } + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index a2222c37ba..ba7c25c65c 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -7076,6 +7076,25 @@ public void printStackTraceToStream(Throwable t, Writer o) { t.printStackTrace(p); } + private AndroidBiometrics biometrics; + private AndroidSecureStorage secureStorage; + + @Override + public com.codename1.security.Biometrics getBiometrics() { + if (biometrics == null) { + biometrics = new AndroidBiometrics(); + } + return biometrics; + } + + @Override + public com.codename1.security.SecureStorage getSecureStorage() { + if (secureStorage == null) { + secureStorage = new AndroidSecureStorage(); + } + return secureStorage; + } + /** * This method returns the platform Location Control * diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java new file mode 100644 index 0000000000..ee333b1b97 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java @@ -0,0 +1,457 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.hardware.fingerprint.FingerprintManager; +import android.os.Build; +import android.os.CancellationSignal; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyPermanentlyInvalidatedException; +import android.security.keystore.KeyProperties; +import android.util.Base64; + +import com.codename1.io.Log; +import com.codename1.security.BiometricError; +import com.codename1.security.BiometricException; +import com.codename1.security.SecureStorage; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.UnrecoverableEntryException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; + +/** + * Android backing for {@link SecureStorage}. Values are AES/CBC/PKCS7-encrypted + * with a key stored in the AndroidKeyStore (alias {@code BiometricsKey}), then + * persisted to a private {@code SharedPreferences} file along with the + * randomly-generated IV. The keystore key is created with + * {@code setUserAuthenticationRequired(true)} so a write or read forces a + * biometric prompt; if the user re-enrols biometrics the key becomes + * permanently invalidated and reads fail with + * {@link BiometricError#KEY_REVOKED}. + * + *

Carries forward two non-obvious workarounds from the original cn1lib that + * must NOT be reverted without re-testing:

+ *
    + *
  • On API 33+ the {@code setUserAuthenticationRequired} call is skipped + * to side-step FingerprintScanner #8. + *
  • On Samsung devices running 8.0.0 the cipher init can succeed but + * final decryption then fails with a key-invalidated error; we delete the + * key and recreate it on first failure to recover. + * See Google issue 65578763. + *
+ */ +public final class AndroidSecureStorage extends SecureStorage { + + private static final String KEY_ID = "BiometricsKey"; + private static final String PREFS = "CN1BiometricSecureStorage"; + private static final String ANDROID_KEY_STORE = "AndroidKeyStore"; + + private KeyStore keyStore; + private KeyGenerator keyGenerator; + private Cipher cipher; + private boolean keyRevoked; + private CancellationSignal cancellationSignal; + + AndroidSecureStorage() { + } + + @Override + public void setKeychainAccessGroup(String group) { + // iOS-only; no-op on Android. + } + + @Override + public AsyncResource set(final String reason, final String account, final String value) { + final AsyncResource result = new AsyncResource(); + if (Build.VERSION.SDK_INT < 23) { + result.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "Android API 23 required for biometric secure storage")); + return result; + } + runAuthenticatedCipher(reason, account, Cipher.ENCRYPT_MODE, result, new CipherWork() { + @Override + public Boolean run(Cipher c) throws Exception { + byte[] enc = c.doFinal(value.getBytes("UTF-8")); + SharedPreferences sp = AndroidNativeUtil.getActivity() + .getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + sp.edit() + .putString("v_" + account, Base64.encodeToString(enc, Base64.DEFAULT)) + .putString("iv_" + account, Base64.encodeToString(c.getIV(), Base64.DEFAULT)) + .apply(); + return Boolean.TRUE; + } + }); + return result; + } + + @Override + public AsyncResource get(final String reason, final String account) { + final AsyncResource result = new AsyncResource(); + if (Build.VERSION.SDK_INT < 23) { + result.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "Android API 23 required for biometric secure storage")); + return result; + } + SharedPreferences sp = AndroidNativeUtil.getActivity() + .getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + if (sp.getString("iv_" + account, null) == null) { + result.error(new BiometricException(BiometricError.UNKNOWN, + "No secure storage entry for account: " + account)); + return result; + } + runAuthenticatedCipher(reason, account, Cipher.DECRYPT_MODE, result, new CipherWork() { + @Override + public String run(Cipher c) throws Exception { + SharedPreferences sp2 = AndroidNativeUtil.getActivity() + .getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + byte[] enc = Base64.decode(sp2.getString("v_" + account, ""), Base64.DEFAULT); + byte[] dec = c.doFinal(enc); + return new String(dec, "UTF-8"); + } + }); + return result; + } + + @Override + public AsyncResource remove(String reason, String account) { + AsyncResource result = new AsyncResource(); + SharedPreferences sp = AndroidNativeUtil.getActivity() + .getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + sp.edit().remove("v_" + account).remove("iv_" + account).apply(); + result.complete(Boolean.TRUE); + return result; + } + + /** + * Generic helper that initialises the cipher under the keystore key, + * prompts the user via {@code BiometricPrompt} (or legacy + * {@code FingerprintManager}), and on success runs the supplied + * {@link CipherWork} against the authenticated cipher. + */ + private void runAuthenticatedCipher(final String reason, final String account, + final int mode, final AsyncResource result, + final CipherWork work) { + SecretKey secret = getSecretKey(); + if (secret == null) { + if (mode == Cipher.ENCRYPT_MODE) { + if (!createKey()) { + failResult(result, BiometricError.UNKNOWN, "Failed to create keystore key"); + return; + } + } else { + if (keyRevoked) { + failResult(result, BiometricError.KEY_REVOKED, "Key has been invalidated"); + } else { + failResult(result, BiometricError.UNKNOWN, "No keystore key for account"); + } + return; + } + } + if (!initCipher(mode, account)) { + if (mode == Cipher.ENCRYPT_MODE) { + if (!createKey() || !initCipher(mode, account)) { + failResult(result, BiometricError.UNKNOWN, "Failed to initialise cipher"); + return; + } + } else { + failResult(result, BiometricError.KEY_REVOKED, + "Failed to initialise cipher; key must have been revoked"); + return; + } + } + if (Build.VERSION.SDK_INT >= 29) { + promptBiometric29(reason, mode, account, result, work); + } else { + promptBiometricLegacy(mode, account, result, work); + } + } + + private void promptBiometric29(final String reason, final int mode, final String account, + final AsyncResource result, final CipherWork work) { + AndroidBiometrics.runOnUi(new Runnable() { + @Override + public void run() { + if (cancellationSignal != null) { + cancellationSignal.cancel(); + } + final CancellationSignal cs = new CancellationSignal(); + cancellationSignal = cs; + BiometricsApi29.authenticateWithCipher( + AndroidNativeUtil.getActivity(), + reason == null ? "Authenticate" : reason, + null, null, "Cancel", + cipher, + cs, + new BiometricsApi29.CipherAuthCallback() { + @Override + public void onSuccess(Object authedCipher) { + cs.cancel(); + runCipherWork((Cipher) authedCipher, work, result, mode, account); + } + + @Override + public void onError(int errorCode, String errString) { + failResult(result, + AndroidBiometrics.mapBiometricPromptError(errorCode), + errString == null ? "" : errString); + } + }); + } + }); + } + + private void promptBiometricLegacy(final int mode, final String account, + final AsyncResource result, final CipherWork work) { + AndroidBiometrics.runOnUi(new Runnable() { + @Override + public void run() { + FingerprintManager fpm = (FingerprintManager) + AndroidNativeUtil.getActivity() + .getSystemService(Activity.FINGERPRINT_SERVICE); + if (fpm == null) { + failResult(result, BiometricError.NOT_AVAILABLE, "No fingerprint hardware"); + return; + } + if (cancellationSignal != null) { + cancellationSignal.cancel(); + } + final CancellationSignal cs = new CancellationSignal(); + cancellationSignal = cs; + FingerprintManager.CryptoObject crypto = + new FingerprintManager.CryptoObject(cipher); + fpm.authenticate(crypto, cs, 0, new FingerprintManager.AuthenticationCallback() { + int failures; + + @Override + public void onAuthenticationError(int errorCode, CharSequence errString) { + failResult(result, AndroidBiometrics.mapFingerprintManagerError(errorCode), + errString == null ? "" : errString.toString()); + } + + @Override + public void onAuthenticationSucceeded(FingerprintManager.AuthenticationResult r) { + cs.cancel(); + runCipherWork(r.getCryptoObject().getCipher(), work, result, mode, account); + } + + @Override + public void onAuthenticationFailed() { + if (failures++ > 5) { + cs.cancel(); + failResult(result, BiometricError.AUTHENTICATION_FAILED, + "Authentication failed"); + } + } + }, null); + } + }); + } + + private void runCipherWork(Cipher authedCipher, CipherWork work, + final AsyncResource result, int mode, String account) { + try { + V v = work.run(authedCipher); + succeedResult(result, v); + } catch (Throwable t) { + // Samsung 8.0.0 quirk: the cipher passes init but doFinal fails + // with a key-invalidated error. Delete the key and let the caller + // retry the entire operation. + // https://issuetracker.google.com/u/0/issues/65578763 + removePermanentlyInvalidatedKey(); + cipher = null; + failResult(result, BiometricError.KEY_REVOKED, + "Cipher operation failed; key invalidated: " + t.getMessage()); + } + } + + private void succeedResult(final AsyncResource result, final V value) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (!result.isDone()) { + result.complete(value); + } + } + }); + } + + private static void failResult(final AsyncResource result, + final BiometricError err, final String msg) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (!result.isDone()) { + result.error(new BiometricException(err, msg)); + } + } + }); + } + + // --- Keystore / cipher helpers (faithful port of the cn1lib idioms) ----- + + private KeyStore keyStore() { + if (keyStore == null) { + try { + keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE); + keyStore = KeyStore.getInstance(ANDROID_KEY_STORE); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("KeyGenerator init failed", e); + } catch (NoSuchProviderException e) { + throw new RuntimeException("KeyGenerator init failed", e); + } catch (KeyStoreException e) { + throw new RuntimeException("KeyStore init failed", e); + } + } + return keyStore; + } + + private boolean createKey() { + try { + keyStore().load(null); + KeyGenParameterSpec.Builder b = new KeyGenParameterSpec.Builder(KEY_ID, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_CBC) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7); + // Skip setUserAuthenticationRequired on API 33+ per + // FingerprintScanner #8; the BiometricPrompt still authenticates + // the user, but the keystore no longer ties the key lifetime to + // biometric enrolment (which caused recovery failures). + if (Build.VERSION.SDK_INT < 33) { + b.setUserAuthenticationRequired(true); + } + keyGenerator.init(b.build()); + keyGenerator.generateKey(); + return true; + } catch (NoSuchAlgorithmException e) { + Log.e(e); + } catch (InvalidAlgorithmParameterException e) { + Log.e(e); + } catch (CertificateException e) { + Log.e(e); + } catch (IOException e) { + Log.e(e); + } + return false; + } + + private SecretKey getSecretKey() { + keyRevoked = false; + try { + keyStore().load(null); + return (SecretKey) keyStore.getKey(KEY_ID, null); + } catch (UnrecoverableKeyException e) { + keyRevoked = true; + } catch (UnrecoverableEntryException e) { + keyRevoked = true; + } catch (KeyStoreException e) { + Log.e(e); + } catch (NoSuchAlgorithmException e) { + Log.e(e); + } catch (CertificateException e) { + Log.e(e); + } catch (IOException e) { + Log.e(e); + } + return null; + } + + private Cipher cipher() { + if (cipher == null) { + try { + cipher = Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + + "/" + KeyProperties.BLOCK_MODE_CBC + + "/" + KeyProperties.ENCRYPTION_PADDING_PKCS7); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Cipher init failed", e); + } catch (NoSuchPaddingException e) { + throw new RuntimeException("Cipher init failed", e); + } + } + return cipher; + } + + private boolean initCipher(int mode, String account) { + try { + SecretKey key = getSecretKey(); + if (key == null) { + return false; + } + if (mode == Cipher.ENCRYPT_MODE) { + cipher().init(mode, key); + } else { + SharedPreferences sp = AndroidNativeUtil.getActivity() + .getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + byte[] iv = Base64.decode(sp.getString("iv_" + account, ""), Base64.DEFAULT); + cipher().init(mode, key, new IvParameterSpec(iv)); + } + return true; + } catch (KeyPermanentlyInvalidatedException e) { + removePermanentlyInvalidatedKey(); + return false; + } catch (InvalidKeyException e) { + Log.e(e); + return false; + } catch (InvalidAlgorithmParameterException e) { + Log.e(e); + return false; + } + } + + private void removePermanentlyInvalidatedKey() { + try { + keyStore().deleteEntry(KEY_ID); + cipher = null; + } catch (KeyStoreException e) { + Log.e(e); + } + } + + /** Lambda-stand-in for Java 5 source level: cipher op that may throw. */ + private interface CipherWork { + V run(Cipher c) throws Exception; + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/BiometricsApi29.java b/Ports/Android/src/com/codename1/impl/android/BiometricsApi29.java new file mode 100644 index 0000000000..28fa79875b --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/BiometricsApi29.java @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.os.CancellationSignal; +import android.os.Handler; +import android.os.Looper; + +import com.codename1.io.Log; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.concurrent.Executor; + +/** + * Reflection adapter for {@code android.hardware.biometrics.BiometricPrompt} + * (API 28+) and {@code android.hardware.biometrics.BiometricManager} + * (API 29+). The cn1-binaries {@code android.jar} predates API 28, so direct + * symbol references would fail to compile; reflection lets us call these APIs + * at runtime on supported devices without lifting the compile-time SDK + * requirement of the Android port. + * + *

Only invoked from code paths guarded by + * {@code Build.VERSION.SDK_INT >= 29} in {@link AndroidBiometrics} and + * {@link AndroidSecureStorage}; on older devices the reflection class is + * never loaded.

+ */ +final class BiometricsApi29 { + + interface AuthCallback { + void onSuccess(); + + void onError(int errorCode, String errString); + } + + interface CipherAuthCallback { + /** Invoked with the authenticated {@code javax.crypto.Cipher}. */ + void onSuccess(Object cipher); + + void onError(int errorCode, String errString); + } + + private BiometricsApi29() { + } + + /** {@code BiometricManager.canAuthenticate() == BIOMETRIC_SUCCESS}. */ + static boolean canAuthenticate(Activity act) { + try { + Object bm = act.getSystemService("biometric"); + if (bm == null) { + return false; + } + Object result = bm.getClass().getMethod("canAuthenticate").invoke(bm); + return ((Integer) result).intValue() == 0; // BIOMETRIC_SUCCESS == 0 + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + /** Builds + shows a BiometricPrompt for plain authentication (no crypto). */ + static void authenticate(Activity act, String title, String subtitle, + String description, String negative, + CancellationSignal cs, AuthCallback cb) { + try { + Object prompt = buildPrompt(act, title, subtitle, description, negative, cb, null); + Class promptCls = Class.forName("android.hardware.biometrics.BiometricPrompt"); + Class authCbCls = Class.forName("android.hardware.biometrics.BiometricPrompt$AuthenticationCallback"); + Executor exec = mainExecutor(act); + Method authM = promptCls.getMethod("authenticate", CancellationSignal.class, + Executor.class, authCbCls); + authM.invoke(prompt, cs, exec, makeAuthProxy(authCbCls, cb, null)); + } catch (Throwable t) { + Log.e(t); + cb.onError(AndroidBiometrics.BIOMETRIC_ERROR_HW_UNAVAILABLE, + "Failed to invoke BiometricPrompt: " + t.getMessage()); + } + } + + /** + * Builds + shows a BiometricPrompt that wraps a CryptoObject around the + * supplied {@code javax.crypto.Cipher}; on success the same cipher + * (now authenticated) is passed to {@code cb.onSuccess}. + */ + static void authenticateWithCipher(Activity act, String title, String subtitle, + String description, String negative, + Object cipher, CancellationSignal cs, + CipherAuthCallback cb) { + try { + Object prompt = buildPrompt(act, title, subtitle, description, negative, + null, cb); + Class promptCls = Class.forName("android.hardware.biometrics.BiometricPrompt"); + Class cryptoCls = Class.forName("android.hardware.biometrics.BiometricPrompt$CryptoObject"); + Class authCbCls = Class.forName("android.hardware.biometrics.BiometricPrompt$AuthenticationCallback"); + Constructor cryptoCtor = cryptoCls.getConstructor(Class.forName("javax.crypto.Cipher")); + Object crypto = cryptoCtor.newInstance(cipher); + Executor exec = mainExecutor(act); + Method authM = promptCls.getMethod("authenticate", cryptoCls, + CancellationSignal.class, Executor.class, authCbCls); + authM.invoke(prompt, crypto, cs, exec, makeAuthProxy(authCbCls, null, cb)); + } catch (Throwable t) { + Log.e(t); + cb.onError(AndroidBiometrics.BIOMETRIC_ERROR_HW_UNAVAILABLE, + "Failed to invoke BiometricPrompt with cipher: " + t.getMessage()); + } + } + + private static Object buildPrompt(final Activity act, String title, String subtitle, + String description, String negative, + final AuthCallback acb, final CipherAuthCallback ccb) throws Exception { + Class builderCls = Class.forName("android.hardware.biometrics.BiometricPrompt$Builder"); + Object builder = builderCls.getConstructor(Context.class).newInstance(act); + builderCls.getMethod("setTitle", CharSequence.class).invoke(builder, title); + if (subtitle != null) { + builderCls.getMethod("setSubtitle", CharSequence.class).invoke(builder, subtitle); + } + if (description != null) { + builderCls.getMethod("setDescription", CharSequence.class).invoke(builder, description); + } + Executor exec = mainExecutor(act); + DialogInterface.OnClickListener neg = new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface d, int which) { + if (acb != null) { + acb.onError(AndroidBiometrics.BIOMETRIC_ERROR_USER_CANCELED, "Cancelled"); + } else if (ccb != null) { + ccb.onError(AndroidBiometrics.BIOMETRIC_ERROR_USER_CANCELED, "Cancelled"); + } + } + }; + builderCls.getMethod("setNegativeButton", CharSequence.class, Executor.class, + DialogInterface.OnClickListener.class).invoke(builder, negative, exec, neg); + return builderCls.getMethod("build").invoke(builder); + } + + private static Object makeAuthProxy(Class authCbCls, final AuthCallback acb, + final CipherAuthCallback ccb) { + InvocationHandler handler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + String name = method.getName(); + try { + if ("onAuthenticationSucceeded".equals(name)) { + if (ccb != null) { + // BiometricPrompt$AuthenticationResult.getCryptoObject().getCipher() + Object ar = args[0]; + Object crypto = ar.getClass().getMethod("getCryptoObject").invoke(ar); + Object cipher = crypto.getClass().getMethod("getCipher").invoke(crypto); + ccb.onSuccess(cipher); + } else if (acb != null) { + acb.onSuccess(); + } + } else if ("onAuthenticationError".equals(name)) { + int code = ((Integer) args[0]).intValue(); + String msg = args[1] == null ? "" : args[1].toString(); + if (ccb != null) { + ccb.onError(code, msg); + } else if (acb != null) { + acb.onError(code, msg); + } + } + // onAuthenticationFailed / Help: ignore (soft-failure stream). + } catch (Throwable t) { + Log.e(t); + } + return null; + } + }; + return Proxy.newProxyInstance(authCbCls.getClassLoader(), + new Class[]{authCbCls}, handler); + } + + /** Activity.getMainExecutor() is API 28+; fall back to a Handler-backed executor. */ + static Executor mainExecutor(Context ctx) { + try { + return (Executor) Context.class.getMethod("getMainExecutor").invoke(ctx); + } catch (Throwable t) { + final Handler h = new Handler(Looper.getMainLooper()); + return new Executor() { + @Override + public void execute(Runnable r) { + h.post(r); + } + }; + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEBiometrics.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEBiometrics.java new file mode 100644 index 0000000000..64e18faea7 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEBiometrics.java @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import com.codename1.security.AuthenticationOptions; +import com.codename1.security.BiometricError; +import com.codename1.security.BiometricException; +import com.codename1.security.BiometricType; +import com.codename1.security.Biometrics; +import com.codename1.ui.CN; +import com.codename1.util.AsyncResource; + +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; +import javax.swing.WindowConstants; +import java.awt.BorderLayout; +import java.awt.FlowLayout; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.ArrayList; +import java.util.List; + +/** + * Simulator backing for {@link Biometrics}. State is mutated by the + * {@code Simulate -> Biometric Simulation} submenu in {@code JavaSEPort}; + * each {@link #authenticate(AuthenticationOptions)} call pops a small modal + * mimicking the real OS prompt so the developer can see the trigger fire and + * step through outcomes interactively. + */ +public final class JavaSEBiometrics extends Biometrics { + + /** Simulated outcome of the next (or current) authentication. */ + public enum SimOutcome { + SUCCEED, + FAIL, + CANCEL, + LOCKED_OUT, + PERMANENTLY_LOCKED_OUT, + NOT_ENROLLED, + PASSCODE_NOT_SET + } + + // Mutated by JavaSEPort's menu items. Volatile because the menu runs on + // the AWT EDT and the API may be called from any CN1 thread. + static volatile boolean simAvailable = false; + static volatile boolean simFaceEnrolled = false; + static volatile boolean simTouchEnrolled = false; + static volatile boolean simIrisEnrolled = false; + static volatile SimOutcome nextOutcome = SimOutcome.SUCCEED; + + private volatile AsyncResource pending; + private volatile JDialog pendingDialog; + + JavaSEBiometrics() { + } + + @Override + public boolean isSupported() { + return simAvailable; + } + + @Override + public boolean canAuthenticate() { + return simAvailable + && (simFaceEnrolled || simTouchEnrolled || simIrisEnrolled); + } + + @Override + public List getAvailableBiometrics() { + List out = new ArrayList(); + if (!simAvailable) { + return out; + } + if (simTouchEnrolled) { + out.add(BiometricType.FINGERPRINT); + } + if (simFaceEnrolled) { + out.add(BiometricType.FACE); + } + if (simIrisEnrolled) { + out.add(BiometricType.IRIS); + } + return out; + } + + @Override + public AsyncResource authenticate(AuthenticationOptions opts) { + final AsyncResource result = new AsyncResource(); + if (!simAvailable) { + result.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "Simulator: Biometric Simulation -> Available is unchecked")); + return result; + } + if (!simFaceEnrolled && !simTouchEnrolled && !simIrisEnrolled) { + result.error(new BiometricException(BiometricError.NOT_ENROLLED, + "Simulator: no biometric modality enrolled")); + return result; + } + pending = result; + + final String reason = opts == null || opts.getReason() == null + ? "Authenticate" + : opts.getReason(); + final String title = opts == null || opts.getTitle() == null + ? "Biometric Authentication" + : opts.getTitle(); + + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + showPromptDialog(title, reason, result); + } + }); + return result; + } + + private void showPromptDialog(String title, String reason, final AsyncResource result) { + final JDialog dlg = new JDialog((java.awt.Frame) null, title, true); + dlg.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE); + JPanel content = new JPanel(new BorderLayout(12, 12)); + content.setBorder(javax.swing.BorderFactory.createEmptyBorder(16, 20, 16, 20)); + content.add(new JLabel("" + escapeHtml(reason) + + "

Simulator: next outcome = " + nextOutcome.name() + ""), + BorderLayout.CENTER); + + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + JButton cancel = new JButton("Cancel"); + JButton authenticate = new JButton("Authenticate"); + buttons.add(cancel); + buttons.add(authenticate); + content.add(buttons, BorderLayout.SOUTH); + + cancel.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent ae) { + dlg.dispose(); + completePending(result, SimOutcome.CANCEL); + } + }); + authenticate.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent ae) { + dlg.dispose(); + completePending(result, nextOutcome); + } + }); + dlg.getContentPane().add(content); + dlg.pack(); + dlg.setLocationRelativeTo(null); + pendingDialog = dlg; + dlg.setVisible(true); + } + + private void completePending(final AsyncResource result, final SimOutcome outcome) { + pendingDialog = null; + if (pending != result) { + // Already cancelled by another caller. + return; + } + pending = null; + // Hop to the EDT (CN1's EDT, not Swing's) before completing so the + // callback runs on the same thread the app expects. + CN.callSerially(new Runnable() { + @Override + public void run() { + if (result.isDone()) { + return; + } + switch (outcome) { + case SUCCEED: + result.complete(Boolean.TRUE); + break; + case FAIL: + result.error(new BiometricException(BiometricError.AUTHENTICATION_FAILED, + "Simulator: simulated authentication failure")); + break; + case CANCEL: + result.error(new BiometricException(BiometricError.USER_CANCELED, + "Simulator: user cancelled")); + break; + case LOCKED_OUT: + result.error(new BiometricException(BiometricError.LOCKED_OUT, + "Simulator: locked out")); + break; + case PERMANENTLY_LOCKED_OUT: + result.error(new BiometricException(BiometricError.PERMANENTLY_LOCKED_OUT, + "Simulator: permanently locked out")); + break; + case NOT_ENROLLED: + result.error(new BiometricException(BiometricError.NOT_ENROLLED, + "Simulator: no biometric enrolled")); + break; + case PASSCODE_NOT_SET: + result.error(new BiometricException(BiometricError.PASSCODE_NOT_SET, + "Simulator: passcode not set")); + break; + default: + result.error(new BiometricException(BiometricError.UNKNOWN)); + } + } + }); + } + + @Override + public boolean stopAuthentication() { + final AsyncResource r = pending; + if (r == null) { + return false; + } + final JDialog dlg = pendingDialog; + if (dlg != null) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + dlg.dispose(); + } + }); + } + completePending(r, SimOutcome.CANCEL); + return true; + } + + private static String escapeHtml(String s) { + if (s == null) { + return ""; + } + StringBuilder sb = new StringBuilder(s.length() + 8); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '<': + sb.append("<"); + break; + case '>': + sb.append(">"); + break; + case '&': + sb.append("&"); + break; + default: + sb.append(c); + } + } + return sb.toString(); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index f4267c7eb7..eaac4b1d38 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -96,6 +96,8 @@ import com.codename1.l10n.L10NManager; import com.codename1.location.Location; import com.codename1.location.LocationManager; +import com.codename1.security.Biometrics; +import com.codename1.security.SecureStorage; import com.codename1.media.AbstractMedia; import com.codename1.media.AudioBuffer; import com.codename1.media.Media; @@ -4556,6 +4558,87 @@ public void actionPerformed(ActionEvent ae) { }); simulateMenu.add(pushSim); + JMenu biometricMenu = new JMenu("Biometric Simulation"); + + final JCheckBoxMenuItem bioAvailable = new JCheckBoxMenuItem("Hardware Available", + pref.getBoolean("BiometricSim.available", false)); + JavaSEBiometrics.simAvailable = bioAvailable.isSelected(); + bioAvailable.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent ae) { + JavaSEBiometrics.simAvailable = bioAvailable.isSelected(); + pref.putBoolean("BiometricSim.available", bioAvailable.isSelected()); + } + }); + biometricMenu.add(bioAvailable); + + biometricMenu.addSeparator(); + + final JCheckBoxMenuItem bioFace = new JCheckBoxMenuItem("Face ID Enrolled", + pref.getBoolean("BiometricSim.face", false)); + JavaSEBiometrics.simFaceEnrolled = bioFace.isSelected(); + bioFace.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent ae) { + JavaSEBiometrics.simFaceEnrolled = bioFace.isSelected(); + pref.putBoolean("BiometricSim.face", bioFace.isSelected()); + } + }); + biometricMenu.add(bioFace); + + final JCheckBoxMenuItem bioTouch = new JCheckBoxMenuItem("Touch ID Enrolled", + pref.getBoolean("BiometricSim.touch", false)); + JavaSEBiometrics.simTouchEnrolled = bioTouch.isSelected(); + bioTouch.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent ae) { + JavaSEBiometrics.simTouchEnrolled = bioTouch.isSelected(); + pref.putBoolean("BiometricSim.touch", bioTouch.isSelected()); + } + }); + biometricMenu.add(bioTouch); + + final JCheckBoxMenuItem bioIris = new JCheckBoxMenuItem("Iris Enrolled", + pref.getBoolean("BiometricSim.iris", false)); + JavaSEBiometrics.simIrisEnrolled = bioIris.isSelected(); + bioIris.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent ae) { + JavaSEBiometrics.simIrisEnrolled = bioIris.isSelected(); + pref.putBoolean("BiometricSim.iris", bioIris.isSelected()); + } + }); + biometricMenu.add(bioIris); + + biometricMenu.addSeparator(); + + JMenu outcomeMenu = new JMenu("Next authenticate() Outcome"); + ButtonGroup outcomeGroup = new ButtonGroup(); + String savedOutcome = pref.get("BiometricSim.outcome", JavaSEBiometrics.SimOutcome.SUCCEED.name()); + try { + JavaSEBiometrics.nextOutcome = JavaSEBiometrics.SimOutcome.valueOf(savedOutcome); + } catch (IllegalArgumentException ex) { + JavaSEBiometrics.nextOutcome = JavaSEBiometrics.SimOutcome.SUCCEED; + } + JavaSEBiometrics.SimOutcome[] outcomes = JavaSEBiometrics.SimOutcome.values(); + for (int i = 0; i < outcomes.length; i++) { + final JavaSEBiometrics.SimOutcome outcome = outcomes[i]; + final JRadioButtonMenuItem item = new JRadioButtonMenuItem(outcome.name(), + outcome == JavaSEBiometrics.nextOutcome); + outcomeGroup.add(item); + item.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent ae) { + JavaSEBiometrics.nextOutcome = outcome; + pref.put("BiometricSim.outcome", outcome.name()); + } + }); + outcomeMenu.add(item); + } + biometricMenu.add(outcomeMenu); + + simulateMenu.add(biometricMenu); + // Mirrors cn1FireStatusBarTap in CodenameOne_GLViewController.m, which // synthesizes a tap inside CN1's StatusBar component (the bar at the // top of Toolbar created by Toolbar.initTitleBarStatus). The native @@ -11726,6 +11809,25 @@ public String[] getPlatformOverrides() { return platformOverrides; } + private JavaSEBiometrics biometrics; + private JavaSESecureStorage secureStorage; + + @Override + public Biometrics getBiometrics() { + if (biometrics == null) { + biometrics = new JavaSEBiometrics(); + } + return biometrics; + } + + @Override + public SecureStorage getSecureStorage() { + if (secureStorage == null) { + secureStorage = new JavaSESecureStorage((JavaSEBiometrics) getBiometrics()); + } + return secureStorage; + } + public LocationManager getLocationManager() { if(!checkForPermission("android.permission.ACCESS_FINE_LOCATION", "This is required to get the location")){ return null; diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSESecureStorage.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSESecureStorage.java new file mode 100644 index 0000000000..4c175eca4a --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSESecureStorage.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import com.codename1.security.AuthenticationOptions; +import com.codename1.security.BiometricError; +import com.codename1.security.BiometricException; +import com.codename1.security.Biometrics; +import com.codename1.security.SecureStorage; +import com.codename1.util.AsyncResource; +import com.codename1.util.AsyncResult; + +/** + * Simulator backing for {@link SecureStorage}. Reads gate behind the + * {@link Biometrics} prompt (which the simulator menu controls); writes + * persist to {@code java.util.prefs} so values survive a JVM restart. + */ +public final class JavaSESecureStorage extends SecureStorage { + + private static final String NODE = "com.codename1.simulator.secureStorage"; + private final java.util.prefs.Preferences prefs; + private final JavaSEBiometrics biometrics; + + JavaSESecureStorage(JavaSEBiometrics biometrics) { + this.biometrics = biometrics; + this.prefs = java.util.prefs.Preferences.userRoot().node(NODE); + } + + @Override + public AsyncResource get(final String reason, final String account) { + final AsyncResource result = new AsyncResource(); + final String stored = prefs.get(account, null); + if (stored == null) { + result.error(new BiometricException(BiometricError.UNKNOWN, + "No secure storage entry for account: " + account)); + return result; + } + AsyncResource auth = biometrics.authenticate( + new AuthenticationOptions().setReason(reason)); + auth.onResult(new AsyncResult() { + @Override + public void onReady(Boolean ok, Throwable err) { + if (err != null) { + result.error(err); + } else { + result.complete(stored); + } + } + }); + return result; + } + + @Override + public AsyncResource set(final String reason, final String account, final String value) { + final AsyncResource result = new AsyncResource(); + if (!biometrics.canAuthenticate()) { + result.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "Simulator: biometrics not enabled for secure storage write")); + return result; + } + prefs.put(account, value); + result.complete(Boolean.TRUE); + return result; + } + + @Override + public AsyncResource remove(String reason, String account) { + AsyncResource result = new AsyncResource(); + prefs.remove(account); + result.complete(Boolean.TRUE); + return result; + } + + @Override + public void setKeychainAccessGroup(String group) { + // No-op in the simulator. + } +} diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 73d88cb99e..edc13dd783 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -42,6 +42,8 @@ #import #import "CodenameOne_GLViewController.h" #import +#import +#import #import "NetworkConnectionImpl.h" #include "com_codename1_impl_ios_IOSImplementation.h" #include "com_codename1_ui_Display.h" @@ -10886,3 +10888,269 @@ void com_codename1_impl_ios_IOSNative_announceForAccessibility___java_lang_Strin UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, nsText); POOL_END(); } + +// ============================================================================ +// Biometrics + SecureStorage natives (LocalAuthentication + Security framework) +// ============================================================================ +// +// The static LAContext is held across calls so it can be invalidated mid-prompt +// by stopBiometricAuthentication(). Memory management is manual because the +// iOS port builds with CLANG_ENABLE_OBJC_ARC=NO (see ARC memory in plan). + +static LAContext *cn1_biometricsContext = nil; +static NSString *cn1_keychainAccessGroup = nil; + +static LAContext *cn1_ensureContext(void) { + if (cn1_biometricsContext == nil) { + cn1_biometricsContext = [[LAContext alloc] init]; + } + return cn1_biometricsContext; +} + +static void cn1_resetContext(void) { + if (cn1_biometricsContext != nil) { + [cn1_biometricsContext release]; + cn1_biometricsContext = nil; + } +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isBiometricsSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + if (NSClassFromString(@"LAContext") == NULL) { + return JAVA_FALSE; + } + NSError *error = nil; + LAContext *ctx = cn1_ensureContext(); + BOOL ok = [ctx canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics error:&error]; + return ok ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_canAuthenticateBiometric__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return com_codename1_impl_ios_IOSNative_isBiometricsSupported__(CN1_THREAD_STATE_PASS_ARG me); +} + +JAVA_INT com_codename1_impl_ios_IOSNative_getAvailableBiometricTypes__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + if (NSClassFromString(@"LAContext") == NULL) { + return 0; + } + NSError *error = nil; + LAContext *ctx = cn1_ensureContext(); + if (![ctx canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics error:&error]) { + return 0; + } + JAVA_INT mask = 0; + if (@available(iOS 11.0, *)) { + if (ctx.biometryType == LABiometryTypeTouchID) { + mask |= 1; + } else if (ctx.biometryType == LABiometryTypeFaceID) { + mask |= 2; + } + } else { + // Pre-iOS 11: only Touch ID exists. + mask |= 1; + } + return mask; +} + +void com_codename1_impl_ios_IOSNative_authenticateBiometric___int_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT reason) { + POOL_BEGIN(); + NSString *nsReason = (reason == JAVA_NULL) ? @"Authenticate" : toNSString(CN1_THREAD_STATE_PASS_ARG reason); + // Each authenticate call gets a fresh context so a prior stopAuthentication + // can't bleed cancellation into the next request. + cn1_resetContext(); + LAContext *ctx = cn1_ensureContext(); + dispatch_async(dispatch_get_main_queue(), ^{ + [ctx evaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics + localizedReason:nsReason + reply:^(BOOL success, NSError *err) { + if (success) { + com_codename1_impl_ios_IOSBiometrics_nativeAuthSuccess___int(getThreadLocalData(), requestId); + } else { + int code = (int)err.code; + NSString *msg = err.localizedDescription ? err.localizedDescription : @""; + JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), msg); + com_codename1_impl_ios_IOSBiometrics_nativeAuthError___int_int_java_lang_String(getThreadLocalData(), requestId, code, jmsg); + } + }]; + }); + POOL_END(); +} + +void com_codename1_impl_ios_IOSNative_stopBiometricAuthentication__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + if (cn1_biometricsContext != nil) { + if (@available(iOS 9.0, *)) { + [cn1_biometricsContext invalidate]; + } + cn1_resetContext(); + } +} + +void com_codename1_impl_ios_IOSNative_setSecureStorageAccessGroup___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT accessGroup) { + if (cn1_keychainAccessGroup != nil) { + [cn1_keychainAccessGroup release]; + cn1_keychainAccessGroup = nil; + } + if (accessGroup != JAVA_NULL) { + NSString *ag = toNSString(CN1_THREAD_STATE_PASS_ARG accessGroup); + if (ag != nil && [ag length] > 0) { + cn1_keychainAccessGroup = [ag retain]; + } + } +} + +static NSString *cn1_getAppName(CN1_THREAD_STATE_SINGLE_ARG) { + JAVA_OBJECT d = com_codename1_ui_Display_getInstance___R_com_codename1_ui_Display(CN1_THREAD_STATE_PASS_SINGLE_ARG); + JAVA_OBJECT key = fromNSString(CN1_THREAD_STATE_PASS_ARG @"AppName"); + JAVA_OBJECT def = fromNSString(CN1_THREAD_STATE_PASS_ARG @"CodenameOneApp"); + JAVA_OBJECT res = com_codename1_ui_Display_getProperty___java_lang_String_java_lang_String_R_java_lang_String(CN1_THREAD_STATE_PASS_ARG d, key, def); + return toNSString(CN1_THREAD_STATE_PASS_ARG res); +} + +void com_codename1_impl_ios_IOSNative_secureStorageGet___int_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT reason, JAVA_OBJECT account) { + POOL_BEGIN(); + NSString *nsReason = (reason == JAVA_NULL) ? @"Authenticate" : toNSString(CN1_THREAD_STATE_PASS_ARG reason); + NSString *nsAccount = toNSString(CN1_THREAD_STATE_PASS_ARG account); + NSString *appName = cn1_getAppName(CN1_THREAD_STATE_PASS_SINGLE_ARG); + NSString *accessGroup = cn1_keychainAccessGroup; + [nsReason retain]; + [nsAccount retain]; + [appName retain]; + if (accessGroup != nil) { + [accessGroup retain]; + } + dispatch_async(dispatch_get_main_queue(), ^{ + NSMutableDictionary *q = [NSMutableDictionary dictionary]; + [q setObject:(__bridge id)kSecClassGenericPassword forKey:(__bridge id)kSecClass]; + [q setObject:@YES forKey:(__bridge id)kSecReturnData]; + [q setObject:(__bridge id)kSecMatchLimitOne forKey:(__bridge id)kSecMatchLimit]; + [q setObject:nsAccount forKey:(__bridge id)kSecAttrAccount]; + [q setObject:appName forKey:(__bridge id)kSecAttrService]; + [q setObject:nsReason forKey:(__bridge id)kSecUseOperationPrompt]; + if (accessGroup != nil) { + [q setObject:accessGroup forKey:(__bridge id)kSecAttrAccessGroup]; + } + CFTypeRef dataRef = NULL; + OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)q, &dataRef); + if (status == errSecSuccess) { + NSData *d = (__bridge NSData *)dataRef; + NSString *value = [[[NSString alloc] initWithData:d encoding:NSUTF8StringEncoding] autorelease]; + JAVA_OBJECT jv = fromNSString(getThreadLocalData(), value); + com_codename1_impl_ios_IOSSecureStorage_nativeStorageStringResult___int_java_lang_String(getThreadLocalData(), requestId, jv); + } else { + JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), [NSString stringWithFormat:@"OSStatus %d", (int)status]); + com_codename1_impl_ios_IOSSecureStorage_nativeStorageError___int_int_java_lang_String(getThreadLocalData(), requestId, (int)status, jmsg); + } + [nsReason release]; + [nsAccount release]; + [appName release]; + if (accessGroup != nil) { + [accessGroup release]; + } + }); + POOL_END(); +} + +static void cn1_secureStorageUpdate(int requestId, NSString *nsReason, NSString *nsAccount, NSString *nsValue, NSString *appName, NSString *accessGroup) { + NSMutableDictionary *q = [NSMutableDictionary dictionary]; + [q setObject:(__bridge id)kSecClassGenericPassword forKey:(__bridge id)kSecClass]; + [q setObject:nsAccount forKey:(__bridge id)kSecAttrAccount]; + [q setObject:appName forKey:(__bridge id)kSecAttrService]; + [q setObject:nsReason forKey:(__bridge id)kSecUseOperationPrompt]; + if (accessGroup != nil) { + [q setObject:accessGroup forKey:(__bridge id)kSecAttrAccessGroup]; + } + NSMutableDictionary *ch = [NSMutableDictionary dictionary]; + [ch setObject:[nsValue dataUsingEncoding:NSUTF8StringEncoding] forKey:(__bridge id)kSecValueData]; + OSStatus status = SecItemUpdate((__bridge CFDictionaryRef)q, (__bridge CFDictionaryRef)ch); + if (status == errSecSuccess) { + com_codename1_impl_ios_IOSSecureStorage_nativeStorageBooleanResult___int_boolean(getThreadLocalData(), requestId, JAVA_TRUE); + } else { + JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), [NSString stringWithFormat:@"OSStatus %d", (int)status]); + com_codename1_impl_ios_IOSSecureStorage_nativeStorageError___int_int_java_lang_String(getThreadLocalData(), requestId, (int)status, jmsg); + } +} + +void com_codename1_impl_ios_IOSNative_secureStorageSet___int_java_lang_String_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT reason, JAVA_OBJECT account, JAVA_OBJECT value) { + POOL_BEGIN(); + NSString *nsReason = (reason == JAVA_NULL) ? @"Authenticate" : toNSString(CN1_THREAD_STATE_PASS_ARG reason); + NSString *nsAccount = toNSString(CN1_THREAD_STATE_PASS_ARG account); + NSString *nsValue = (value == JAVA_NULL) ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG value); + NSString *appName = cn1_getAppName(CN1_THREAD_STATE_PASS_SINGLE_ARG); + NSString *accessGroup = cn1_keychainAccessGroup; + [nsReason retain]; + [nsAccount retain]; + [nsValue retain]; + [appName retain]; + if (accessGroup != nil) { + [accessGroup retain]; + } + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + SecAccessControlRef sacRef = SecAccessControlCreateWithFlags(kCFAllocatorDefault, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + kSecAccessControlTouchIDCurrentSet, + nil); + NSMutableDictionary *d = [NSMutableDictionary dictionary]; + [d setObject:(__bridge id)kSecClassGenericPassword forKey:(__bridge id)kSecClass]; + [d setObject:nsAccount forKey:(__bridge id)kSecAttrAccount]; + [d setObject:appName forKey:(__bridge id)kSecAttrService]; + [d setObject:[nsValue dataUsingEncoding:NSUTF8StringEncoding] forKey:(__bridge id)kSecValueData]; + [d setObject:(__bridge id)sacRef forKey:(__bridge id)kSecAttrAccessControl]; + [d setObject:nsReason forKey:(__bridge id)kSecUseOperationPrompt]; + if (accessGroup != nil) { + [d setObject:accessGroup forKey:(__bridge id)kSecAttrAccessGroup]; + } + OSStatus status = SecItemAdd((__bridge CFDictionaryRef)d, nil); + if (sacRef != NULL) { + CFRelease(sacRef); + } + if (status == errSecDuplicateItem) { + cn1_secureStorageUpdate((int)requestId, nsReason, nsAccount, nsValue, appName, accessGroup); + } else if (status == errSecSuccess) { + com_codename1_impl_ios_IOSSecureStorage_nativeStorageBooleanResult___int_boolean(getThreadLocalData(), requestId, JAVA_TRUE); + } else { + JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), [NSString stringWithFormat:@"OSStatus %d", (int)status]); + com_codename1_impl_ios_IOSSecureStorage_nativeStorageError___int_int_java_lang_String(getThreadLocalData(), requestId, (int)status, jmsg); + } + [nsReason release]; + [nsAccount release]; + [nsValue release]; + [appName release]; + if (accessGroup != nil) { + [accessGroup release]; + } + }); + POOL_END(); +} + +void com_codename1_impl_ios_IOSNative_secureStorageRemove___int_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT reason, JAVA_OBJECT account) { + POOL_BEGIN(); + NSString *nsAccount = toNSString(CN1_THREAD_STATE_PASS_ARG account); + NSString *appName = cn1_getAppName(CN1_THREAD_STATE_PASS_SINGLE_ARG); + NSString *accessGroup = cn1_keychainAccessGroup; + [nsAccount retain]; + [appName retain]; + if (accessGroup != nil) { + [accessGroup retain]; + } + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSMutableDictionary *d = [NSMutableDictionary dictionary]; + [d setObject:(__bridge id)kSecClassGenericPassword forKey:(__bridge id)kSecClass]; + [d setObject:nsAccount forKey:(__bridge id)kSecAttrAccount]; + [d setObject:appName forKey:(__bridge id)kSecAttrService]; + if (accessGroup != nil) { + [d setObject:accessGroup forKey:(__bridge id)kSecAttrAccessGroup]; + } + OSStatus status = SecItemDelete((__bridge CFDictionaryRef)d); + if (status == errSecSuccess || status == errSecItemNotFound) { + com_codename1_impl_ios_IOSSecureStorage_nativeStorageBooleanResult___int_boolean(getThreadLocalData(), requestId, JAVA_TRUE); + } else { + JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), [NSString stringWithFormat:@"OSStatus %d", (int)status]); + com_codename1_impl_ios_IOSSecureStorage_nativeStorageError___int_int_java_lang_String(getThreadLocalData(), requestId, (int)status, jmsg); + } + [nsAccount release]; + [appName release]; + if (accessGroup != nil) { + [accessGroup release]; + } + }); + POOL_END(); +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java new file mode 100644 index 0000000000..62ed7e0257 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.ios; + +import com.codename1.security.AuthenticationOptions; +import com.codename1.security.BiometricError; +import com.codename1.security.BiometricException; +import com.codename1.security.BiometricType; +import com.codename1.security.Biometrics; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * iOS backing for {@link Biometrics}, wrapping {@code LAContext} from + * {@code LocalAuthentication.framework}. + * + *

The native side dispatches results back via the static + * {@link #nativeAuthSuccess(int)} / {@link #nativeAuthError(int, int, String)} + * methods on this class. To stop the ParparVM dead-code eliminator from + * stripping these (no Java caller exists), the static initializer invokes + * each with no-op values --- the same idiom used by the original + * FingerprintScanner cn1lib.

+ */ +public final class IOSBiometrics extends Biometrics { + + static { + // Prevents the iOS VM optimizer from eliding these callbacks. + nativeAuthSuccess(-1); + nativeAuthError(-1, 0, null); + } + + // Map request id -> pending AsyncResource. Static because the native + // callback path doesn't carry an instance reference. + private static final Map> REQUESTS = + new HashMap>(); + private static int nextRequestId = 1; + + private IOSNative nativeInstance; + + IOSBiometrics(IOSNative nativeInstance) { + this.nativeInstance = nativeInstance; + } + + @Override + public boolean isSupported() { + return nativeInstance.isBiometricsSupported(); + } + + @Override + public boolean canAuthenticate() { + return nativeInstance.canAuthenticateBiometric(); + } + + @Override + public List getAvailableBiometrics() { + int mask = nativeInstance.getAvailableBiometricTypes(); + List out = new ArrayList(); + if ((mask & 1) != 0) { + out.add(BiometricType.FINGERPRINT); + } + if ((mask & 2) != 0) { + out.add(BiometricType.FACE); + } + return out; + } + + @Override + public AsyncResource authenticate(AuthenticationOptions opts) { + AsyncResource r = new AsyncResource(); + if (!nativeInstance.isBiometricsSupported()) { + r.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "Biometrics not available on this device")); + return r; + } + String reason = opts == null || opts.getReason() == null + ? "Authenticate" : opts.getReason(); + int rid; + synchronized (REQUESTS) { + rid = nextRequestId++; + REQUESTS.put(Integer.valueOf(rid), r); + } + nativeInstance.authenticateBiometric(rid, reason); + return r; + } + + @Override + public boolean stopAuthentication() { + synchronized (REQUESTS) { + if (REQUESTS.isEmpty()) { + return false; + } + } + nativeInstance.stopBiometricAuthentication(); + return true; + } + + // ---- Callbacks invoked from native code (do not rename) ---------------- + + /** Called from native when the LAContext.evaluatePolicy succeeds. */ + public static void nativeAuthSuccess(final int requestId) { + final AsyncResource r = take(requestId); + if (r == null) { + return; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (!r.isDone()) { + r.complete(Boolean.TRUE); + } + } + }); + } + + /** Called from native when evaluatePolicy fails or is cancelled. */ + public static void nativeAuthError(final int requestId, final int errorCode, final String msg) { + final AsyncResource r = take(requestId); + if (r == null) { + return; + } + final BiometricError mapped = mapLAError(errorCode); + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (!r.isDone()) { + r.error(new BiometricException(mapped, msg == null ? mapped.name() : msg)); + } + } + }); + } + + private static AsyncResource take(int requestId) { + synchronized (REQUESTS) { + return REQUESTS.remove(Integer.valueOf(requestId)); + } + } + + /** + * Maps numeric {@code LAError} codes (passed across the JNI boundary) to + * our typed enum. The native side uses the canonical + * {@code LAErrorCode} values verbatim. + */ + private static BiometricError mapLAError(int code) { + switch (code) { + case -1: // LAErrorAuthenticationFailed + return BiometricError.AUTHENTICATION_FAILED; + case -2: // LAErrorUserCancel + return BiometricError.USER_CANCELED; + case -3: // LAErrorUserFallback (user chose PIN/passcode) + return BiometricError.USER_CANCELED; + case -4: // LAErrorSystemCancel + return BiometricError.SYSTEM_CANCELED; + case -5: // LAErrorPasscodeNotSet + return BiometricError.PASSCODE_NOT_SET; + case -6: // LAErrorTouchIDNotAvailable / LAErrorBiometryNotAvailable + return BiometricError.NOT_AVAILABLE; + case -7: // LAErrorTouchIDNotEnrolled / LAErrorBiometryNotEnrolled + return BiometricError.NOT_ENROLLED; + case -8: // LAErrorTouchIDLockout / LAErrorBiometryLockout + return BiometricError.LOCKED_OUT; + case -9: // LAErrorAppCancel + return BiometricError.SYSTEM_CANCELED; + case -10: // LAErrorInvalidContext + return BiometricError.UNKNOWN; + default: + return BiometricError.UNKNOWN; + } + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index dcbbab64fb..64cc7ea6e6 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -3331,6 +3331,25 @@ public static void appDidLaunchWithLocation() { } + private IOSBiometrics biometrics; + private IOSSecureStorage secureStorage; + + @Override + public com.codename1.security.Biometrics getBiometrics() { + if (biometrics == null) { + biometrics = new IOSBiometrics(nativeInstance); + } + return biometrics; + } + + @Override + public com.codename1.security.SecureStorage getSecureStorage() { + if (secureStorage == null) { + secureStorage = new IOSSecureStorage(nativeInstance); + } + return secureStorage; + } + public LocationManager getLocationManager() { if (!nativeInstance.checkLocationUsage()) { throw new RuntimeException("Please add the ios.NSLocationUsageDescription or ios.NSLocationAlwaysUsageDescription build hint"); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 2bc0b86a4f..5b294826f2 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -628,6 +628,42 @@ native void nativeSetTransformMutable( native void cancelLocalNotification(String id); + // --- Biometrics (LocalAuthentication.framework) ------------------------- + + /** True when LAContext.canEvaluatePolicy(deviceOwnerAuthenticationWithBiometrics) succeeds. */ + native boolean isBiometricsSupported(); + + /** Same as {@link #isBiometricsSupported()} but also requires at least one biometric to be enrolled. */ + native boolean canAuthenticateBiometric(); + + /** Bitmask: bit 0 = FINGERPRINT (Touch ID), bit 1 = FACE (Face ID). */ + native int getAvailableBiometricTypes(); + + /** + * Triggers an asynchronous biometric prompt. Native code calls back into + * {@code IOSBiometrics.nativeAuthSuccess(int)} or + * {@code IOSBiometrics.nativeAuthError(int, int, String)} with the same + * requestId. + */ + native void authenticateBiometric(int requestId, String reason); + + /** Invalidates the LAContext so the in-flight prompt resolves with LAErrorAppCancel. */ + native void stopBiometricAuthentication(); + + // --- Secure storage (Security.framework keychain) ----------------------- + + /** Sets the kSecAttrAccessGroup applied to subsequent keychain operations. {@code null} clears. */ + native void setSecureStorageAccessGroup(String accessGroup); + + /** Async keychain read; result via IOSSecureStorage.nativeStorageStringResult / nativeStorageError. */ + native void secureStorageGet(int requestId, String reason, String account); + + /** Async keychain write; result via IOSSecureStorage.nativeStorageBooleanResult / nativeStorageError. */ + native void secureStorageSet(int requestId, String reason, String account, String value); + + /** Async keychain delete; result via IOSSecureStorage.nativeStorageBooleanResult / nativeStorageError. */ + native void secureStorageRemove(int requestId, String reason, String account); + native long gausianBlurImage(long peer, float radius); /** diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSecureStorage.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSecureStorage.java new file mode 100644 index 0000000000..116956ec2c --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSecureStorage.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.ios; + +import com.codename1.security.BiometricError; +import com.codename1.security.BiometricException; +import com.codename1.security.SecureStorage; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.util.HashMap; +import java.util.Map; + +/** + * iOS backing for {@link SecureStorage} backed by the system Keychain. Reads + * present a Touch ID / Face ID prompt courtesy of + * {@code kSecUseOperationPrompt}; writes are performed via + * {@code SecItemAdd} without a separate prompt (Apple does not enforce one + * because the user could simply enrol a new fingerprint in Settings to + * bypass it). Set the {@code ios.Fingerprint.addPassword.prompt} display + * property to enable a write-side prompt for parity with the legacy cn1lib. + */ +public final class IOSSecureStorage extends SecureStorage { + + static { + // Prevents the iOS VM optimizer from eliding these callbacks. + nativeStorageStringResult(-1, null); + nativeStorageBooleanResult(-1, false); + nativeStorageError(-1, 0, null); + } + + private static final Map> REQUESTS = + new HashMap>(); + private static int nextRequestId = 1; + + private final IOSNative nativeInstance; + private String accessGroup; + + IOSSecureStorage(IOSNative nativeInstance) { + this.nativeInstance = nativeInstance; + } + + @Override + public void setKeychainAccessGroup(String group) { + this.accessGroup = (group != null && group.length() == 0) ? null : group; + nativeInstance.setSecureStorageAccessGroup(this.accessGroup); + } + + @Override + public AsyncResource get(String reason, String account) { + AsyncResource r = new AsyncResource(); + int rid; + synchronized (REQUESTS) { + rid = nextRequestId++; + REQUESTS.put(Integer.valueOf(rid), r); + } + nativeInstance.secureStorageGet(rid, reason == null ? "Authenticate" : reason, account); + return r; + } + + @Override + public AsyncResource set(String reason, String account, String value) { + AsyncResource r = new AsyncResource(); + int rid; + synchronized (REQUESTS) { + rid = nextRequestId++; + REQUESTS.put(Integer.valueOf(rid), r); + } + nativeInstance.secureStorageSet(rid, reason == null ? "Authenticate" : reason, account, value); + return r; + } + + @Override + public AsyncResource remove(String reason, String account) { + AsyncResource r = new AsyncResource(); + int rid; + synchronized (REQUESTS) { + rid = nextRequestId++; + REQUESTS.put(Integer.valueOf(rid), r); + } + nativeInstance.secureStorageRemove(rid, reason == null ? "Authenticate" : reason, account); + return r; + } + + // ---- Native callbacks (do not rename) ---------------------------------- + + /** Called from native on a successful {@link #get(String, String)}. */ + public static void nativeStorageStringResult(final int requestId, final String value) { + final AsyncResource rr = take(requestId); + if (rr == null) { + return; + } + @SuppressWarnings("unchecked") + final AsyncResource r = (AsyncResource) rr; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (!r.isDone()) { + r.complete(value); + } + } + }); + } + + /** Called from native on a successful {@link #set(String, String, String)} / {@link #remove(String, String)}. */ + public static void nativeStorageBooleanResult(final int requestId, final boolean ok) { + final AsyncResource rr = take(requestId); + if (rr == null) { + return; + } + @SuppressWarnings("unchecked") + final AsyncResource r = (AsyncResource) rr; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (!r.isDone()) { + r.complete(Boolean.valueOf(ok)); + } + } + }); + } + + /** Called from native on any failure. {@code errorCode} is an LAError or OSStatus value. */ + public static void nativeStorageError(final int requestId, final int errorCode, final String msg) { + final AsyncResource r = take(requestId); + if (r == null) { + return; + } + final BiometricError mapped = mapStorageError(errorCode); + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (!r.isDone()) { + r.error(new BiometricException(mapped, msg == null ? mapped.name() : msg)); + } + } + }); + } + + private static AsyncResource take(int requestId) { + synchronized (REQUESTS) { + return REQUESTS.remove(Integer.valueOf(requestId)); + } + } + + private static BiometricError mapStorageError(int code) { + // Reuse the LAError mapping (negative values) and the dominant + // OSStatus values returned by Security.framework. + switch (code) { + case -128: // errSecUserCanceled + return BiometricError.USER_CANCELED; + case -25291: // errSecNotAvailable + return BiometricError.NOT_AVAILABLE; + case -25300: // errSecItemNotFound + return BiometricError.UNKNOWN; + case -25308: // errSecInteractionNotAllowed + return BiometricError.LOCKED_OUT; + case -34018: // errSecMissingEntitlement + return BiometricError.UNKNOWN; + case -2: // LAErrorUserCancel + return BiometricError.USER_CANCELED; + case -7: // LAErrorBiometryNotEnrolled + return BiometricError.NOT_ENROLLED; + case -8: // LAErrorBiometryLockout + return BiometricError.LOCKED_OUT; + default: + return BiometricError.UNKNOWN; + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 29c1e4ea22..e48d0253bd 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -733,6 +733,18 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc // Augment the xpermissions request arg with explicit android.permissions.XXX build hints xPermissions = request.getArg("android.xpermissions", ""); + + // USE_BIOMETRIC is a "normal" permission (no runtime prompt) required by + // com.codename1.security.Biometrics. Inject it unconditionally so apps + // don't have to remember the build hint; if the user already declared + // it in xpermissions we leave their version alone. + if (!xPermissions.contains("android.permission.USE_BIOMETRIC")) { + xPermissions = " \n" + xPermissions; + } + if (!xPermissions.contains("android.permission.USE_FINGERPRINT")) { + xPermissions = " \n" + xPermissions; + } + debug("Adding android permissions..."); for (String xPerm : ANDROID_PERMISSIONS) { String permName = xPerm.substring(xPerm.lastIndexOf(".")+1); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index e25d9fb304..e4a13d9948 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1565,6 +1565,15 @@ public void usesClassMethod(String cls, String method) { } } + // LocalAuthentication is required by com.codename1.security.Biometrics + // (Touch ID / Face ID). Always link it -- the framework is ubiquitous + // (iOS 8+) and tiny, and apps that never call Biometrics pay nothing. + if (addLibs == null || addLibs.length() == 0) { + addLibs = "LocalAuthentication.framework"; + } else if (!addLibs.toLowerCase().contains("localauthentication")) { + addLibs = addLibs + ";LocalAuthentication.framework"; + } + try { if (!runPods && googleAdUnitId != null && googleAdUnitId.length() > 0) { unzip(getResourceAsStream("/google-play-services_lib-ios.zip"), classesDir, buildinRes, buildinRes); From 2e55dcdccac340105ecc8872cf97a6df9e9a136e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 20 May 2026 03:42:46 +0300 Subject: [PATCH 02/12] Convert security/ Javadoc to /// markdown; include IOSBiometrics headers CI surfaced two issues against the initial commit: 1. CodenameOne/ and Ports/CLDC11/ run a validator that rejects classic /** Javadoc -- the codebase has standardised on /// markdown comments. Convert all 8 files in com.codename1.security to that style and translate {@link X} / {@code Y} to [X] / `Y` markdown. 2. IOSNative.m calls com_codename1_impl_ios_IOSBiometrics_* and com_codename1_impl_ios_IOSSecureStorage_* callbacks but did not #include the ParparVM-generated headers, so clang complained about implicit function declarations and the iOS native build, build-ios, build-ios-metal and packaging jobs all failed identically. Add the two #includes alongside the existing com_codename1_impl_ios_IOSImplementation.h include. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../security/AuthenticationOptions.java | 72 ++++---- .../codename1/security/BiometricError.java | 41 ++--- .../security/BiometricException.java | 12 +- .../com/codename1/security/BiometricType.java | 18 +- .../com/codename1/security/Biometrics.java | 154 ++++++++---------- .../com/codename1/security/SecureStorage.java | 83 ++++------ .../codename1/security/StubBiometrics.java | 9 +- .../codename1/security/StubSecureStorage.java | 6 +- Ports/iOSPort/nativeSources/IOSNative.m | 2 + 9 files changed, 173 insertions(+), 224 deletions(-) diff --git a/CodenameOne/src/com/codename1/security/AuthenticationOptions.java b/CodenameOne/src/com/codename1/security/AuthenticationOptions.java index dca001613e..6bc925ec69 100644 --- a/CodenameOne/src/com/codename1/security/AuthenticationOptions.java +++ b/CodenameOne/src/com/codename1/security/AuthenticationOptions.java @@ -22,17 +22,14 @@ */ package com.codename1.security; -/** - * Configures a single call to {@link Biometrics#authenticate(AuthenticationOptions)}. - * Setters return {@code this} for fluent chaining; only {@link #setReason(String)} - * is required (it maps to the iOS {@code localizedReason} and the Android - * BiometricPrompt title fallback). - * - *

Not every option is honored on every platform — the JavaDoc on each - * setter notes the platforms where the value is consulted. Unrecognized - * options are silently ignored, so callers can set the union without - * platform-checking.

- */ +/// Configures a single call to [Biometrics#authenticate(AuthenticationOptions)]. +/// Setters return `this` for fluent chaining; only [#setReason(String)] is +/// required (it maps to the iOS `localizedReason` and the Android +/// `BiometricPrompt` title fallback). +/// +/// Not every option is honoured on every platform -- the docs on each setter +/// note the platforms where the value is consulted. Unrecognised options are +/// silently ignored, so callers can set the union without platform-checking. public final class AuthenticationOptions { private String reason; @@ -52,12 +49,9 @@ public String getReason() { return reason; } - /** - * The user-facing reason for prompting. On iOS this is the - * {@code localizedReason} passed to {@code LAContext.evaluatePolicy}; - * on Android it is used as the BiometricPrompt title if - * {@link #setTitle(String)} is unset. - */ + /// The user-facing reason for prompting. On iOS this is the + /// `localizedReason` passed to `LAContext.evaluatePolicy`; on Android it + /// is used as the `BiometricPrompt` title if [#setTitle(String)] is unset. public AuthenticationOptions setReason(String reason) { this.reason = reason; return this; @@ -67,7 +61,7 @@ public String getTitle() { return title; } - /** Android BiometricPrompt title. Ignored on iOS. */ + /// Android `BiometricPrompt` title. Ignored on iOS. public AuthenticationOptions setTitle(String title) { this.title = title; return this; @@ -77,7 +71,7 @@ public String getSubtitle() { return subtitle; } - /** Android BiometricPrompt subtitle. Ignored on iOS. */ + /// Android `BiometricPrompt` subtitle. Ignored on iOS. public AuthenticationOptions setSubtitle(String subtitle) { this.subtitle = subtitle; return this; @@ -87,7 +81,7 @@ public String getDescription() { return description; } - /** Android BiometricPrompt description body. Ignored on iOS. */ + /// Android `BiometricPrompt` description body. Ignored on iOS. public AuthenticationOptions setDescription(String description) { this.description = description; return this; @@ -97,7 +91,7 @@ public String getNegativeButtonText() { return negativeButtonText; } - /** Android BiometricPrompt negative button label (defaults to "Cancel"). */ + /// Android `BiometricPrompt` negative button label (defaults to "Cancel"). public AuthenticationOptions setNegativeButtonText(String text) { this.negativeButtonText = text == null ? "Cancel" : text; return this; @@ -107,12 +101,10 @@ public boolean isBiometricOnly() { return biometricOnly; } - /** - * If {@code true}, the OS prompt rejects device-credential fallback (PIN - * / pattern / passcode). Honored on both platforms; on Android this maps - * to {@code setAllowedAuthenticators(BIOMETRIC_STRONG)} or its legacy - * equivalent. - */ + /// If `true`, the OS prompt rejects device-credential fallback (PIN / + /// pattern / passcode). Honoured on both platforms; on Android this maps + /// to `setAllowedAuthenticators(BIOMETRIC_STRONG)` or its legacy + /// equivalent. public AuthenticationOptions setBiometricOnly(boolean biometricOnly) { this.biometricOnly = biometricOnly; return this; @@ -122,11 +114,9 @@ public boolean isSensitiveTransaction() { return sensitiveTransaction; } - /** - * Hints that the operation guards a sensitive action and a class-3 - * ("strong") biometric should be required where the platform exposes the - * distinction. Affects Android API 30+; advisory on iOS. - */ + /// Hints that the operation guards a sensitive action and a class-3 + /// ("strong") biometric should be required where the platform exposes the + /// distinction. Affects Android API 30+; advisory on iOS. public AuthenticationOptions setSensitiveTransaction(boolean sensitive) { this.sensitiveTransaction = sensitive; return this; @@ -136,11 +126,9 @@ public boolean isStickyAuth() { return stickyAuth; } - /** - * If {@code true}, the in-progress authentication survives the app being - * backgrounded and resumes on foreground (Android sticky-auth semantics). - * No effect on iOS. - */ + /// If `true`, the in-progress authentication survives the app being + /// backgrounded and resumes on foreground (Android sticky-auth semantics). + /// No effect on iOS. public AuthenticationOptions setStickyAuth(boolean stickyAuth) { this.stickyAuth = stickyAuth; return this; @@ -150,12 +138,10 @@ public boolean isShowDialogOnAndroid() { return showDialogOnAndroid; } - /** - * Controls whether the legacy {@code FingerprintManager} path (Android - * 6-9) draws a Codename One Dialog over the system prompt. The modern - * BiometricPrompt path (Android 10+) provides its own UI and ignores - * this flag. - */ + /// Controls whether the legacy `FingerprintManager` path (Android 6-9) + /// draws a Codename One Dialog over the system prompt. The modern + /// `BiometricPrompt` path (Android 10+) provides its own UI and ignores + /// this flag. public AuthenticationOptions setShowDialogOnAndroid(boolean show) { this.showDialogOnAndroid = show; return this; diff --git a/CodenameOne/src/com/codename1/security/BiometricError.java b/CodenameOne/src/com/codename1/security/BiometricError.java index 4ea75c50ea..d24f69e741 100644 --- a/CodenameOne/src/com/codename1/security/BiometricError.java +++ b/CodenameOne/src/com/codename1/security/BiometricError.java @@ -22,47 +22,42 @@ */ package com.codename1.security; -/** - * Typed error codes returned by {@link Biometrics} and {@link SecureStorage} - * when an asynchronous operation fails. Callers branch on these codes via - * {@link BiometricException#getError()} instead of string-matching error - * messages, which makes localization and recovery logic straightforward. - */ +/// Typed error codes returned by [Biometrics] and [SecureStorage] when an +/// asynchronous operation fails. Callers branch on these codes via +/// [BiometricException#getError()] instead of string-matching error messages, +/// which makes localization and recovery logic straightforward. public enum BiometricError { - /** Biometric hardware is not present, or is disabled by policy. */ + /// Biometric hardware is not present, or is disabled by policy. NOT_AVAILABLE, - /** Hardware is present but the user has not enrolled any biometrics. */ + /// Hardware is present but the user has not enrolled any biometrics. NOT_ENROLLED, - /** Too many failed attempts; biometric prompt is temporarily disabled. */ + /// Too many failed attempts; biometric prompt is temporarily disabled. LOCKED_OUT, - /** - * Too many failed attempts and the user must unlock with their device - * passcode or PIN before biometrics can be used again. - */ + /// Too many failed attempts and the user must unlock with their device + /// passcode or PIN before biometrics can be used again. PERMANENTLY_LOCKED_OUT, - /** The device has no passcode / PIN / pattern configured. */ + /// The device has no passcode / PIN / pattern configured. PASSCODE_NOT_SET, - /** The user explicitly cancelled the prompt. */ + /// The user explicitly cancelled the prompt. USER_CANCELED, - /** The OS cancelled the prompt (app backgrounded, system pre-empted, etc.). */ + /// The OS cancelled the prompt (app backgrounded, system pre-empted, etc.). SYSTEM_CANCELED, - /** Authentication completed but the user was not recognized. */ + /// Authentication completed but the user was not recognized. AUTHENTICATION_FAILED, - /** - * A previously-stored {@link SecureStorage} entry can no longer be decrypted - * because the user enrolled new biometrics or disabled device security since - * the entry was written. Callers must re-prompt and re-write the entry. - */ + /// A previously-stored [SecureStorage] entry can no longer be decrypted + /// because the user enrolled new biometrics or disabled device security + /// since the entry was written. Callers must re-prompt and re-write the + /// entry. KEY_REVOKED, - /** Anything not covered by the more specific codes. */ + /// Anything not covered by the more specific codes. UNKNOWN } diff --git a/CodenameOne/src/com/codename1/security/BiometricException.java b/CodenameOne/src/com/codename1/security/BiometricException.java index b600646b3e..a5761b87ab 100644 --- a/CodenameOne/src/com/codename1/security/BiometricException.java +++ b/CodenameOne/src/com/codename1/security/BiometricException.java @@ -22,12 +22,10 @@ */ package com.codename1.security; -/** - * Thrown via the failure path of an {@code AsyncResource} returned by - * {@link Biometrics} or {@link SecureStorage} when the underlying biometric or - * keychain operation fails. {@link #getError()} returns a typed - * {@link BiometricError} code so callers can react without string-matching. - */ +/// Thrown via the failure path of an `AsyncResource` returned by [Biometrics] +/// or [SecureStorage] when the underlying biometric or keychain operation +/// fails. [#getError()] returns a typed [BiometricError] code so callers can +/// react without string-matching. public class BiometricException extends Exception { private final BiometricError error; @@ -47,7 +45,7 @@ public BiometricException(BiometricError error, String message, Throwable cause) this.error = error == null ? BiometricError.UNKNOWN : error; } - /** Typed error code describing the failure. Never {@code null}. */ + /// Typed error code describing the failure. Never `null`. public BiometricError getError() { return error; } diff --git a/CodenameOne/src/com/codename1/security/BiometricType.java b/CodenameOne/src/com/codename1/security/BiometricType.java index 288a53caa5..bb8c42e3ce 100644 --- a/CodenameOne/src/com/codename1/security/BiometricType.java +++ b/CodenameOne/src/com/codename1/security/BiometricType.java @@ -22,16 +22,14 @@ */ package com.codename1.security; -/** - * Enumerates the biometric authentication modalities that may be available on a - * device. Returned from {@link Biometrics#getAvailableBiometrics()}. - * - *

{@link #FINGERPRINT} and {@link #FACE} are populated on both iOS and - * Android. {@link #IRIS} only appears on Android devices whose hardware - * advertises {@code PackageManager.FEATURE_IRIS}. {@link #STRONG} and - * {@link #WEAK} reflect Android's BiometricManager authenticator class tiers - * (class 3 and class 2) and are only populated on Android API 30+.

- */ +/// Enumerates the biometric authentication modalities that may be available on +/// a device. Returned from `Biometrics.getAvailableBiometrics()`. +/// +/// `FINGERPRINT` and `FACE` are populated on both iOS and Android. `IRIS` only +/// appears on Android devices whose hardware advertises +/// `PackageManager.FEATURE_IRIS`. `STRONG` and `WEAK` reflect Android's +/// `BiometricManager` authenticator class tiers (class 3 and class 2) and are +/// only populated on Android API 30+. public enum BiometricType { FINGERPRINT, FACE, diff --git a/CodenameOne/src/com/codename1/security/Biometrics.java b/CodenameOne/src/com/codename1/security/Biometrics.java index dd745143bf..ca4e05584c 100644 --- a/CodenameOne/src/com/codename1/security/Biometrics.java +++ b/CodenameOne/src/com/codename1/security/Biometrics.java @@ -27,54 +27,49 @@ import java.util.List; -/** - * Entry point for biometric authentication (Touch ID, Face ID, fingerprint, - * Android BiometricPrompt). Obtain the platform implementation via - * {@link #getInstance()}; the returned subclass is owned by the active port. - * - *

A typical unlock flow:

- * - *
{@code
- * Biometrics b = Biometrics.getInstance();
- * if (!b.canAuthenticate()) {
- *     // Fall back to password
- *     return;
- * }
- * b.authenticate("Unlock your account").onResult((success, err) -> {
- *     if (err != null) {
- *         BiometricError code = ((BiometricException) err).getError();
- *         // branch on code
- *     } else {
- *         // success
- *     }
- * });
- * }
- * - *

{@link #authenticate(AuthenticationOptions)} returns an - * {@link AsyncResource} whose failure path completes with a - * {@link BiometricException} so callers can branch on the typed - * {@link BiometricError}.

- * - *

This class is the parallel of Flutter's {@code local_auth} surface. - * On platforms without biometric support (desktop simulator with the - * "Available" simulator menu item unchecked, or older Android devices), - * {@link #canAuthenticate()} returns {@code false} and - * {@link #authenticate(AuthenticationOptions)} completes with - * {@link BiometricError#NOT_AVAILABLE}.

- */ +/// Entry point for biometric authentication (Touch ID, Face ID, fingerprint, +/// Android BiometricPrompt). Obtain the platform implementation via +/// [#getInstance()]; the returned subclass is owned by the active port. +/// +/// A typical unlock flow: +/// +/// ```java +/// Biometrics b = Biometrics.getInstance(); +/// if (!b.canAuthenticate()) { +/// // Fall back to password +/// return; +/// } +/// b.authenticate("Unlock your account").onResult((success, err) -> { +/// if (err != null) { +/// BiometricError code = ((BiometricException) err).getError(); +/// // branch on code +/// } else { +/// // success +/// } +/// }); +/// ``` +/// +/// [#authenticate(AuthenticationOptions)] returns an `AsyncResource` whose +/// failure path completes with a [BiometricException] so callers can branch +/// on the typed [BiometricError]. +/// +/// This class is the parallel of Flutter's `local_auth` surface. On platforms +/// without biometric support (desktop simulator with the "Available" +/// simulator menu item unchecked, or older Android devices), +/// [#canAuthenticate()] returns `false` and +/// [#authenticate(AuthenticationOptions)] completes with +/// [BiometricError#NOT_AVAILABLE]. public abstract class Biometrics { private static Biometrics fallback; - /** Subclasses are constructed by the port; not for application use. */ + /// Subclasses are constructed by the port; not for application use. protected Biometrics() { } - /** - * Returns the platform-specific singleton owned by the current port. - * Ports that do not implement biometrics get a no-op fallback that - * reports {@link BiometricError#NOT_AVAILABLE}. - */ + /// Returns the platform-specific singleton owned by the current port. + /// Ports that do not implement biometrics get a no-op fallback that + /// reports [BiometricError#NOT_AVAILABLE]. public static Biometrics getInstance() { Biometrics b = Display.getInstance().getBiometrics(); if (b != null) { @@ -86,59 +81,52 @@ public static Biometrics getInstance() { return fallback; } - /** - * Returns {@code true} when biometric hardware exists on the device, - * regardless of whether the user has enrolled biometrics. Combine with - * {@link #canAuthenticate()} to gate UI affordances: show the "Use - * biometrics" toggle when {@code isSupported()} is true, but only invoke - * {@link #authenticate(AuthenticationOptions)} when {@code canAuthenticate()} - * is also true. - */ + /// Returns `true` when biometric hardware exists on the device, + /// regardless of whether the user has enrolled biometrics. Combine with + /// [#canAuthenticate()] to gate UI affordances: show the "Use biometrics" + /// toggle when `isSupported()` is true, but only invoke + /// [#authenticate(AuthenticationOptions)] when `canAuthenticate()` is + /// also true. public abstract boolean isSupported(); - /** - * Returns {@code true} when the device is ready to authenticate right now: - * hardware present, at least one biometric enrolled, and not in a - * locked-out state. - */ + /// Returns `true` when the device is ready to authenticate right now: + /// hardware present, at least one biometric enrolled, and not in a + /// locked-out state. public abstract boolean canAuthenticate(); - /** - * Lists the biometric modalities currently enrolled. On iOS this is - * {@link BiometricType#FINGERPRINT} or {@link BiometricType#FACE}; on - * Android the list may contain {@link BiometricType#IRIS} as well, and - * Android API 30+ adds {@link BiometricType#STRONG} / {@link BiometricType#WEAK} - * authenticator class tags. - * - * @return an empty list when nothing is enrolled or the device is unsupported - */ + /// Lists the biometric modalities currently enrolled. On iOS this is + /// [BiometricType#FINGERPRINT] or [BiometricType#FACE]; on Android the + /// list may contain [BiometricType#IRIS] as well, and Android API 30+ + /// adds [BiometricType#STRONG] / [BiometricType#WEAK] authenticator class + /// tags. + /// + /// #### Returns + /// + /// an empty list when nothing is enrolled or the device is unsupported public abstract List getAvailableBiometrics(); - /** - * Prompts the user to authenticate. The returned {@link AsyncResource} - * completes with {@code true} on success, or with a - * {@link BiometricException} on failure (consult - * {@link BiometricException#getError()} for the typed code). - * - * @param opts non-null configuration; {@link AuthenticationOptions#setReason(String)} - * should be set - */ + /// Prompts the user to authenticate. The returned `AsyncResource` + /// completes with `true` on success, or with a [BiometricException] on + /// failure (consult [BiometricException#getError()] for the typed code). + /// + /// #### Parameters + /// + /// - `opts`: non-null configuration; [AuthenticationOptions#setReason(String)] + /// should be set public abstract AsyncResource authenticate(AuthenticationOptions opts); - /** - * Convenience for {@code authenticate(new AuthenticationOptions().setReason(reason))}. - */ + /// Convenience for `authenticate(new AuthenticationOptions().setReason(reason))`. public AsyncResource authenticate(String reason) { return authenticate(new AuthenticationOptions().setReason(reason)); } - /** - * Cancels an in-flight {@link #authenticate(AuthenticationOptions)} call - * if one is running. The pending {@link AsyncResource} completes with - * {@link BiometricError#USER_CANCELED}. - * - * @return {@code true} when a call was cancelled; {@code false} when no - * authentication was pending - */ + /// Cancels an in-flight [#authenticate(AuthenticationOptions)] call if + /// one is running. The pending `AsyncResource` completes with + /// [BiometricError#USER_CANCELED]. + /// + /// #### Returns + /// + /// `true` when a call was cancelled; `false` when no authentication was + /// pending public abstract boolean stopAuthentication(); } diff --git a/CodenameOne/src/com/codename1/security/SecureStorage.java b/CodenameOne/src/com/codename1/security/SecureStorage.java index ae31f80bc5..1ce30b7eae 100644 --- a/CodenameOne/src/com/codename1/security/SecureStorage.java +++ b/CodenameOne/src/com/codename1/security/SecureStorage.java @@ -25,35 +25,30 @@ import com.codename1.ui.Display; import com.codename1.util.AsyncResource; -/** - * Biometric-gated secure storage backed by the platform keychain. - * Reading an entry prompts the user for biometric authentication; writing or - * deleting may or may not, depending on the platform. - * - *

Entries are bound to the current set of enrolled biometrics. If the user - * adds a fingerprint, enrolls a new face, or disables device security, every - * stored entry is automatically invalidated and subsequent - * {@link #get(String, String)} calls fail with - * {@link BiometricError#KEY_REVOKED}. The application must then re-prompt the - * user for the original value and {@link #set(String, String, String)} it - * again.

- * - *

Use this for short, secret strings (auth tokens, refresh tokens, - * encryption keys). For larger data, encrypt with a key stored here.

- */ +/// Biometric-gated secure storage backed by the platform keychain. Reading an +/// entry prompts the user for biometric authentication; writing or deleting +/// may or may not, depending on the platform. +/// +/// Entries are bound to the current set of enrolled biometrics. If the user +/// adds a fingerprint, enrols a new face, or disables device security, every +/// stored entry is automatically invalidated and subsequent +/// [#get(String, String)] calls fail with [BiometricError#KEY_REVOKED]. The +/// application must then re-prompt the user for the original value and +/// [#set(String, String, String)] it again. +/// +/// Use this for short, secret strings (auth tokens, refresh tokens, +/// encryption keys). For larger data, encrypt with a key stored here. public abstract class SecureStorage { private static SecureStorage fallback; - /** Subclasses are constructed by the port; not for application use. */ + /// Subclasses are constructed by the port; not for application use. protected SecureStorage() { } - /** - * Returns the platform-specific singleton owned by the current port. - * Ports that do not implement secure storage get a no-op fallback that - * reports {@link BiometricError#NOT_AVAILABLE}. - */ + /// Returns the platform-specific singleton owned by the current port. + /// Ports that do not implement secure storage get a no-op fallback that + /// reports [BiometricError#NOT_AVAILABLE]. public static SecureStorage getInstance() { SecureStorage s = Display.getInstance().getSecureStorage(); if (s != null) { @@ -65,37 +60,29 @@ public static SecureStorage getInstance() { return fallback; } - /** - * Retrieves a previously-stored entry, prompting for biometric - * authentication. The returned {@link AsyncResource} completes with the - * value, or with a {@link BiometricException} on failure (including - * {@link BiometricError#KEY_REVOKED} when biometrics have been re-enrolled - * since the entry was written). - */ + /// Retrieves a previously-stored entry, prompting for biometric + /// authentication. The returned `AsyncResource` completes with the value, + /// or with a [BiometricException] on failure (including + /// [BiometricError#KEY_REVOKED] when biometrics have been re-enrolled + /// since the entry was written). public abstract AsyncResource get(String reason, String account); - /** - * Stores or overwrites a value for the given account. On iOS the user is - * typically not prompted (Apple's keychain accepts writes without - * re-authenticating); on Android the user is prompted because the - * underlying cipher requires biometric authentication. - */ + /// Stores or overwrites a value for the given account. On iOS the user + /// is typically not prompted (Apple's keychain accepts writes without + /// re-authenticating); on Android the user is prompted because the + /// underlying cipher requires biometric authentication. public abstract AsyncResource set(String reason, String account, String value); - /** - * Removes a previously-stored entry. No authentication is required since - * deletion does not reveal the value. - */ + /// Removes a previously-stored entry. No authentication is required since + /// deletion does not reveal the value. public abstract AsyncResource remove(String reason, String account); - /** - * Configures the iOS keychain access group for sharing entries between - * the main app and its extensions. The argument must include the Team ID - * prefix (e.g. {@code "ABCDE12345.group.com.example.app"}). Pass - * {@code null} or empty to clear. Ignored on non-iOS platforms. - * - *

The {@code ios.keychainAccessGroup} build hint must declare the same - * group in the app's entitlements for this to work.

- */ + /// Configures the iOS keychain access group for sharing entries between + /// the main app and its extensions. The argument must include the Team + /// ID prefix (e.g. `"ABCDE12345.group.com.example.app"`). Pass `null` or + /// empty to clear. Ignored on non-iOS platforms. + /// + /// The `ios.keychainAccessGroup` build hint must declare the same group + /// in the app's entitlements for this to work. public abstract void setKeychainAccessGroup(String group); } diff --git a/CodenameOne/src/com/codename1/security/StubBiometrics.java b/CodenameOne/src/com/codename1/security/StubBiometrics.java index 8024420d71..6e0fbb7e02 100644 --- a/CodenameOne/src/com/codename1/security/StubBiometrics.java +++ b/CodenameOne/src/com/codename1/security/StubBiometrics.java @@ -27,12 +27,9 @@ import java.util.Collections; import java.util.List; -/** - * No-op Biometrics returned by {@code CodenameOneImplementation} when a port - * has not overridden {@code getBiometrics()}. Reports the device as - * unsupported and fails every authentication with - * {@link BiometricError#NOT_AVAILABLE}. - */ +/// No-op Biometrics returned by `CodenameOneImplementation` when a port has +/// not overridden `getBiometrics()`. Reports the device as unsupported and +/// fails every authentication with [BiometricError#NOT_AVAILABLE]. final class StubBiometrics extends Biometrics { StubBiometrics() { diff --git a/CodenameOne/src/com/codename1/security/StubSecureStorage.java b/CodenameOne/src/com/codename1/security/StubSecureStorage.java index 06042d9099..258f24ff8d 100644 --- a/CodenameOne/src/com/codename1/security/StubSecureStorage.java +++ b/CodenameOne/src/com/codename1/security/StubSecureStorage.java @@ -24,10 +24,8 @@ import com.codename1.util.AsyncResource; -/** - * No-op SecureStorage returned by {@code CodenameOneImplementation} when a - * port has not overridden {@code getSecureStorage()}. - */ +/// No-op SecureStorage returned by `CodenameOneImplementation` when a port +/// has not overridden `getSecureStorage()`. final class StubSecureStorage extends SecureStorage { StubSecureStorage() { diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index edc13dd783..5582087609 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -46,6 +46,8 @@ #import #import "NetworkConnectionImpl.h" #include "com_codename1_impl_ios_IOSImplementation.h" +#include "com_codename1_impl_ios_IOSBiometrics.h" +#include "com_codename1_impl_ios_IOSSecureStorage.h" #include "com_codename1_ui_Display.h" #include "com_codename1_ui_Component.h" #include "java_lang_Throwable.h" From 2db04e082999191940bc56c477980c0b1e6e8a36 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 20 May 2026 04:19:54 +0300 Subject: [PATCH 03/12] Fix Android port build under US-ASCII JDK default encoding JDK 17 (and JDK 8) javac on CI defaults file.encoding to US-ASCII; file.encoding=UTF-8 is only the JDK 18+ default per JEP 400. That made the em-dash in AndroidBiometrics.java:64 ('-- values are stable per AOSP') fatal with three "unmappable character" errors and broke the Ant compile step. Replace with ASCII double-hyphen to match the ASCII-only invariant documented in CLAUDE memory. Also remove the unreachable UnrecoverableEntryException catch in AndroidSecureStorage.getSecretKey -- keyStore.getKey() only declares UnrecoverableKeyException, not its parent, so the second clause is dead code (javac warning) and the import is unused. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/com/codename1/impl/android/AndroidBiometrics.java | 2 +- .../src/com/codename1/impl/android/AndroidSecureStorage.java | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java index b67ec7ba36..b6ca4cf681 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java @@ -61,7 +61,7 @@ public final class AndroidBiometrics extends Biometrics { private static final int FINGERPRINT_ERROR_NO_FINGERPRINTS = 11; private static final int FINGERPRINT_ERROR_HW_NOT_PRESENT = 12; - // BiometricPrompt error codes (API 28+) — values are stable per AOSP. + // BiometricPrompt error codes (API 28+) -- values are stable per AOSP. static final int BIOMETRIC_ERROR_HW_UNAVAILABLE = 1; static final int BIOMETRIC_ERROR_HW_NOT_PRESENT = 12; static final int BIOMETRIC_ERROR_LOCKOUT = 7; diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java index ee333b1b97..6cbf417da4 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java @@ -47,7 +47,6 @@ import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; -import java.security.UnrecoverableEntryException; import java.security.UnrecoverableKeyException; import java.security.cert.CertificateException; @@ -384,8 +383,6 @@ private SecretKey getSecretKey() { return (SecretKey) keyStore.getKey(KEY_ID, null); } catch (UnrecoverableKeyException e) { keyRevoked = true; - } catch (UnrecoverableEntryException e) { - keyRevoked = true; } catch (KeyStoreException e) { Log.e(e); } catch (NoSuchAlgorithmException e) { From 0d1bf17d883b156f3fe7e1de2f0dbb840727a749 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 20 May 2026 04:55:09 +0300 Subject: [PATCH 04/12] Make Biometrics/SecureStorage fallback eagerly final to silence SpotBugs build-test (8) flagged LI_LAZY_INIT_STATIC on the lazy `fallback` field in both Biometrics.getInstance() and SecureStorage.getInstance() -- the if-null-then-assign-then-return pattern isn't thread-safe and the workflow treats this rule as forbidden. The fallback stubs are cheap immutable objects, so promote them to `private static final` eager fields and return them with a ternary. Also drop the explicit no-arg constructors on AuthenticationOptions, StubBiometrics and StubSecureStorage (PMD UnnecessaryConstructor) -- the compiler-generated default has the correct visibility in each case (public, package-private, package-private). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/codename1/security/AuthenticationOptions.java | 3 --- CodenameOne/src/com/codename1/security/Biometrics.java | 10 ++-------- .../src/com/codename1/security/SecureStorage.java | 10 ++-------- .../src/com/codename1/security/StubBiometrics.java | 3 --- .../src/com/codename1/security/StubSecureStorage.java | 3 --- 5 files changed, 4 insertions(+), 25 deletions(-) diff --git a/CodenameOne/src/com/codename1/security/AuthenticationOptions.java b/CodenameOne/src/com/codename1/security/AuthenticationOptions.java index 6bc925ec69..c15207a0e5 100644 --- a/CodenameOne/src/com/codename1/security/AuthenticationOptions.java +++ b/CodenameOne/src/com/codename1/security/AuthenticationOptions.java @@ -42,9 +42,6 @@ public final class AuthenticationOptions { private boolean stickyAuth; private boolean showDialogOnAndroid = true; - public AuthenticationOptions() { - } - public String getReason() { return reason; } diff --git a/CodenameOne/src/com/codename1/security/Biometrics.java b/CodenameOne/src/com/codename1/security/Biometrics.java index ca4e05584c..6f9ff0bb17 100644 --- a/CodenameOne/src/com/codename1/security/Biometrics.java +++ b/CodenameOne/src/com/codename1/security/Biometrics.java @@ -61,7 +61,7 @@ /// [BiometricError#NOT_AVAILABLE]. public abstract class Biometrics { - private static Biometrics fallback; + private static final Biometrics FALLBACK = new StubBiometrics(); /// Subclasses are constructed by the port; not for application use. protected Biometrics() { @@ -72,13 +72,7 @@ protected Biometrics() { /// reports [BiometricError#NOT_AVAILABLE]. public static Biometrics getInstance() { Biometrics b = Display.getInstance().getBiometrics(); - if (b != null) { - return b; - } - if (fallback == null) { - fallback = new StubBiometrics(); - } - return fallback; + return b != null ? b : FALLBACK; } /// Returns `true` when biometric hardware exists on the device, diff --git a/CodenameOne/src/com/codename1/security/SecureStorage.java b/CodenameOne/src/com/codename1/security/SecureStorage.java index 1ce30b7eae..2c84941583 100644 --- a/CodenameOne/src/com/codename1/security/SecureStorage.java +++ b/CodenameOne/src/com/codename1/security/SecureStorage.java @@ -40,7 +40,7 @@ /// encryption keys). For larger data, encrypt with a key stored here. public abstract class SecureStorage { - private static SecureStorage fallback; + private static final SecureStorage FALLBACK = new StubSecureStorage(); /// Subclasses are constructed by the port; not for application use. protected SecureStorage() { @@ -51,13 +51,7 @@ protected SecureStorage() { /// reports [BiometricError#NOT_AVAILABLE]. public static SecureStorage getInstance() { SecureStorage s = Display.getInstance().getSecureStorage(); - if (s != null) { - return s; - } - if (fallback == null) { - fallback = new StubSecureStorage(); - } - return fallback; + return s != null ? s : FALLBACK; } /// Retrieves a previously-stored entry, prompting for biometric diff --git a/CodenameOne/src/com/codename1/security/StubBiometrics.java b/CodenameOne/src/com/codename1/security/StubBiometrics.java index 6e0fbb7e02..bffa2d2900 100644 --- a/CodenameOne/src/com/codename1/security/StubBiometrics.java +++ b/CodenameOne/src/com/codename1/security/StubBiometrics.java @@ -32,9 +32,6 @@ /// fails every authentication with [BiometricError#NOT_AVAILABLE]. final class StubBiometrics extends Biometrics { - StubBiometrics() { - } - @Override public boolean isSupported() { return false; diff --git a/CodenameOne/src/com/codename1/security/StubSecureStorage.java b/CodenameOne/src/com/codename1/security/StubSecureStorage.java index 258f24ff8d..d3ba1358e6 100644 --- a/CodenameOne/src/com/codename1/security/StubSecureStorage.java +++ b/CodenameOne/src/com/codename1/security/StubSecureStorage.java @@ -28,9 +28,6 @@ /// has not overridden `getSecureStorage()`. final class StubSecureStorage extends SecureStorage { - StubSecureStorage() { - } - @Override public AsyncResource get(String reason, String account) { AsyncResource r = new AsyncResource(); From ae699b7ddb1418c59ea50cf5f18902c4dc6f4ba2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 20 May 2026 19:01:31 +0300 Subject: [PATCH 05/12] Android Biometrics: bind authenticate() to a Keystore-backed CryptoObject CodeQL alert "Insecure local authentication" (java/android/insecure- local-authentication, alert #108) correctly flagged that AndroidBiometrics.authenticate() granted access on the bare onAuthenticationSucceeded callback. Without a CryptoObject the success path can be reached via runtime hooking tools (Frida) without the user ever actually authenticating. Add a single-use AES probe key to the AndroidKeyStore with setUserAuthenticationRequired(true) and (API 24+) setInvalidatedByBiometricEnrollment(true), initialise a Cipher under it, and pass that Cipher to BiometricPrompt / FingerprintManager as the CryptoObject. The success callbacks then run authedCipher.doFinal(PROBE_PLAINTEXT); a real biometric unlocks the cipher and the doFinal returns, a spoofed callback fails because the Keystore refuses the operation. Probe key is recreated on KeyPermanentlyInvalidatedException (which happens when the user enrols a new biometric -- the security property CodeQL is asking us to enforce). The key is independent of the AndroidSecureStorage per-account keys, so SecureStorage entries are not affected when the probe is rotated. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../impl/android/AndroidBiometrics.java | 144 +++++++++++++++++- 1 file changed, 136 insertions(+), 8 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java index b6ca4cf681..5c84b6c6e8 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java @@ -29,6 +29,9 @@ import android.os.Build; import android.os.CancellationSignal; import android.os.Looper; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyPermanentlyInvalidatedException; +import android.security.keystore.KeyProperties; import com.codename1.io.Log; import com.codename1.security.AuthenticationOptions; @@ -39,9 +42,14 @@ import com.codename1.ui.Display; import com.codename1.util.AsyncResource; +import java.security.KeyStore; import java.util.ArrayList; import java.util.List; +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + /** * Android backing for {@link Biometrics}. Uses * {@code BiometricPrompt} on API 29+ (via reflection — the cn1-binaries @@ -61,6 +69,13 @@ public final class AndroidBiometrics extends Biometrics { private static final int FINGERPRINT_ERROR_NO_FINGERPRINTS = 11; private static final int FINGERPRINT_ERROR_HW_NOT_PRESENT = 12; + // Probe key tying biometric success to a real KeyStore unlock so the + // success callback cannot be bypassed by app hooking tools (Frida etc.). + // See CodeQL alert "Insecure local authentication" (java/android/insecure-local-authentication). + private static final String PROBE_KEY_ID = "CN1BiometricsAuthProbeKey"; + private static final String ANDROID_KEY_STORE = "AndroidKeyStore"; + private static final byte[] PROBE_PLAINTEXT = new byte[]{0x42}; + // BiometricPrompt error codes (API 28+) -- values are stable per AOSP. static final int BIOMETRIC_ERROR_HW_UNAVAILABLE = 1; static final int BIOMETRIC_ERROR_HW_NOT_PRESENT = 12; @@ -177,6 +192,15 @@ public AsyncResource authenticate(final AuthenticationOptions opts) { final String description = opts == null ? null : opts.getDescription(); pending = result; + // Initialise a CryptoObject-backed Cipher that the OS will unlock + // ONLY if real biometric authentication succeeds. The success callback + // then performs a doFinal() against the unlocked cipher; if a hooking + // tool bypasses the callback, doFinal() throws and the result fails. + final Cipher probeCipher = initProbeCipher(result); + if (probeCipher == null) { + // initProbeCipher already errored the result. + return result; + } if (Build.VERSION.SDK_INT >= 29) { runOnUi(new Runnable() { @Override @@ -185,13 +209,20 @@ public void run() { cancellationSignal.cancel(); } cancellationSignal = new CancellationSignal(); - BiometricsApi29.authenticate(AndroidNativeUtil.getActivity(), + BiometricsApi29.authenticateWithCipher( + AndroidNativeUtil.getActivity(), title, subtitle, description, negative, + probeCipher, cancellationSignal, - new BiometricsApi29.AuthCallback() { + new BiometricsApi29.CipherAuthCallback() { @Override - public void onSuccess() { - completeSuccess(result); + public void onSuccess(Object authedCipher) { + if (verifyProbeCipher((Cipher) authedCipher)) { + completeSuccess(result); + } else { + completeError(result, BiometricError.AUTHENTICATION_FAILED, + "Probe cipher rejected -- biometric success may have been spoofed"); + } } @Override @@ -202,12 +233,12 @@ public void onError(int code, String msg) { } }); } else { - authenticateLegacy(result); + authenticateLegacy(result, probeCipher); } return result; } - private void authenticateLegacy(final AsyncResource result) { + private void authenticateLegacy(final AsyncResource result, final Cipher probeCipher) { runOnUi(new Runnable() { @Override public void run() { @@ -246,7 +277,14 @@ public void onAuthenticationError(int errorCode, CharSequence errString) { @Override public void onAuthenticationSucceeded(FingerprintManager.AuthenticationResult r) { cs.cancel(); - completeSuccess(result); + Cipher authedCipher = r.getCryptoObject() == null + ? probeCipher : r.getCryptoObject().getCipher(); + if (verifyProbeCipher(authedCipher)) { + completeSuccess(result); + } else { + completeError(result, BiometricError.AUTHENTICATION_FAILED, + "Probe cipher rejected -- biometric success may have been spoofed"); + } } @Override @@ -258,11 +296,101 @@ public void onAuthenticationFailed() { } } }; - fpm.authenticate(null, cs, 0, cb, null); + fpm.authenticate(new FingerprintManager.CryptoObject(probeCipher), cs, 0, cb, null); } }); } + /// Initialises an AES/CBC/PKCS7 Cipher under the Keystore probe key in + /// ENCRYPT_MODE. The Keystore enforces user-authentication-required, so + /// the Cipher only finalises after a real biometric prompt completes + /// (see [CodeQL "Insecure local authentication"](https://github.com/codenameone/CodenameOne/security/code-scanning)). + /// Returns `null` and errors the result if the key cannot be created or + /// initialised. + private Cipher initProbeCipher(AsyncResource result) { + for (int attempt = 0; attempt < 2; attempt++) { + try { + KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE); + ks.load(null); + SecretKey key = (SecretKey) ks.getKey(PROBE_KEY_ID, null); + if (key == null) { + createProbeKey(); + key = (SecretKey) ks.getKey(PROBE_KEY_ID, null); + if (key == null) { + completeError(result, BiometricError.UNKNOWN, + "Failed to create biometric probe key"); + return null; + } + } + Cipher c = Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + + "/" + KeyProperties.BLOCK_MODE_CBC + + "/" + KeyProperties.ENCRYPTION_PADDING_PKCS7); + c.init(Cipher.ENCRYPT_MODE, key); + return c; + } catch (KeyPermanentlyInvalidatedException e) { + // User enrolled new biometrics since the probe key was created. + // Delete it and retry once -- the next iteration recreates it. + deleteProbeKey(); + } catch (Throwable t) { + Log.e(t); + completeError(result, BiometricError.UNKNOWN, + "Failed to initialise biometric probe cipher: " + t.getMessage()); + return null; + } + } + completeError(result, BiometricError.KEY_REVOKED, + "Biometric probe key permanently invalidated"); + return null; + } + + private void createProbeKey() { + try { + KeyGenParameterSpec.Builder b = new KeyGenParameterSpec.Builder(PROBE_KEY_ID, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_CBC) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) + .setUserAuthenticationRequired(true); + // Invalidate the probe key whenever the user enrols a new biometric; + // this is the security property CodeQL is asking us to enforce. + if (Build.VERSION.SDK_INT >= 24) { + b.setInvalidatedByBiometricEnrollment(true); + } + KeyGenerator kg = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE); + kg.init(b.build()); + kg.generateKey(); + } catch (Throwable t) { + Log.e(t); + } + } + + private void deleteProbeKey() { + try { + KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE); + ks.load(null); + ks.deleteEntry(PROBE_KEY_ID); + } catch (Throwable t) { + Log.e(t); + } + } + + /// Performs the actual cryptographic operation on the biometric-unlocked + /// Cipher. Returning `true` proves the user really authenticated; throws + /// indicate the success callback was reached without a valid biometric + /// unlock and the call must be rejected. + private boolean verifyProbeCipher(Cipher authedCipher) { + if (authedCipher == null) { + return false; + } + try { + authedCipher.doFinal(PROBE_PLAINTEXT); + return true; + } catch (Throwable t) { + Log.e(t); + return false; + } + } + void completeSuccess(final AsyncResource result) { if (pending != result) { return; From 5f5c2fef97ccaf2f31166a08774298706b9a4191 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 20 May 2026 19:19:03 +0300 Subject: [PATCH 06/12] Android Biometrics: require CryptoObject from result, no fallback path Tighten the legacy FingerprintManager success path so CodeQL's dataflow analyser can see unambiguously that the cipher used for doFinal() comes from the AuthenticationResult.getCryptoObject() that the OS returned, not the local probe Cipher variable. If the result is missing a CryptoObject (shouldn't happen, but a spoofed callback might supply null) we now fail with AUTHENTICATION_FAILED rather than falling back to the local cipher reference. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../codename1/impl/android/AndroidBiometrics.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java index 5c84b6c6e8..5b86c4a0da 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java @@ -277,14 +277,18 @@ public void onAuthenticationError(int errorCode, CharSequence errString) { @Override public void onAuthenticationSucceeded(FingerprintManager.AuthenticationResult r) { cs.cancel(); - Cipher authedCipher = r.getCryptoObject() == null - ? probeCipher : r.getCryptoObject().getCipher(); - if (verifyProbeCipher(authedCipher)) { - completeSuccess(result); - } else { + // Require the OS to return the same CryptoObject we + // passed in, and confirm by running an actual crypto + // operation on the unlocked cipher. A hooked / spoofed + // success callback either lacks the CryptoObject or + // hits a Keystore-locked cipher and doFinal() throws. + FingerprintManager.CryptoObject crypto = r.getCryptoObject(); + if (crypto == null || !verifyProbeCipher(crypto.getCipher())) { completeError(result, BiometricError.AUTHENTICATION_FAILED, "Probe cipher rejected -- biometric success may have been spoofed"); + return; } + completeSuccess(result); } @Override From 0918efeef80bb9e10e0ca0684d128f9174cf4633 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 20 May 2026 21:37:42 +0300 Subject: [PATCH 07/12] Biometrics: PR feedback -- design, scope, docs, SpotBugs gate Round of fixes from the PR review: 1. **Non-abstract base classes.** Biometrics and SecureStorage are now concrete with no-op default implementations -- StubBiometrics and StubSecureStorage are deleted. The default class is returned as the fallback for unsupported ports, so app code never needs a null check or a platform if. Reduces class count and eliminates two public-package types that were really impl details. 2. **Six SIC_INNER_SHOULD_BE_STATIC_ANON warnings fixed** in AndroidBiometrics + AndroidSecureStorage by converting the self-contained Runnables / CipherWork lambdas to actual Java 8 lambdas (the Android port already compiles -source 1.8). 3. **SpotBugs forbidden_rules now applied across every project**, not only core-unittests. A regression in android/ios spotbugs now fails CI exactly the way a regression in core does. 4. **Conditional injection.** IPhoneBuilder and AndroidGradleBuilder only inject LocalAuthentication.framework / USE_BIOMETRIC / USE_FINGERPRINT when the bytecode scanner observes any com.codename1.security class. Apps that never touch the API pay nothing. Mirrored in BuildDaemon (legacy build server). 5. **JavaSEBiometrics installBuildHints** -- first time the API is touched in the simulator, set ios.NSFaceIDUsageDescription on the project (placeholder text the developer should overwrite). Mirrors the historical FingerprintScanner cn1lib pattern. 6. **/// javadoc on every public getter / setter / enum constant** under com.codename1.security; STRONG/WEAK/IRIS now explained; iOS/Android/JavaSE/fallback behaviour spelled out throughout. 7. **Developer guide chapter** docs/developer-guide/Biometric- Authentication.asciidoc covering quick start, platform support matrix, build hints, prompt configuration, typed errors, simulator workflow, and the Keystore-bound success-verification security note. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/scripts/generate-quality-report.py | 19 +- .../security/AuthenticationOptions.java | 67 +++++-- .../com/codename1/security/BiometricType.java | 46 ++++- .../com/codename1/security/Biometrics.java | 86 ++++++--- .../com/codename1/security/SecureStorage.java | 89 ++++++--- .../codename1/security/StubBiometrics.java | 62 ------- .../codename1/security/StubSecureStorage.java | 59 ------ .../impl/android/AndroidBiometrics.java | 107 +++++------ .../impl/android/AndroidSecureStorage.java | 58 +++--- .../impl/javase/JavaSEBiometrics.java | 31 ++++ .../Biometric-Authentication.asciidoc | 172 ++++++++++++++++++ docs/developer-guide/developer-guide.asciidoc | 2 + .../builders/AndroidGradleBuilder.java | 31 ++-- .../com/codename1/builders/IPhoneBuilder.java | 21 ++- 14 files changed, 540 insertions(+), 310 deletions(-) delete mode 100644 CodenameOne/src/com/codename1/security/StubBiometrics.java delete mode 100644 CodenameOne/src/com/codename1/security/StubSecureStorage.java create mode 100644 docs/developer-guide/Biometric-Authentication.asciidoc diff --git a/.github/scripts/generate-quality-report.py b/.github/scripts/generate-quality-report.py index 258f5cf7c9..7cf7ce0122 100755 --- a/.github/scripts/generate-quality-report.py +++ b/.github/scripts/generate-quality-report.py @@ -959,14 +959,19 @@ def _is_exempt(f: Finding) -> bool: return False - violations = [ - f for f in spotbugs.findings - if f.rule in forbidden_rules and not _is_exempt(f) - ] - if violations: + # Apply the same forbidden_rules to every SpotBugs report (android, + # ios, codenameone-maven-plugin, ...) -- not just core-unittests -- + # so a quality regression in a port is caught in CI just as quickly + # as one in core. + all_violations: List[Tuple[str, Finding]] = [] + for label, report in spotbugs_reports.items(): + for f in report.findings: + if f.rule in forbidden_rules and not _is_exempt(f): + all_violations.append((label, f)) + if all_violations: print("\n❌ Build failed due to forbidden SpotBugs violations:") - for v in violations: - print(f" - {v.rule}: {v.location} - {v.message}") + for label, v in all_violations: + print(f" - {v.rule}: {label} {v.location} - {v.message}") exit(1) pmd = parse_pmd() diff --git a/CodenameOne/src/com/codename1/security/AuthenticationOptions.java b/CodenameOne/src/com/codename1/security/AuthenticationOptions.java index c15207a0e5..8d159ea7d5 100644 --- a/CodenameOne/src/com/codename1/security/AuthenticationOptions.java +++ b/CodenameOne/src/com/codename1/security/AuthenticationOptions.java @@ -27,9 +27,12 @@ /// required (it maps to the iOS `localizedReason` and the Android /// `BiometricPrompt` title fallback). /// -/// Not every option is honoured on every platform -- the docs on each setter -/// note the platforms where the value is consulted. Unrecognised options are -/// silently ignored, so callers can set the union without platform-checking. +/// Not every option is honoured on every platform -- the docs on each +/// setter note where the value is consulted. Unrecognised options are +/// silently ignored, so callers can set the union without +/// platform-checking. On platforms that don't support biometrics at all +/// (desktop deploy, JavaScript), the entire prompt collapses into a +/// [BiometricError#NOT_AVAILABLE] failure and none of these options matter. public final class AuthenticationOptions { private String reason; @@ -42,95 +45,125 @@ public final class AuthenticationOptions { private boolean stickyAuth; private boolean showDialogOnAndroid = true; + /// The current prompt reason, or `null` if unset. See + /// [#setReason(String)] for how it is rendered. public String getReason() { return reason; } /// The user-facing reason for prompting. On iOS this is the - /// `localizedReason` passed to `LAContext.evaluatePolicy`; on Android it - /// is used as the `BiometricPrompt` title if [#setTitle(String)] is unset. + /// `localizedReason` passed to `LAContext.evaluatePolicy`; on Android + /// it is used as the `BiometricPrompt` title if [#setTitle(String)] is + /// unset. On JavaSE simulator it is shown in the modal prompt body. + /// Required for production use -- platforms typically reject an empty + /// reason. public AuthenticationOptions setReason(String reason) { this.reason = reason; return this; } + /// The current Android `BiometricPrompt` title, or `null` to fall back + /// to [#getReason()]. Always `null` on iOS where this property has no + /// effect. public String getTitle() { return title; } - /// Android `BiometricPrompt` title. Ignored on iOS. + /// Android `BiometricPrompt` title. Ignored on iOS (use [#setReason(String)] + /// there). Ignored on the fallback base class. public AuthenticationOptions setTitle(String title) { this.title = title; return this; } + /// The current Android `BiometricPrompt` subtitle, or `null` if unset. + /// Always `null` on iOS where this property has no effect. public String getSubtitle() { return subtitle; } - /// Android `BiometricPrompt` subtitle. Ignored on iOS. + /// Android `BiometricPrompt` subtitle. Ignored on iOS and on the + /// fallback base class. public AuthenticationOptions setSubtitle(String subtitle) { this.subtitle = subtitle; return this; } + /// The current Android `BiometricPrompt` description body, or `null` + /// if unset. Always `null` on iOS where this property has no effect. public String getDescription() { return description; } - /// Android `BiometricPrompt` description body. Ignored on iOS. + /// Android `BiometricPrompt` description body. Ignored on iOS and on + /// the fallback base class. public AuthenticationOptions setDescription(String description) { this.description = description; return this; } + /// The current Android `BiometricPrompt` negative button label. + /// Defaults to `"Cancel"`. Ignored on iOS (Apple's prompt has its own + /// system-defined cancel button). public String getNegativeButtonText() { return negativeButtonText; } - /// Android `BiometricPrompt` negative button label (defaults to "Cancel"). + /// Android `BiometricPrompt` negative button label (defaults to + /// `"Cancel"`). Ignored on iOS and on the fallback base class. public AuthenticationOptions setNegativeButtonText(String text) { this.negativeButtonText = text == null ? "Cancel" : text; return this; } + /// `true` when the OS prompt is configured to reject device-credential + /// fallback. See [#setBiometricOnly(boolean)]. public boolean isBiometricOnly() { return biometricOnly; } /// If `true`, the OS prompt rejects device-credential fallback (PIN / - /// pattern / passcode). Honoured on both platforms; on Android this maps - /// to `setAllowedAuthenticators(BIOMETRIC_STRONG)` or its legacy - /// equivalent. + /// pattern / passcode). Honoured on both platforms; on Android this + /// maps to `setAllowedAuthenticators(BIOMETRIC_STRONG)` or its legacy + /// equivalent. Ignored on the fallback base class. public AuthenticationOptions setBiometricOnly(boolean biometricOnly) { this.biometricOnly = biometricOnly; return this; } + /// `true` when the caller has asked for a class-3 ("strong") biometric. + /// See [#setSensitiveTransaction(boolean)]. public boolean isSensitiveTransaction() { return sensitiveTransaction; } /// Hints that the operation guards a sensitive action and a class-3 - /// ("strong") biometric should be required where the platform exposes the - /// distinction. Affects Android API 30+; advisory on iOS. + /// ("strong") biometric should be required where the platform exposes + /// the distinction. Affects Android API 30+; advisory on iOS (where + /// Face ID / Touch ID are both considered strong). Ignored on the + /// fallback base class. public AuthenticationOptions setSensitiveTransaction(boolean sensitive) { this.sensitiveTransaction = sensitive; return this; } + /// `true` when the Android sticky-auth flag is set. See + /// [#setStickyAuth(boolean)]. public boolean isStickyAuth() { return stickyAuth; } /// If `true`, the in-progress authentication survives the app being - /// backgrounded and resumes on foreground (Android sticky-auth semantics). - /// No effect on iOS. + /// backgrounded and resumes on foreground (Android sticky-auth + /// semantics). No effect on iOS or on the fallback base class. public AuthenticationOptions setStickyAuth(boolean stickyAuth) { this.stickyAuth = stickyAuth; return this; } + /// `true` (the default) when Codename One should overlay its own dialog + /// on the legacy Android `FingerprintManager` path. See + /// [#setShowDialogOnAndroid(boolean)]. public boolean isShowDialogOnAndroid() { return showDialogOnAndroid; } @@ -138,7 +171,7 @@ public boolean isShowDialogOnAndroid() { /// Controls whether the legacy `FingerprintManager` path (Android 6-9) /// draws a Codename One Dialog over the system prompt. The modern /// `BiometricPrompt` path (Android 10+) provides its own UI and ignores - /// this flag. + /// this flag. Ignored on iOS and on the fallback base class. public AuthenticationOptions setShowDialogOnAndroid(boolean show) { this.showDialogOnAndroid = show; return this; diff --git a/CodenameOne/src/com/codename1/security/BiometricType.java b/CodenameOne/src/com/codename1/security/BiometricType.java index bb8c42e3ce..524eff0822 100644 --- a/CodenameOne/src/com/codename1/security/BiometricType.java +++ b/CodenameOne/src/com/codename1/security/BiometricType.java @@ -22,18 +22,50 @@ */ package com.codename1.security; -/// Enumerates the biometric authentication modalities that may be available on -/// a device. Returned from `Biometrics.getAvailableBiometrics()`. +/// Enumerates the biometric authentication modalities that may be available +/// on a device. Returned from +/// [Biometrics#getAvailableBiometrics()]. /// -/// `FINGERPRINT` and `FACE` are populated on both iOS and Android. `IRIS` only -/// appears on Android devices whose hardware advertises -/// `PackageManager.FEATURE_IRIS`. `STRONG` and `WEAK` reflect Android's -/// `BiometricManager` authenticator class tiers (class 3 and class 2) and are -/// only populated on Android API 30+. +/// The set returned at runtime depends on the platform and the user's +/// enrolled credentials -- e.g. an iPhone with Face ID enrolled returns +/// `[FACE]`; an Android Pixel with a fingerprint and a face enrolled +/// returns `[FINGERPRINT, FACE]`. Use the list to drive UI affordances +/// (icon, prompt copy) but never to gate the actual `authenticate()` call: +/// always rely on [Biometrics#canAuthenticate()] for that decision. public enum BiometricType { + + /// Fingerprint sensor (iOS Touch ID, Android `FEATURE_FINGERPRINT`). + /// Populated on both platforms when the device has fingerprint hardware + /// AND at least one fingerprint is enrolled. FINGERPRINT, + + /// Face recognition (iOS Face ID, Android `FEATURE_FACE`). Populated on + /// both platforms; on Android 9-10 the OS does not expose face + /// enrolment via the BiometricPrompt API even on devices that have it, + /// so this value only appears on Android API 29+. FACE, + + /// Iris recognition (Android `FEATURE_IRIS`). Used by a handful of + /// Samsung devices and is not exposed on iOS. Practically rare in 2026 + /// -- code that targets it should still treat the absence as the + /// expected case. IRIS, + + /// Android **class-3 / "strong"** authenticator tier. Indicates the + /// device's available biometric meets Android's stricter cryptographic + /// requirements (false-acceptance rate < 1/100,000) and can therefore + /// gate Keystore-backed keys created with `setUserAuthenticationRequired` + /// + a strong-only authenticator policy. Populated only on Android API + /// 30+; iOS has no analogous concept and this value is never returned + /// there. Combine with [AuthenticationOptions#setSensitiveTransaction(boolean)] + /// to require this tier when guarding sensitive operations. STRONG, + + /// Android **class-2 / "weak"** authenticator tier. Indicates a + /// biometric whose false-acceptance rate is between 1/10,000 and + /// 1/100,000 (typically older fingerprint sensors). It can authenticate + /// the user for UI flows but is NOT permitted to unlock Keystore-bound + /// keys, so weak-only devices cannot use [SecureStorage]. Populated + /// only on Android API 30+; not returned on iOS. WEAK } diff --git a/CodenameOne/src/com/codename1/security/Biometrics.java b/CodenameOne/src/com/codename1/security/Biometrics.java index 6f9ff0bb17..7c29e8f26c 100644 --- a/CodenameOne/src/com/codename1/security/Biometrics.java +++ b/CodenameOne/src/com/codename1/security/Biometrics.java @@ -25,10 +25,11 @@ import com.codename1.ui.Display; import com.codename1.util.AsyncResource; +import java.util.Collections; import java.util.List; /// Entry point for biometric authentication (Touch ID, Face ID, fingerprint, -/// Android BiometricPrompt). Obtain the platform implementation via +/// Android `BiometricPrompt`). Obtain the platform implementation via /// [#getInstance()]; the returned subclass is owned by the active port. /// /// A typical unlock flow: @@ -53,61 +54,92 @@ /// failure path completes with a [BiometricException] so callers can branch /// on the typed [BiometricError]. /// -/// This class is the parallel of Flutter's `local_auth` surface. On platforms -/// without biometric support (desktop simulator with the "Available" -/// simulator menu item unchecked, or older Android devices), -/// [#canAuthenticate()] returns `false` and -/// [#authenticate(AuthenticationOptions)] completes with -/// [BiometricError#NOT_AVAILABLE]. -public abstract class Biometrics { - - private static final Biometrics FALLBACK = new StubBiometrics(); +/// #### Platform support +/// +/// - **iOS** -- uses `LocalAuthentication.framework` (`LAContext`). Touch ID +/// and Face ID on supported devices. Add the `ios.NSFaceIDUsageDescription` +/// build hint when targeting Face ID hardware. +/// - **Android** -- uses `BiometricPrompt` on API 29+ (Android 10) and the +/// legacy `FingerprintManager` on API 23-28. Fingerprint, face, and iris +/// modalities are reported per `PackageManager` features. +/// - **JavaSE simulator** -- behaves as a real device with no enrolled +/// biometrics by default. The `Simulate -> Biometric Simulation` submenu +/// in the simulator lets you toggle hardware availability, enrolled +/// modalities, and the outcome of the next [#authenticate(String)] call. +/// - **All other platforms (desktop deploy, JavaScript, ...)** -- this base +/// class is returned as-is and acts as a non-supporting fallback: +/// [#isSupported()] / [#canAuthenticate()] return `false` and +/// [#authenticate(String)] completes with [BiometricError#NOT_AVAILABLE]. +/// Application code does not need platform `if` statements -- always +/// gate biometrics on [#canAuthenticate()] before invoking the prompt. +/// +/// This class is the Codename One parallel of Flutter's `local_auth` API. +public class Biometrics { - /// Subclasses are constructed by the port; not for application use. + /// Subclasses are constructed by the port. Application code obtains the + /// active instance via [#getInstance()]. protected Biometrics() { } /// Returns the platform-specific singleton owned by the current port. - /// Ports that do not implement biometrics get a no-op fallback that - /// reports [BiometricError#NOT_AVAILABLE]. + /// On ports that do not implement biometrics this returns a base + /// [Biometrics] instance whose methods report the device as + /// unsupported, so calling code never needs a `null` check or a + /// platform-specific `if`. public static Biometrics getInstance() { Biometrics b = Display.getInstance().getBiometrics(); - return b != null ? b : FALLBACK; + return b != null ? b : DEFAULT; } + private static final Biometrics DEFAULT = new Biometrics(); + /// Returns `true` when biometric hardware exists on the device, /// regardless of whether the user has enrolled biometrics. Combine with - /// [#canAuthenticate()] to gate UI affordances: show the "Use biometrics" - /// toggle when `isSupported()` is true, but only invoke + /// [#canAuthenticate()] to gate UI affordances: show the "Use + /// biometrics" toggle when `isSupported()` is true, but only invoke /// [#authenticate(AuthenticationOptions)] when `canAuthenticate()` is - /// also true. - public abstract boolean isSupported(); + /// also true. Returns `false` on the fallback base class. + public boolean isSupported() { + return false; + } /// Returns `true` when the device is ready to authenticate right now: /// hardware present, at least one biometric enrolled, and not in a - /// locked-out state. - public abstract boolean canAuthenticate(); + /// locked-out state. Returns `false` on the fallback base class. + public boolean canAuthenticate() { + return false; + } /// Lists the biometric modalities currently enrolled. On iOS this is /// [BiometricType#FINGERPRINT] or [BiometricType#FACE]; on Android the /// list may contain [BiometricType#IRIS] as well, and Android API 30+ - /// adds [BiometricType#STRONG] / [BiometricType#WEAK] authenticator class - /// tags. + /// adds [BiometricType#STRONG] / [BiometricType#WEAK] authenticator + /// class tags. /// /// #### Returns /// /// an empty list when nothing is enrolled or the device is unsupported - public abstract List getAvailableBiometrics(); + public List getAvailableBiometrics() { + return Collections.emptyList(); + } /// Prompts the user to authenticate. The returned `AsyncResource` /// completes with `true` on success, or with a [BiometricException] on /// failure (consult [BiometricException#getError()] for the typed code). + /// On the fallback base class this completes immediately with + /// [BiometricError#NOT_AVAILABLE] so callers don't need to platform- + /// check before invoking. /// /// #### Parameters /// /// - `opts`: non-null configuration; [AuthenticationOptions#setReason(String)] /// should be set - public abstract AsyncResource authenticate(AuthenticationOptions opts); + public AsyncResource authenticate(AuthenticationOptions opts) { + AsyncResource r = new AsyncResource(); + r.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "Biometric authentication is not available on this platform")); + return r; + } /// Convenience for `authenticate(new AuthenticationOptions().setReason(reason))`. public AsyncResource authenticate(String reason) { @@ -121,6 +153,8 @@ public AsyncResource authenticate(String reason) { /// #### Returns /// /// `true` when a call was cancelled; `false` when no authentication was - /// pending - public abstract boolean stopAuthentication(); + /// pending. Always `false` on the fallback base class. + public boolean stopAuthentication() { + return false; + } } diff --git a/CodenameOne/src/com/codename1/security/SecureStorage.java b/CodenameOne/src/com/codename1/security/SecureStorage.java index 2c84941583..6b3d2ff1aa 100644 --- a/CodenameOne/src/com/codename1/security/SecureStorage.java +++ b/CodenameOne/src/com/codename1/security/SecureStorage.java @@ -25,9 +25,9 @@ import com.codename1.ui.Display; import com.codename1.util.AsyncResource; -/// Biometric-gated secure storage backed by the platform keychain. Reading an -/// entry prompts the user for biometric authentication; writing or deleting -/// may or may not, depending on the platform. +/// Biometric-gated secure storage backed by the platform keychain. Reading +/// an entry prompts the user for biometric authentication; writing or +/// deleting may or may not, depending on the platform. /// /// Entries are bound to the current set of enrolled biometrics. If the user /// adds a fingerprint, enrols a new face, or disables device security, every @@ -38,45 +38,90 @@ /// /// Use this for short, secret strings (auth tokens, refresh tokens, /// encryption keys). For larger data, encrypt with a key stored here. -public abstract class SecureStorage { - - private static final SecureStorage FALLBACK = new StubSecureStorage(); +/// +/// #### Platform support +/// +/// - **iOS** -- backed by Security.framework (`SecItemAdd` / +/// `SecItemCopyMatching` / `SecItemDelete`) with +/// `kSecAccessControlTouchIDCurrentSet`. Sharing entries with App +/// Extensions requires both the `ios.keychainAccessGroup` build hint AND +/// a call to [#setKeychainAccessGroup(String)] passing the same +/// Team-ID-prefixed group identifier. +/// - **Android** -- AES/CBC/PKCS7 ciphertext stored in `SharedPreferences` +/// with the key in the `AndroidKeyStore`, locked via +/// `setUserAuthenticationRequired(true)`. The `BiometricPrompt` (API 29+) +/// or `FingerprintManager` (API 23-28) unlocks the cipher for one +/// operation per prompt. +/// - **JavaSE simulator** -- backed by `java.util.prefs.Preferences`, gated +/// on the same Biometric Simulation menu used by [Biometrics]. Useful for +/// testing the round-trip and `KEY_REVOKED` paths without a device. +/// - **All other platforms** -- this base class is returned as-is and acts +/// as a non-supporting fallback: every method completes with +/// [BiometricError#NOT_AVAILABLE]. Application code does not need +/// platform `if` statements. +public class SecureStorage { - /// Subclasses are constructed by the port; not for application use. + /// Subclasses are constructed by the port. Application code obtains the + /// active instance via [#getInstance()]. protected SecureStorage() { } /// Returns the platform-specific singleton owned by the current port. - /// Ports that do not implement secure storage get a no-op fallback that - /// reports [BiometricError#NOT_AVAILABLE]. + /// On ports that do not implement secure storage this returns a base + /// [SecureStorage] instance whose methods report + /// [BiometricError#NOT_AVAILABLE]. public static SecureStorage getInstance() { SecureStorage s = Display.getInstance().getSecureStorage(); - return s != null ? s : FALLBACK; + return s != null ? s : DEFAULT; } + private static final SecureStorage DEFAULT = new SecureStorage(); + /// Retrieves a previously-stored entry, prompting for biometric - /// authentication. The returned `AsyncResource` completes with the value, - /// or with a [BiometricException] on failure (including + /// authentication. The returned `AsyncResource` completes with the + /// value, or with a [BiometricException] on failure (including /// [BiometricError#KEY_REVOKED] when biometrics have been re-enrolled - /// since the entry was written). - public abstract AsyncResource get(String reason, String account); + /// since the entry was written). On the fallback base class this + /// completes immediately with [BiometricError#NOT_AVAILABLE]. + public AsyncResource get(String reason, String account) { + AsyncResource r = new AsyncResource(); + r.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "Secure storage is not available on this platform")); + return r; + } /// Stores or overwrites a value for the given account. On iOS the user /// is typically not prompted (Apple's keychain accepts writes without /// re-authenticating); on Android the user is prompted because the - /// underlying cipher requires biometric authentication. - public abstract AsyncResource set(String reason, String account, String value); + /// underlying cipher requires biometric authentication. On the fallback + /// base class this completes immediately with + /// [BiometricError#NOT_AVAILABLE]. + public AsyncResource set(String reason, String account, String value) { + AsyncResource r = new AsyncResource(); + r.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "Secure storage is not available on this platform")); + return r; + } - /// Removes a previously-stored entry. No authentication is required since - /// deletion does not reveal the value. - public abstract AsyncResource remove(String reason, String account); + /// Removes a previously-stored entry. No authentication is required + /// since deletion does not reveal the value. On the fallback base class + /// this completes immediately with [BiometricError#NOT_AVAILABLE]. + public AsyncResource remove(String reason, String account) { + AsyncResource r = new AsyncResource(); + r.error(new BiometricException(BiometricError.NOT_AVAILABLE, + "Secure storage is not available on this platform")); + return r; + } /// Configures the iOS keychain access group for sharing entries between /// the main app and its extensions. The argument must include the Team - /// ID prefix (e.g. `"ABCDE12345.group.com.example.app"`). Pass `null` or - /// empty to clear. Ignored on non-iOS platforms. + /// ID prefix (e.g. `"ABCDE12345.group.com.example.app"`). Pass `null` + /// or empty to clear. Ignored on non-iOS platforms and on the fallback + /// base class. /// /// The `ios.keychainAccessGroup` build hint must declare the same group /// in the app's entitlements for this to work. - public abstract void setKeychainAccessGroup(String group); + public void setKeychainAccessGroup(String group) { + // No-op fallback. + } } diff --git a/CodenameOne/src/com/codename1/security/StubBiometrics.java b/CodenameOne/src/com/codename1/security/StubBiometrics.java deleted file mode 100644 index bffa2d2900..0000000000 --- a/CodenameOne/src/com/codename1/security/StubBiometrics.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -package com.codename1.security; - -import com.codename1.util.AsyncResource; - -import java.util.Collections; -import java.util.List; - -/// No-op Biometrics returned by `CodenameOneImplementation` when a port has -/// not overridden `getBiometrics()`. Reports the device as unsupported and -/// fails every authentication with [BiometricError#NOT_AVAILABLE]. -final class StubBiometrics extends Biometrics { - - @Override - public boolean isSupported() { - return false; - } - - @Override - public boolean canAuthenticate() { - return false; - } - - @Override - public List getAvailableBiometrics() { - return Collections.emptyList(); - } - - @Override - public AsyncResource authenticate(AuthenticationOptions opts) { - AsyncResource r = new AsyncResource(); - r.error(new BiometricException(BiometricError.NOT_AVAILABLE, - "Biometric authentication is not available on this platform")); - return r; - } - - @Override - public boolean stopAuthentication() { - return false; - } -} diff --git a/CodenameOne/src/com/codename1/security/StubSecureStorage.java b/CodenameOne/src/com/codename1/security/StubSecureStorage.java deleted file mode 100644 index d3ba1358e6..0000000000 --- a/CodenameOne/src/com/codename1/security/StubSecureStorage.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -package com.codename1.security; - -import com.codename1.util.AsyncResource; - -/// No-op SecureStorage returned by `CodenameOneImplementation` when a port -/// has not overridden `getSecureStorage()`. -final class StubSecureStorage extends SecureStorage { - - @Override - public AsyncResource get(String reason, String account) { - AsyncResource r = new AsyncResource(); - r.error(new BiometricException(BiometricError.NOT_AVAILABLE, - "Secure storage is not available on this platform")); - return r; - } - - @Override - public AsyncResource set(String reason, String account, String value) { - AsyncResource r = new AsyncResource(); - r.error(new BiometricException(BiometricError.NOT_AVAILABLE, - "Secure storage is not available on this platform")); - return r; - } - - @Override - public AsyncResource remove(String reason, String account) { - AsyncResource r = new AsyncResource(); - r.error(new BiometricException(BiometricError.NOT_AVAILABLE, - "Secure storage is not available on this platform")); - return r; - } - - @Override - public void setKeychainAccessGroup(String group) { - // No-op on the stub. - } -} diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java index 5b86c4a0da..c0c60ca791 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java @@ -125,53 +125,52 @@ public List getAvailableBiometrics() { if (Build.VERSION.SDK_INT < 23) { return out; } - runOnUi(new Runnable() { - @Override - public void run() { - try { - Activity act = AndroidNativeUtil.getActivity(); - PackageManager pm = act.getPackageManager(); - boolean okBio = false; - if (Build.VERSION.SDK_INT >= 29) { - if (!AndroidNativeUtil.checkForPermission("android.permission.USE_BIOMETRIC", - "Authorize using biometrics")) { - return; - } - okBio = BiometricsApi29.canAuthenticate(act); - } else { - if (!AndroidNativeUtil.checkForPermission(Manifest.permission.USE_FINGERPRINT, - "Authorize using fingerprint")) { - return; - } - FingerprintManager fpm = (FingerprintManager) - act.getSystemService(Activity.FINGERPRINT_SERVICE); - okBio = fpm != null && fpm.isHardwareDetected() - && fpm.hasEnrolledFingerprints(); - } - if (!okBio) { - return; - } - if (pm.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) { - FingerprintManager fpm = (FingerprintManager) - act.getSystemService(Activity.FINGERPRINT_SERVICE); - if (fpm != null && fpm.hasEnrolledFingerprints()) { - out.add(BiometricType.FINGERPRINT); - } - } - if (Build.VERSION.SDK_INT >= 29) { - if (pm.hasSystemFeature("android.hardware.biometrics.face")) { - out.add(BiometricType.FACE); - } - if (pm.hasSystemFeature("android.hardware.biometrics.iris")) { - out.add(BiometricType.IRIS); - } - } - } catch (Throwable t) { - Log.e(t); + runOnUi(() -> collectAvailableBiometrics(out)); + return out; + } + + private static void collectAvailableBiometrics(List out) { + try { + Activity act = AndroidNativeUtil.getActivity(); + PackageManager pm = act.getPackageManager(); + boolean okBio; + if (Build.VERSION.SDK_INT >= 29) { + if (!AndroidNativeUtil.checkForPermission("android.permission.USE_BIOMETRIC", + "Authorize using biometrics")) { + return; + } + okBio = BiometricsApi29.canAuthenticate(act); + } else { + if (!AndroidNativeUtil.checkForPermission(Manifest.permission.USE_FINGERPRINT, + "Authorize using fingerprint")) { + return; } + FingerprintManager fpm = (FingerprintManager) + act.getSystemService(Activity.FINGERPRINT_SERVICE); + okBio = fpm != null && fpm.isHardwareDetected() + && fpm.hasEnrolledFingerprints(); } - }); - return out; + if (!okBio) { + return; + } + if (pm.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) { + FingerprintManager fpm = (FingerprintManager) + act.getSystemService(Activity.FINGERPRINT_SERVICE); + if (fpm != null && fpm.hasEnrolledFingerprints()) { + out.add(BiometricType.FINGERPRINT); + } + } + if (Build.VERSION.SDK_INT >= 29) { + if (pm.hasSystemFeature("android.hardware.biometrics.face")) { + out.add(BiometricType.FACE); + } + if (pm.hasSystemFeature("android.hardware.biometrics.iris")) { + out.add(BiometricType.IRIS); + } + } + } catch (Throwable t) { + Log.e(t); + } } @Override @@ -400,12 +399,9 @@ void completeSuccess(final AsyncResource result) { return; } pending = null; - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - if (!result.isDone()) { - result.complete(Boolean.TRUE); - } + Display.getInstance().callSerially(() -> { + if (!result.isDone()) { + result.complete(Boolean.TRUE); } }); } @@ -416,12 +412,9 @@ void completeError(final AsyncResource result, return; } pending = null; - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - if (!result.isDone()) { - result.error(new BiometricException(err, msg)); - } + Display.getInstance().callSerially(() -> { + if (!result.isDone()) { + result.error(new BiometricException(err, msg)); } }); } diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java index 6cbf417da4..eef2713f3f 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java @@ -105,19 +105,16 @@ public AsyncResource set(final String reason, final String account, fin "Android API 23 required for biometric secure storage")); return result; } - runAuthenticatedCipher(reason, account, Cipher.ENCRYPT_MODE, result, new CipherWork() { - @Override - public Boolean run(Cipher c) throws Exception { - byte[] enc = c.doFinal(value.getBytes("UTF-8")); - SharedPreferences sp = AndroidNativeUtil.getActivity() - .getApplicationContext() - .getSharedPreferences(PREFS, Context.MODE_PRIVATE); - sp.edit() - .putString("v_" + account, Base64.encodeToString(enc, Base64.DEFAULT)) - .putString("iv_" + account, Base64.encodeToString(c.getIV(), Base64.DEFAULT)) - .apply(); - return Boolean.TRUE; - } + runAuthenticatedCipher(reason, account, Cipher.ENCRYPT_MODE, result, c -> { + byte[] enc = c.doFinal(value.getBytes("UTF-8")); + SharedPreferences sp = AndroidNativeUtil.getActivity() + .getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + sp.edit() + .putString("v_" + account, Base64.encodeToString(enc, Base64.DEFAULT)) + .putString("iv_" + account, Base64.encodeToString(c.getIV(), Base64.DEFAULT)) + .apply(); + return Boolean.TRUE; }); return result; } @@ -138,16 +135,13 @@ public AsyncResource get(final String reason, final String account) { "No secure storage entry for account: " + account)); return result; } - runAuthenticatedCipher(reason, account, Cipher.DECRYPT_MODE, result, new CipherWork() { - @Override - public String run(Cipher c) throws Exception { - SharedPreferences sp2 = AndroidNativeUtil.getActivity() - .getApplicationContext() - .getSharedPreferences(PREFS, Context.MODE_PRIVATE); - byte[] enc = Base64.decode(sp2.getString("v_" + account, ""), Base64.DEFAULT); - byte[] dec = c.doFinal(enc); - return new String(dec, "UTF-8"); - } + runAuthenticatedCipher(reason, account, Cipher.DECRYPT_MODE, result, c -> { + SharedPreferences sp2 = AndroidNativeUtil.getActivity() + .getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + byte[] enc = Base64.decode(sp2.getString("v_" + account, ""), Base64.DEFAULT); + byte[] dec = c.doFinal(enc); + return new String(dec, "UTF-8"); }); return result; } @@ -306,24 +300,18 @@ private void runCipherWork(Cipher authedCipher, CipherWork work, } private void succeedResult(final AsyncResource result, final V value) { - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - if (!result.isDone()) { - result.complete(value); - } + Display.getInstance().callSerially(() -> { + if (!result.isDone()) { + result.complete(value); } }); } private static void failResult(final AsyncResource result, final BiometricError err, final String msg) { - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - if (!result.isDone()) { - result.error(new BiometricException(err, msg)); - } + Display.getInstance().callSerially(() -> { + if (!result.isDone()) { + result.error(new BiometricException(err, msg)); } }); } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEBiometrics.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEBiometrics.java index 64e18faea7..c70606ba69 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEBiometrics.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEBiometrics.java @@ -28,6 +28,7 @@ import com.codename1.security.BiometricType; import com.codename1.security.Biometrics; import com.codename1.ui.CN; +import com.codename1.ui.Display; import com.codename1.util.AsyncResource; import javax.swing.JButton; @@ -42,6 +43,7 @@ import java.awt.event.ActionListener; import java.util.ArrayList; import java.util.List; +import java.util.Map; /** * Simulator backing for {@link Biometrics}. State is mutated by the @@ -73,23 +75,51 @@ public enum SimOutcome { private volatile AsyncResource pending; private volatile JDialog pendingDialog; + private boolean buildHintsInstalled; JavaSEBiometrics() { } + /// Mirrors the historical FingerprintScanner cn1lib pattern: the first + /// time the application touches Biometrics in the simulator, declare + /// the iOS Face ID usage description build hint on the project. The + /// device builders also auto-inject what's needed (LocalAuthentication + /// framework, USE_BIOMETRIC permission), but `NSFaceIDUsageDescription` + /// must contain app-specific localised text that Apple rejects + /// placeholder defaults for, so we only add it if the developer hasn't + /// already supplied one. + private void installBuildHintsIfNeeded() { + if (buildHintsInstalled) { + return; + } + buildHintsInstalled = true; + Map existing = Display.getInstance().getProjectBuildHints(); + if (existing == null) { + return; + } + if (!existing.containsKey("ios.NSFaceIDUsageDescription")) { + Display.getInstance().setProjectBuildHint( + "ios.NSFaceIDUsageDescription", + "Authenticate to securely access your account"); + } + } + @Override public boolean isSupported() { + installBuildHintsIfNeeded(); return simAvailable; } @Override public boolean canAuthenticate() { + installBuildHintsIfNeeded(); return simAvailable && (simFaceEnrolled || simTouchEnrolled || simIrisEnrolled); } @Override public List getAvailableBiometrics() { + installBuildHintsIfNeeded(); List out = new ArrayList(); if (!simAvailable) { return out; @@ -108,6 +138,7 @@ public List getAvailableBiometrics() { @Override public AsyncResource authenticate(AuthenticationOptions opts) { + installBuildHintsIfNeeded(); final AsyncResource result = new AsyncResource(); if (!simAvailable) { result.error(new BiometricException(BiometricError.NOT_AVAILABLE, diff --git a/docs/developer-guide/Biometric-Authentication.asciidoc b/docs/developer-guide/Biometric-Authentication.asciidoc new file mode 100644 index 0000000000..946e82057e --- /dev/null +++ b/docs/developer-guide/Biometric-Authentication.asciidoc @@ -0,0 +1,172 @@ +== Biometric Authentication + +Codename One ships first-class biometric authentication (Touch ID, Face ID, Android `BiometricPrompt`) under the `com.codename1.security` package. The API is modelled after Flutter's `local_auth`: a single entry point exposes the platform prompt, a typed enum reports the modalities currently enrolled, and a typed error enum lets callers react to failures without string matching. + +A sibling `SecureStorage` API stores secret strings (auth tokens, refresh tokens, encryption keys) behind biometric authentication using the iOS keychain or the Android Keystore. + +=== Quick start -- authenticate + +[source,java] +---- +import com.codename1.security.Biometrics; +import com.codename1.security.BiometricError; +import com.codename1.security.BiometricException; + +Biometrics b = Biometrics.getInstance(); +if (!b.canAuthenticate()) { + // Fall back to password + return; +} +b.authenticate("Unlock your account").onResult((success, err) -> { + if (err != null) { + BiometricError code = ((BiometricException) err).getError(); + switch (code) { + case USER_CANCELED: /* user dismissed the prompt */ break; + case LOCKED_OUT: /* too many bad attempts */ break; + case NOT_ENROLLED: /* prompt the user to enrol in Settings */ break; + default: /* generic failure */ break; + } + } else { + // Authenticated -- continue with the gated action + } +}); +---- + +Always gate the call on `canAuthenticate()` and never on `isSupported()` alone -- `isSupported()` only checks for hardware while `canAuthenticate()` also requires at least one enrolled credential and that the device is not in a locked-out state. + +=== Quick start -- secure storage + +[source,java] +---- +import com.codename1.security.SecureStorage; + +// Write (Android prompts; iOS does not unless ios.Fingerprint.addPassword.prompt=true) +SecureStorage.getInstance().set("Save your token", "user@example.com", token); + +// Read (prompts on both platforms) +SecureStorage.getInstance().get("Unlock your token", "user@example.com") + .onResult((value, err) -> { + if (err == null) { + // Use value + } + }); +---- + +Entries are bound to the current set of enrolled biometrics. If the user enrols a new fingerprint or face after writing, the next `get` fails with `BiometricError.KEY_REVOKED` and the application must re-prompt for the original value and write it again. + +=== Platform behaviour + +[options="header"] +|=== +| Platform | Implementation | Notes +| iOS | `LocalAuthentication.framework` (`LAContext`) + `Security.framework` keychain | Add the `ios.NSFaceIDUsageDescription` build hint when targeting Face ID hardware. +| Android API 29+ | `BiometricPrompt` | Face / iris / fingerprint per `PackageManager` features. +| Android API 23-28| `FingerprintManager` | Fingerprint only. +| JavaSE simulator | `Simulate -> Biometric Simulation` submenu | Toggle hardware availability, per-modality enrolment, and the next-call outcome. +| All other ports | Non-supporting fallback | `canAuthenticate()` returns `false`; `authenticate()` completes with `BiometricError.NOT_AVAILABLE`. +|=== + +Application code never needs a platform `if` -- the API itself returns the right thing on each port. On platforms without biometric hardware, the fallback simply reports `NOT_AVAILABLE` immediately. + +=== Build hints + +The Codename One Maven plugin (and the legacy build daemon) **automatically inject** the platform configuration when the app's bytecode references `com.codename1.security`: + +- **iOS:** `LocalAuthentication.framework` is linked into the generated Xcode project. +- **Android:** `` (and `USE_FINGERPRINT` with `maxSdkVersion=28` for the legacy path) is injected into `AndroidManifest.xml`. + +Apps that never touch the API pay nothing -- no extra framework, no extra permission. + +One thing the plugin will not inject: the iOS Face ID usage description. Apple rejects placeholder text, so you must supply the build hint yourself: + +[source,properties] +---- +ios.NSFaceIDUsageDescription=Authenticate to securely access your account +---- + +The JavaSE simulator auto-sets this hint to a default the first time biometrics is invoked, so projects developed in the simulator have a working default; you should overwrite the text before shipping. + +To share keychain entries with iOS App Extensions, set both the build hint and the runtime access group: + +[source,properties] +---- +ios.keychainAccessGroup=TEAMID123.group.com.example.app +---- + +[source,java] +---- +SecureStorage.getInstance().setKeychainAccessGroup("TEAMID123.group.com.example.app"); +---- + +The Team ID prefix is required; omitting it produces `errSecMissingEntitlement`. + +=== Configuring the prompt + +Use `AuthenticationOptions` to control prompt copy and security policy: + +[source,java] +---- +import com.codename1.security.AuthenticationOptions; + +Biometrics.getInstance().authenticate(new AuthenticationOptions() + .setReason("Authorize transfer") // iOS localizedReason; Android title fallback + .setTitle("Confirm payment") // Android only + .setSubtitle("Stripe charge $25.00") // Android only + .setNegativeButtonText("Cancel") // Android only + .setBiometricOnly(true) // reject PIN / passcode fallback + .setSensitiveTransaction(true) // request class-3 ("strong") biometric (Android 30+) +); +---- + +Unrecognised options are silently ignored, so callers can set the union without platform-checking. + +=== Typed error handling + +`BiometricException.getError()` returns a `BiometricError`: + +[options="header"] +|=== +| Code | Meaning +| `NOT_AVAILABLE` | No biometric hardware or the platform doesn't support it +| `NOT_ENROLLED` | Hardware present but the user hasn't enrolled +| `LOCKED_OUT` | Too many failed attempts; temporary +| `PERMANENTLY_LOCKED_OUT` | User must unlock with passcode before biometrics can be used again +| `PASSCODE_NOT_SET` | Device has no PIN / pattern configured (cannot use biometrics) +| `USER_CANCELED` | User dismissed the prompt +| `SYSTEM_CANCELED` | OS dismissed the prompt (app backgrounded, system pre-empted) +| `AUTHENTICATION_FAILED` | Prompt completed but the user was not recognised +| `KEY_REVOKED` | `SecureStorage` entry can no longer be decrypted (re-enrolment occurred) +| `UNKNOWN` | Anything not covered above +|=== + +=== Cancelling an in-flight prompt + +Call `Biometrics.getInstance().stopAuthentication()` to dismiss an active prompt; the pending `AsyncResource` then completes with `BiometricError.USER_CANCELED`. The method returns `true` if a prompt was cancelled, `false` if nothing was pending. + +=== Listing enrolled biometrics + +`getAvailableBiometrics()` returns a `List` to drive UI affordances (icon, prompt copy): + +- `FINGERPRINT` -- iOS Touch ID; Android fingerprint sensor. +- `FACE` -- iOS Face ID; Android face recognition (API 29+). +- `IRIS` -- a handful of Samsung devices; never on iOS. +- `STRONG` -- Android class-3 authenticator tier (false-acceptance < 1/100,000); only Android API 30+. +- `WEAK` -- Android class-2 authenticator tier; only Android API 30+. Cannot unlock Keystore-bound keys, so weak-only devices cannot use `SecureStorage`. + +Never use this list to decide whether to call `authenticate()` -- always use `canAuthenticate()` for that decision. + +=== Simulator workflow + +The JavaSE simulator's `Simulate -> Biometric Simulation` submenu lets you exercise every code path without a device: + +- **Hardware Available** -- toggles `isSupported()` and `canAuthenticate()`. +- **Face ID Enrolled / Touch ID Enrolled / Iris Enrolled** -- populate `getAvailableBiometrics()`. +- **Next authenticate() Outcome** -- radio button that picks the outcome of the next prompt: `SUCCEED`, `FAIL`, `CANCEL`, `LOCKED_OUT`, `PERMANENTLY_LOCKED_OUT`, `NOT_ENROLLED`, `PASSCODE_NOT_SET`. Sticky -- change it any time. + +When `authenticate()` is invoked, a small modal Swing dialog mirrors the platform prompt so the developer can see the trigger fire and click through. State persists across simulator restarts via `java.util.prefs`. + +`SecureStorage` round-trips against `java.util.prefs` in the simulator, so you can test the full `set` -> `get` -> `KEY_REVOKED` flow end-to-end. + +=== Security notes + +The Android backing for `authenticate()` does not simply forward the OS success callback; it binds the prompt to a single-use AES probe cipher in the Android Keystore (with `setUserAuthenticationRequired(true)` and, on API 24+, `setInvalidatedByBiometricEnrollment(true)`). On success the cipher performs a real `doFinal` operation -- a hooked or spoofed success callback fails because the Keystore refuses the operation. This guards against the bypass class documented by CodeQL's `java/android/insecure-local-authentication` rule. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 13c8be99d4..635d2e90d9 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -82,6 +82,8 @@ include::Advanced-Topics-Under-The-Hood.asciidoc[] include::security.asciidoc[] +include::Biometric-Authentication.asciidoc[] + include::signing.asciidoc[] include::Working-With-iOS.asciidoc[] diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index e48d0253bd..0dd4c4b3ea 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -281,6 +281,7 @@ public File getGradleProjectDirectory() { private String playFlag; private boolean capturePermission; + private boolean usesBiometrics; private boolean vibratePermission; private boolean smsPermission; private boolean gpsPermission; @@ -734,17 +735,6 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc // Augment the xpermissions request arg with explicit android.permissions.XXX build hints xPermissions = request.getArg("android.xpermissions", ""); - // USE_BIOMETRIC is a "normal" permission (no runtime prompt) required by - // com.codename1.security.Biometrics. Inject it unconditionally so apps - // don't have to remember the build hint; if the user already declared - // it in xpermissions we leave their version alone. - if (!xPermissions.contains("android.permission.USE_BIOMETRIC")) { - xPermissions = " \n" + xPermissions; - } - if (!xPermissions.contains("android.permission.USE_FINGERPRINT")) { - xPermissions = " \n" + xPermissions; - } - debug("Adding android permissions..."); for (String xPerm : ANDROID_PERMISSIONS) { String permName = xPerm.substring(xPerm.lastIndexOf(".")+1); @@ -1259,6 +1249,9 @@ public void usesClass(String cls) { getAccountsPermission = true; } + if (cls.indexOf("com/codename1/security/") == 0) { + usesBiometrics = true; + } } @@ -1351,6 +1344,22 @@ public void usesClassMethod(String cls, String method) { } catch (IOException ex) { throw new BuildException("An error occurred while trying to scan the classes for API usage.", ex); } + + // Inject USE_BIOMETRIC / USE_FINGERPRINT only when the app actually + // touches com.codename1.security (Biometrics / SecureStorage). Both + // are "normal" permissions (no runtime prompt) so injecting them + // when used is invisible to the user; apps that never reference the + // API see no manifest change. If the developer already declared + // either permission via android.xpermissions we leave it alone. + if (usesBiometrics) { + if (!xPermissions.contains("android.permission.USE_BIOMETRIC")) { + xPermissions = " \n" + xPermissions; + } + if (!xPermissions.contains("android.permission.USE_FINGERPRINT")) { + xPermissions = " \n" + xPermissions; + } + } + boolean useFCM = pushPermission && "fcm".equalsIgnoreCase(request.getArg("android.messagingService", "fcm")); if (useFCM) { request.putArgument("android.fcm.minPlayServicesVersion", "12.0.1"); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index e4a13d9948..b48bd94c96 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -83,6 +83,7 @@ public class IPhoneBuilder extends Executor { private String buildVersion; private boolean usesLocalNotifications; private boolean usesPurchaseAPI; + private boolean usesBiometrics; // so we need to store the main class name for later here. // Map will be used for Xcode 8 privacy usage descriptions. Don't need it yet // so leaving it commented out. @@ -646,6 +647,9 @@ public void usesClass(String cls) { if (!usesPurchaseAPI && cls.indexOf("com/codename1/payment") == 0) { usesPurchaseAPI = true; } + if (!usesBiometrics && cls.indexOf("com/codename1/security/") == 0) { + usesBiometrics = true; + } } @Override @@ -1565,13 +1569,16 @@ public void usesClassMethod(String cls, String method) { } } - // LocalAuthentication is required by com.codename1.security.Biometrics - // (Touch ID / Face ID). Always link it -- the framework is ubiquitous - // (iOS 8+) and tiny, and apps that never call Biometrics pay nothing. - if (addLibs == null || addLibs.length() == 0) { - addLibs = "LocalAuthentication.framework"; - } else if (!addLibs.toLowerCase().contains("localauthentication")) { - addLibs = addLibs + ";LocalAuthentication.framework"; + // LocalAuthentication is required only when the app actually uses + // com.codename1.security.Biometrics / SecureStorage. The scanner + // above sets usesBiometrics if any com/codename1/security/ class + // is referenced; apps that don't touch the API pay nothing. + if (usesBiometrics) { + if (addLibs == null || addLibs.length() == 0) { + addLibs = "LocalAuthentication.framework"; + } else if (!addLibs.toLowerCase().contains("localauthentication")) { + addLibs = addLibs + ";LocalAuthentication.framework"; + } } try { From 23055dc26a807cca49a8203418ac42a4166d8483 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 20 May 2026 22:58:36 +0300 Subject: [PATCH 08/12] Biometrics: Java-1.6 source compatibility + Vale style cleanup build-test (8) runs the Android port Ant build with -source 1.6 (the Maven build uses 1.8, so my local checks missed this). Revert the Java-8 lambdas added in the previous commit to named private static inner classes -- same SpotBugs SIC_INNER_SHOULD_BE_STATIC_ANON outcome, Java 1.6 compatible. Also clean up the new docs/developer-guide/Biometric-Authentication asciidoc against the project's Vale style ruleset: 8 Microsoft. Contractions findings (is not / will not / cannot / does not / was not must be contractions), one Microsoft.Auto (don't hyphenate "auto-sets"), one Microsoft.Adverbs ("silently" removed). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../impl/android/AndroidBiometrics.java | 37 +++++++++-- .../impl/android/AndroidSecureStorage.java | 64 ++++++++++++++++--- .../Biometric-Authentication.asciidoc | 16 ++--- 3 files changed, 94 insertions(+), 23 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java index c0c60ca791..2f484850eb 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java @@ -125,10 +125,17 @@ public List getAvailableBiometrics() { if (Build.VERSION.SDK_INT < 23) { return out; } - runOnUi(() -> collectAvailableBiometrics(out)); + runOnUi(new CollectAvailableBiometricsRunnable(out)); return out; } + private static final class CollectAvailableBiometricsRunnable implements Runnable { + private final List out; + CollectAvailableBiometricsRunnable(List out) { this.out = out; } + @Override + public void run() { collectAvailableBiometrics(out); } + } + private static void collectAvailableBiometrics(List out) { try { Activity act = AndroidNativeUtil.getActivity(); @@ -399,11 +406,18 @@ void completeSuccess(final AsyncResource result) { return; } pending = null; - Display.getInstance().callSerially(() -> { + Display.getInstance().callSerially(new CompleteSuccessRunnable(result)); + } + + private static final class CompleteSuccessRunnable implements Runnable { + private final AsyncResource result; + CompleteSuccessRunnable(AsyncResource result) { this.result = result; } + @Override + public void run() { if (!result.isDone()) { result.complete(Boolean.TRUE); } - }); + } } void completeError(final AsyncResource result, @@ -412,11 +426,24 @@ void completeError(final AsyncResource result, return; } pending = null; - Display.getInstance().callSerially(() -> { + Display.getInstance().callSerially(new CompleteErrorRunnable(result, err, msg)); + } + + private static final class CompleteErrorRunnable implements Runnable { + private final AsyncResource result; + private final BiometricError err; + private final String msg; + CompleteErrorRunnable(AsyncResource result, BiometricError err, String msg) { + this.result = result; + this.err = err; + this.msg = msg; + } + @Override + public void run() { if (!result.isDone()) { result.error(new BiometricException(err, msg)); } - }); + } } static BiometricError mapBiometricPromptError(int code) { diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java index eef2713f3f..c1329f53e7 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java @@ -105,7 +105,20 @@ public AsyncResource set(final String reason, final String account, fin "Android API 23 required for biometric secure storage")); return result; } - runAuthenticatedCipher(reason, account, Cipher.ENCRYPT_MODE, result, c -> { + runAuthenticatedCipher(reason, account, Cipher.ENCRYPT_MODE, result, + new EncryptCipherWork(account, value)); + return result; + } + + private static final class EncryptCipherWork implements CipherWork { + private final String account; + private final String value; + EncryptCipherWork(String account, String value) { + this.account = account; + this.value = value; + } + @Override + public Boolean run(Cipher c) throws Exception { byte[] enc = c.doFinal(value.getBytes("UTF-8")); SharedPreferences sp = AndroidNativeUtil.getActivity() .getApplicationContext() @@ -115,8 +128,7 @@ public AsyncResource set(final String reason, final String account, fin .putString("iv_" + account, Base64.encodeToString(c.getIV(), Base64.DEFAULT)) .apply(); return Boolean.TRUE; - }); - return result; + } } @Override @@ -135,15 +147,23 @@ public AsyncResource get(final String reason, final String account) { "No secure storage entry for account: " + account)); return result; } - runAuthenticatedCipher(reason, account, Cipher.DECRYPT_MODE, result, c -> { + runAuthenticatedCipher(reason, account, Cipher.DECRYPT_MODE, result, + new DecryptCipherWork(account)); + return result; + } + + private static final class DecryptCipherWork implements CipherWork { + private final String account; + DecryptCipherWork(String account) { this.account = account; } + @Override + public String run(Cipher c) throws Exception { SharedPreferences sp2 = AndroidNativeUtil.getActivity() .getApplicationContext() .getSharedPreferences(PREFS, Context.MODE_PRIVATE); byte[] enc = Base64.decode(sp2.getString("v_" + account, ""), Base64.DEFAULT); byte[] dec = c.doFinal(enc); return new String(dec, "UTF-8"); - }); - return result; + } } @Override @@ -300,20 +320,44 @@ private void runCipherWork(Cipher authedCipher, CipherWork work, } private void succeedResult(final AsyncResource result, final V value) { - Display.getInstance().callSerially(() -> { + Display.getInstance().callSerially(new SucceedResultRunnable(result, value)); + } + + private static final class SucceedResultRunnable implements Runnable { + private final AsyncResource result; + private final V value; + SucceedResultRunnable(AsyncResource result, V value) { + this.result = result; + this.value = value; + } + @Override + public void run() { if (!result.isDone()) { result.complete(value); } - }); + } } private static void failResult(final AsyncResource result, final BiometricError err, final String msg) { - Display.getInstance().callSerially(() -> { + Display.getInstance().callSerially(new FailResultRunnable(result, err, msg)); + } + + private static final class FailResultRunnable implements Runnable { + private final AsyncResource result; + private final BiometricError err; + private final String msg; + FailResultRunnable(AsyncResource result, BiometricError err, String msg) { + this.result = result; + this.err = err; + this.msg = msg; + } + @Override + public void run() { if (!result.isDone()) { result.error(new BiometricException(err, msg)); } - }); + } } // --- Keystore / cipher helpers (faithful port of the cn1lib idioms) ----- diff --git a/docs/developer-guide/Biometric-Authentication.asciidoc b/docs/developer-guide/Biometric-Authentication.asciidoc index 946e82057e..ee565c7aff 100644 --- a/docs/developer-guide/Biometric-Authentication.asciidoc +++ b/docs/developer-guide/Biometric-Authentication.asciidoc @@ -32,7 +32,7 @@ b.authenticate("Unlock your account").onResult((success, err) -> { }); ---- -Always gate the call on `canAuthenticate()` and never on `isSupported()` alone -- `isSupported()` only checks for hardware while `canAuthenticate()` also requires at least one enrolled credential and that the device is not in a locked-out state. +Always gate the call on `canAuthenticate()` and never on `isSupported()` alone -- `isSupported()` only checks for hardware while `canAuthenticate()` also requires at least one enrolled credential and that the device isn't in a locked-out state. === Quick start -- secure storage @@ -77,14 +77,14 @@ The Codename One Maven plugin (and the legacy build daemon) **automatically inje Apps that never touch the API pay nothing -- no extra framework, no extra permission. -One thing the plugin will not inject: the iOS Face ID usage description. Apple rejects placeholder text, so you must supply the build hint yourself: +One thing the plugin won't inject: the iOS Face ID usage description. Apple rejects placeholder text, so you must supply the build hint yourself: [source,properties] ---- ios.NSFaceIDUsageDescription=Authenticate to securely access your account ---- -The JavaSE simulator auto-sets this hint to a default the first time biometrics is invoked, so projects developed in the simulator have a working default; you should overwrite the text before shipping. +The JavaSE simulator automatically sets this hint to a default the first time biometrics is invoked, so projects developed in the simulator have a working default; you should overwrite the text before shipping. To share keychain entries with iOS App Extensions, set both the build hint and the runtime access group: @@ -118,7 +118,7 @@ Biometrics.getInstance().authenticate(new AuthenticationOptions() ); ---- -Unrecognised options are silently ignored, so callers can set the union without platform-checking. +Unrecognised options are ignored, so callers can set the union without platform-checking. === Typed error handling @@ -131,10 +131,10 @@ Unrecognised options are silently ignored, so callers can set the union without | `NOT_ENROLLED` | Hardware present but the user hasn't enrolled | `LOCKED_OUT` | Too many failed attempts; temporary | `PERMANENTLY_LOCKED_OUT` | User must unlock with passcode before biometrics can be used again -| `PASSCODE_NOT_SET` | Device has no PIN / pattern configured (cannot use biometrics) +| `PASSCODE_NOT_SET` | Device has no PIN / pattern configured (can't use biometrics) | `USER_CANCELED` | User dismissed the prompt | `SYSTEM_CANCELED` | OS dismissed the prompt (app backgrounded, system pre-empted) -| `AUTHENTICATION_FAILED` | Prompt completed but the user was not recognised +| `AUTHENTICATION_FAILED` | Prompt completed but the user wasn't recognised | `KEY_REVOKED` | `SecureStorage` entry can no longer be decrypted (re-enrolment occurred) | `UNKNOWN` | Anything not covered above |=== @@ -151,7 +151,7 @@ Call `Biometrics.getInstance().stopAuthentication()` to dismiss an active prompt - `FACE` -- iOS Face ID; Android face recognition (API 29+). - `IRIS` -- a handful of Samsung devices; never on iOS. - `STRONG` -- Android class-3 authenticator tier (false-acceptance < 1/100,000); only Android API 30+. -- `WEAK` -- Android class-2 authenticator tier; only Android API 30+. Cannot unlock Keystore-bound keys, so weak-only devices cannot use `SecureStorage`. +- `WEAK` -- Android class-2 authenticator tier; only Android API 30+. Can't unlock Keystore-bound keys, so weak-only devices can't use `SecureStorage`. Never use this list to decide whether to call `authenticate()` -- always use `canAuthenticate()` for that decision. @@ -169,4 +169,4 @@ When `authenticate()` is invoked, a small modal Swing dialog mirrors the platfor === Security notes -The Android backing for `authenticate()` does not simply forward the OS success callback; it binds the prompt to a single-use AES probe cipher in the Android Keystore (with `setUserAuthenticationRequired(true)` and, on API 24+, `setInvalidatedByBiometricEnrollment(true)`). On success the cipher performs a real `doFinal` operation -- a hooked or spoofed success callback fails because the Keystore refuses the operation. This guards against the bypass class documented by CodeQL's `java/android/insecure-local-authentication` rule. +The Android backing for `authenticate()` doesn't simply forward the OS success callback; it binds the prompt to a single-use AES probe cipher in the Android Keystore (with `setUserAuthenticationRequired(true)` and, on API 24+, `setInvalidatedByBiometricEnrollment(true)`). On success the cipher performs a real `doFinal` operation -- a hooked or spoofed success callback fails because the Keystore refuses the operation. This guards against the bypass class documented by CodeQL's `java/android/insecure-local-authentication` rule. From 24994be6602272b839cab66d5b4bde9eb0cc5738 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 21 May 2026 07:44:48 +0300 Subject: [PATCH 09/12] Biometrics docs: drop Flutter mention, em-dashes, "legacy" build daemon Three editorial cleanups requested in PR review: 1. Drop the comparison to Flutter local_auth in Biometrics.java javadoc and in the developer guide intro. The API stands on its own. 2. Avoid asciidoc em-dashes (--) throughout the new chapter. Replace with colons in headings and definition lists, semicolons or full sentences in prose. The four-dash code-block delimiters (----) are intentional asciidoc syntax and remain. 3. Drop the "legacy build daemon" wording. The build daemon is the build server and is not legacy. Reword to "the Codename One Maven plugin and the build daemon both automatically inject ...". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/codename1/security/Biometrics.java | 2 -- .../Biometric-Authentication.asciidoc | 36 +++++++++---------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/CodenameOne/src/com/codename1/security/Biometrics.java b/CodenameOne/src/com/codename1/security/Biometrics.java index 7c29e8f26c..8e822900ac 100644 --- a/CodenameOne/src/com/codename1/security/Biometrics.java +++ b/CodenameOne/src/com/codename1/security/Biometrics.java @@ -72,8 +72,6 @@ /// [#authenticate(String)] completes with [BiometricError#NOT_AVAILABLE]. /// Application code does not need platform `if` statements -- always /// gate biometrics on [#canAuthenticate()] before invoking the prompt. -/// -/// This class is the Codename One parallel of Flutter's `local_auth` API. public class Biometrics { /// Subclasses are constructed by the port. Application code obtains the diff --git a/docs/developer-guide/Biometric-Authentication.asciidoc b/docs/developer-guide/Biometric-Authentication.asciidoc index ee565c7aff..7d0d010c73 100644 --- a/docs/developer-guide/Biometric-Authentication.asciidoc +++ b/docs/developer-guide/Biometric-Authentication.asciidoc @@ -1,10 +1,10 @@ == Biometric Authentication -Codename One ships first-class biometric authentication (Touch ID, Face ID, Android `BiometricPrompt`) under the `com.codename1.security` package. The API is modelled after Flutter's `local_auth`: a single entry point exposes the platform prompt, a typed enum reports the modalities currently enrolled, and a typed error enum lets callers react to failures without string matching. +Codename One ships first-class biometric authentication (Touch ID, Face ID, Android `BiometricPrompt`) under the `com.codename1.security` package. A single entry point exposes the platform prompt, a typed enum reports the modalities currently enrolled, and a typed error enum lets callers react to failures without string matching. A sibling `SecureStorage` API stores secret strings (auth tokens, refresh tokens, encryption keys) behind biometric authentication using the iOS keychain or the Android Keystore. -=== Quick start -- authenticate +=== Quick start: authenticate [source,java] ---- @@ -27,14 +27,14 @@ b.authenticate("Unlock your account").onResult((success, err) -> { default: /* generic failure */ break; } } else { - // Authenticated -- continue with the gated action + // Authenticated. Continue with the gated action. } }); ---- -Always gate the call on `canAuthenticate()` and never on `isSupported()` alone -- `isSupported()` only checks for hardware while `canAuthenticate()` also requires at least one enrolled credential and that the device isn't in a locked-out state. +Always gate the call on `canAuthenticate()` and never on `isSupported()` alone. `isSupported()` only checks for hardware, while `canAuthenticate()` also requires at least one enrolled credential and that the device isn't in a locked-out state. -=== Quick start -- secure storage +=== Quick start: secure storage [source,java] ---- @@ -66,16 +66,16 @@ Entries are bound to the current set of enrolled biometrics. If the user enrols | All other ports | Non-supporting fallback | `canAuthenticate()` returns `false`; `authenticate()` completes with `BiometricError.NOT_AVAILABLE`. |=== -Application code never needs a platform `if` -- the API itself returns the right thing on each port. On platforms without biometric hardware, the fallback simply reports `NOT_AVAILABLE` immediately. +Application code never needs a platform `if`. The API itself returns the right thing on each port. On platforms without biometric hardware, the fallback reports `NOT_AVAILABLE` immediately. === Build hints -The Codename One Maven plugin (and the legacy build daemon) **automatically inject** the platform configuration when the app's bytecode references `com.codename1.security`: +The Codename One Maven plugin and the build daemon both **automatically inject** the platform configuration when the app's bytecode references `com.codename1.security`: - **iOS:** `LocalAuthentication.framework` is linked into the generated Xcode project. - **Android:** `` (and `USE_FINGERPRINT` with `maxSdkVersion=28` for the legacy path) is injected into `AndroidManifest.xml`. -Apps that never touch the API pay nothing -- no extra framework, no extra permission. +Apps that never touch the API pay nothing: no extra framework, no extra permission. One thing the plugin won't inject: the iOS Face ID usage description. Apple rejects placeholder text, so you must supply the build hint yourself: @@ -147,21 +147,21 @@ Call `Biometrics.getInstance().stopAuthentication()` to dismiss an active prompt `getAvailableBiometrics()` returns a `List` to drive UI affordances (icon, prompt copy): -- `FINGERPRINT` -- iOS Touch ID; Android fingerprint sensor. -- `FACE` -- iOS Face ID; Android face recognition (API 29+). -- `IRIS` -- a handful of Samsung devices; never on iOS. -- `STRONG` -- Android class-3 authenticator tier (false-acceptance < 1/100,000); only Android API 30+. -- `WEAK` -- Android class-2 authenticator tier; only Android API 30+. Can't unlock Keystore-bound keys, so weak-only devices can't use `SecureStorage`. +- `FINGERPRINT`: iOS Touch ID; Android fingerprint sensor. +- `FACE`: iOS Face ID; Android face recognition (API 29+). +- `IRIS`: a handful of Samsung devices; never on iOS. +- `STRONG`: Android class-3 authenticator tier (false-acceptance < 1/100,000); only Android API 30+. +- `WEAK`: Android class-2 authenticator tier; only Android API 30+. Can't unlock Keystore-bound keys, so weak-only devices can't use `SecureStorage`. -Never use this list to decide whether to call `authenticate()` -- always use `canAuthenticate()` for that decision. +Never use this list to decide whether to call `authenticate()`; always use `canAuthenticate()` for that decision. === Simulator workflow The JavaSE simulator's `Simulate -> Biometric Simulation` submenu lets you exercise every code path without a device: -- **Hardware Available** -- toggles `isSupported()` and `canAuthenticate()`. -- **Face ID Enrolled / Touch ID Enrolled / Iris Enrolled** -- populate `getAvailableBiometrics()`. -- **Next authenticate() Outcome** -- radio button that picks the outcome of the next prompt: `SUCCEED`, `FAIL`, `CANCEL`, `LOCKED_OUT`, `PERMANENTLY_LOCKED_OUT`, `NOT_ENROLLED`, `PASSCODE_NOT_SET`. Sticky -- change it any time. +- **Hardware Available**: toggles `isSupported()` and `canAuthenticate()`. +- **Face ID Enrolled / Touch ID Enrolled / Iris Enrolled**: populate `getAvailableBiometrics()`. +- **Next authenticate() Outcome**: radio button that picks the outcome of the next prompt: `SUCCEED`, `FAIL`, `CANCEL`, `LOCKED_OUT`, `PERMANENTLY_LOCKED_OUT`, `NOT_ENROLLED`, `PASSCODE_NOT_SET`. The selection is sticky; change it any time. When `authenticate()` is invoked, a small modal Swing dialog mirrors the platform prompt so the developer can see the trigger fire and click through. State persists across simulator restarts via `java.util.prefs`. @@ -169,4 +169,4 @@ When `authenticate()` is invoked, a small modal Swing dialog mirrors the platfor === Security notes -The Android backing for `authenticate()` doesn't simply forward the OS success callback; it binds the prompt to a single-use AES probe cipher in the Android Keystore (with `setUserAuthenticationRequired(true)` and, on API 24+, `setInvalidatedByBiometricEnrollment(true)`). On success the cipher performs a real `doFinal` operation -- a hooked or spoofed success callback fails because the Keystore refuses the operation. This guards against the bypass class documented by CodeQL's `java/android/insecure-local-authentication` rule. +The Android backing for `authenticate()` doesn't simply forward the OS success callback; it binds the prompt to a single-use AES probe cipher in the Android Keystore (with `setUserAuthenticationRequired(true)` and, on API 24+, `setInvalidatedByBiometricEnrollment(true)`). On success the cipher performs a real `doFinal` operation. A hooked or spoofed success callback fails because the Keystore refuses the operation. This guards against the bypass class documented by CodeQL's `java/android/insecure-local-authentication` rule. From 71f71bd3fee225a8122d899ba62b45eef9c8ba0a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 21 May 2026 07:48:00 +0300 Subject: [PATCH 10/12] JavaSEPort: install ios.NSFaceIDUsageDescription on first biometrics use The previous round of feedback flagged that the build-hint injection wasn't visible in JavaSEPort -- it was buried in JavaSEBiometrics' public methods. Move the logic to JavaSEPort.installBiometricsBuildHints IfNeeded() and call it from getBiometrics() and getSecureStorage(). The semantics are unchanged: the first time the application touches either API in the simulator we detect whether ios.NSFaceIDUsageDescription is set on the project; if not, write a placeholder so the next iOS device build doesn't crash. The developer should overwrite the placeholder text with their app-specific localised reason before shipping (Apple rejects builds with the placeholder default). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../impl/javase/JavaSEBiometrics.java | 31 ----------------- .../com/codename1/impl/javase/JavaSEPort.java | 34 +++++++++++++++++++ 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEBiometrics.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEBiometrics.java index c70606ba69..64e18faea7 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEBiometrics.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEBiometrics.java @@ -28,7 +28,6 @@ import com.codename1.security.BiometricType; import com.codename1.security.Biometrics; import com.codename1.ui.CN; -import com.codename1.ui.Display; import com.codename1.util.AsyncResource; import javax.swing.JButton; @@ -43,7 +42,6 @@ import java.awt.event.ActionListener; import java.util.ArrayList; import java.util.List; -import java.util.Map; /** * Simulator backing for {@link Biometrics}. State is mutated by the @@ -75,51 +73,23 @@ public enum SimOutcome { private volatile AsyncResource pending; private volatile JDialog pendingDialog; - private boolean buildHintsInstalled; JavaSEBiometrics() { } - /// Mirrors the historical FingerprintScanner cn1lib pattern: the first - /// time the application touches Biometrics in the simulator, declare - /// the iOS Face ID usage description build hint on the project. The - /// device builders also auto-inject what's needed (LocalAuthentication - /// framework, USE_BIOMETRIC permission), but `NSFaceIDUsageDescription` - /// must contain app-specific localised text that Apple rejects - /// placeholder defaults for, so we only add it if the developer hasn't - /// already supplied one. - private void installBuildHintsIfNeeded() { - if (buildHintsInstalled) { - return; - } - buildHintsInstalled = true; - Map existing = Display.getInstance().getProjectBuildHints(); - if (existing == null) { - return; - } - if (!existing.containsKey("ios.NSFaceIDUsageDescription")) { - Display.getInstance().setProjectBuildHint( - "ios.NSFaceIDUsageDescription", - "Authenticate to securely access your account"); - } - } - @Override public boolean isSupported() { - installBuildHintsIfNeeded(); return simAvailable; } @Override public boolean canAuthenticate() { - installBuildHintsIfNeeded(); return simAvailable && (simFaceEnrolled || simTouchEnrolled || simIrisEnrolled); } @Override public List getAvailableBiometrics() { - installBuildHintsIfNeeded(); List out = new ArrayList(); if (!simAvailable) { return out; @@ -138,7 +108,6 @@ public List getAvailableBiometrics() { @Override public AsyncResource authenticate(AuthenticationOptions opts) { - installBuildHintsIfNeeded(); final AsyncResource result = new AsyncResource(); if (!simAvailable) { result.error(new BiometricException(BiometricError.NOT_AVAILABLE, diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index eaac4b1d38..23728c9ee5 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -11811,9 +11811,11 @@ public String[] getPlatformOverrides() { private JavaSEBiometrics biometrics; private JavaSESecureStorage secureStorage; + private boolean biometricsBuildHintsInstalled; @Override public Biometrics getBiometrics() { + installBiometricsBuildHintsIfNeeded(); if (biometrics == null) { biometrics = new JavaSEBiometrics(); } @@ -11822,12 +11824,44 @@ public Biometrics getBiometrics() { @Override public SecureStorage getSecureStorage() { + installBiometricsBuildHintsIfNeeded(); if (secureStorage == null) { secureStorage = new JavaSESecureStorage((JavaSEBiometrics) getBiometrics()); } return secureStorage; } + /** + * The first time the app reaches the biometric APIs in the simulator, + * add the iOS Face ID usage description to {@code codenameone_settings.properties} + * if the developer hasn't supplied one. Apple rejects builds that present + * the Face ID prompt without {@code NSFaceIDUsageDescription} set, so this + * keeps simulator-developed projects buildable on iOS without the user + * having to remember the build hint. They should overwrite the placeholder + * text before shipping. + */ + private void installBiometricsBuildHintsIfNeeded() { + if (biometricsBuildHintsInstalled) { + return; + } + biometricsBuildHintsInstalled = true; + Map existing = getProjectBuildHints(); + if (existing == null) { + return; + } + if (!existing.containsKey("ios.NSFaceIDUsageDescription")) { + try { + setProjectBuildHint( + "ios.NSFaceIDUsageDescription", + "Authenticate to securely access your account"); + } catch (RuntimeException ignore) { + // codenameone_settings.properties became unwritable between + // the read above and the write here; not fatal -- the device + // builder will warn if the hint is missing. + } + } + } + public LocationManager getLocationManager() { if(!checkForPermission("android.permission.ACCESS_FINE_LOCATION", "This is required to get the location")){ return null; From c68f0778c77ee1d1db6a5fbe096c259408afdd30 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 21 May 2026 08:36:51 +0300 Subject: [PATCH 11/12] Biometric docs: capitalise word after colon in subheadings Vale's Microsoft.HeadingColons rule flags lowercase first words after a heading colon. Capitalise "Authenticate" and "Secure storage" in the two Quick start subsection titles. Fallout from the em-dash removal in the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/developer-guide/Biometric-Authentication.asciidoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/developer-guide/Biometric-Authentication.asciidoc b/docs/developer-guide/Biometric-Authentication.asciidoc index 7d0d010c73..83908cbacc 100644 --- a/docs/developer-guide/Biometric-Authentication.asciidoc +++ b/docs/developer-guide/Biometric-Authentication.asciidoc @@ -4,7 +4,7 @@ Codename One ships first-class biometric authentication (Touch ID, Face ID, Andr A sibling `SecureStorage` API stores secret strings (auth tokens, refresh tokens, encryption keys) behind biometric authentication using the iOS keychain or the Android Keystore. -=== Quick start: authenticate +=== Quick start: Authenticate [source,java] ---- @@ -34,7 +34,7 @@ b.authenticate("Unlock your account").onResult((success, err) -> { Always gate the call on `canAuthenticate()` and never on `isSupported()` alone. `isSupported()` only checks for hardware, while `canAuthenticate()` also requires at least one enrolled credential and that the device isn't in a locked-out state. -=== Quick start: secure storage +=== Quick start: Secure storage [source,java] ---- From e3e1da2fd4bda010fbd6d25c215d9e1fedfa07bd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 21 May 2026 09:54:54 +0300 Subject: [PATCH 12/12] Playground: exclude new com.codename1.security types from CN1 registry The TeaVM cloud-build that ships the playground crashed mid-compile on the previous commit (RMI EOFException from the build daemon). The new com.codename1.security classes (Biometrics, SecureStorage and their helper types) are present in the core jar the playground depends on, and the generated CN1 reflection bridge in tools/GenerateCN1AccessRegistry attempts to expose them. The cloud TeaVM backend doesn't yet know how to handle the new bridge classes, so the daemon dies before the playground can finish building. Add the six new types to INTERNAL_CN1_TYPES (the same exclusion list that already carries Simd, Accessor and IOAccessor) so the registry generator skips them. This is a one-release workaround: once the backend cloud build server is updated and rolls a new plugin, the exclusions can be removed and biometrics will work inside playground scripts too. Apps built off the device-side builders are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tools/GenerateCN1AccessRegistry.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/cn1playground/tools/src/main/java/com/codenameone/playground/tools/GenerateCN1AccessRegistry.java b/scripts/cn1playground/tools/src/main/java/com/codenameone/playground/tools/GenerateCN1AccessRegistry.java index 533e130e11..5c463ca65c 100644 --- a/scripts/cn1playground/tools/src/main/java/com/codenameone/playground/tools/GenerateCN1AccessRegistry.java +++ b/scripts/cn1playground/tools/src/main/java/com/codenameone/playground/tools/GenerateCN1AccessRegistry.java @@ -59,7 +59,20 @@ public final class GenerateCN1AccessRegistry { // it by returning it from invokeN. Exclude the whole class from // the bean-shell registry - Simd is a low-level SIMD primitives // API that playground scripts are extremely unlikely to need. - "com.codename1.util.Simd" + "com.codename1.util.Simd", + // com.codename1.security.* is a newly-introduced API + // (Biometrics, SecureStorage and friends). Until the cloud build + // server / TeaVM backend that compiles the playground catches up + // with the new classes the bridge generation trips the TeaVM + // RMI daemon. Exclude the new types from the registry for one + // release; the API is still usable in real apps, just not in the + // playground sandbox. + "com.codename1.security.Biometrics", + "com.codename1.security.SecureStorage", + "com.codename1.security.AuthenticationOptions", + "com.codename1.security.BiometricType", + "com.codename1.security.BiometricError", + "com.codename1.security.BiometricException" )); private static final String[] INDEX_PACKAGE_PREFIXES = new String[]{