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/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..8d159ea7d5 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/AuthenticationOptions.java @@ -0,0 +1,179 @@ +/* + * 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 [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 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; + 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; + + /// 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. 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 (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 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 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"`). 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. 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 (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 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; + } + + /// 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. 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/BiometricError.java b/CodenameOne/src/com/codename1/security/BiometricError.java new file mode 100644 index 0000000000..d24f69e741 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/BiometricError.java @@ -0,0 +1,63 @@ +/* + * 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 [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. + 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 [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..a5761b87ab --- /dev/null +++ b/CodenameOne/src/com/codename1/security/BiometricException.java @@ -0,0 +1,52 @@ +/* + * 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 `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; + + 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 `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..524eff0822 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/BiometricType.java @@ -0,0 +1,71 @@ +/* + * 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 +/// [Biometrics#getAvailableBiometrics()]. +/// +/// 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 new file mode 100644 index 0000000000..8e822900ac --- /dev/null +++ b/CodenameOne/src/com/codename1/security/Biometrics.java @@ -0,0 +1,158 @@ +/* + * 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.Collections; +import java.util.List; + +/// 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]. +/// +/// #### 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. +public class Biometrics { + + /// 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. + /// 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 : 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 + /// [#authenticate(AuthenticationOptions)] when `canAuthenticate()` is + /// 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. 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. + /// + /// #### Returns + /// + /// an empty list when nothing is enrolled or the device is unsupported + 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 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) { + return authenticate(new AuthenticationOptions().setReason(reason)); + } + + /// 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. 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 new file mode 100644 index 0000000000..6b3d2ff1aa --- /dev/null +++ b/CodenameOne/src/com/codename1/security/SecureStorage.java @@ -0,0 +1,127 @@ +/* + * 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, 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. +/// +/// #### 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. Application code obtains the + /// active instance via [#getInstance()]. + protected SecureStorage() { + } + + /// Returns the platform-specific singleton owned by the current port. + /// 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 : 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 + /// [BiometricError#KEY_REVOKED] when biometrics have been re-enrolled + /// 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. 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. 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 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 void setKeychainAccessGroup(String group) { + // No-op fallback. + } +} 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..2f484850eb --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidBiometrics.java @@ -0,0 +1,518 @@ +/* + * 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 android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyPermanentlyInvalidatedException; +import android.security.keystore.KeyProperties; + +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.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 + * 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; + + // 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; + 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 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(); + 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(); + } + 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 + 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; + // 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 + public void run() { + if (cancellationSignal != null) { + cancellationSignal.cancel(); + } + cancellationSignal = new CancellationSignal(); + BiometricsApi29.authenticateWithCipher( + AndroidNativeUtil.getActivity(), + title, subtitle, description, negative, + probeCipher, + cancellationSignal, + new BiometricsApi29.CipherAuthCallback() { + @Override + 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 + public void onError(int code, String msg) { + completeError(result, mapBiometricPromptError(code), msg); + } + }); + } + }); + } else { + authenticateLegacy(result, probeCipher); + } + return result; + } + + private void authenticateLegacy(final AsyncResource result, final Cipher probeCipher) { + 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(); + // 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 + public void onAuthenticationFailed() { + if (failures++ > 5) { + cs.cancel(); + completeError(result, BiometricError.AUTHENTICATION_FAILED, + "Authentication failed"); + } + } + }; + 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; + } + pending = null; + 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, + final BiometricError err, final String msg) { + if (pending != result) { + return; + } + pending = null; + 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) { + 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..c1329f53e7 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java @@ -0,0 +1,486 @@ +/* + * 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.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 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() + .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; + } + } + + @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 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"); + } + } + + @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 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(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) ----- + + 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 (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..23728c9ee5 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,59 @@ public String[] getPlatformOverrides() { return platformOverrides; } + private JavaSEBiometrics biometrics; + private JavaSESecureStorage secureStorage; + private boolean biometricsBuildHintsInstalled; + + @Override + public Biometrics getBiometrics() { + installBiometricsBuildHintsIfNeeded(); + if (biometrics == null) { + biometrics = new JavaSEBiometrics(); + } + return biometrics; + } + + @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; 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..5582087609 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -42,8 +42,12 @@ #import #import "CodenameOne_GLViewController.h" #import +#import +#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" @@ -10886,3 +10890,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/docs/developer-guide/Biometric-Authentication.asciidoc b/docs/developer-guide/Biometric-Authentication.asciidoc new file mode 100644 index 0000000000..83908cbacc --- /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. 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 isn't 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 reports `NOT_AVAILABLE` immediately. + +=== Build hints + +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. + +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 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: + +[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 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 (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 wasn't 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+. 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. + +=== 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`. 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`. + +`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()` 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. 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 29c1e4ea22..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; @@ -733,6 +734,7 @@ 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", ""); + debug("Adding android permissions..."); for (String xPerm : ANDROID_PERMISSIONS) { String permName = xPerm.substring(xPerm.lastIndexOf(".")+1); @@ -1247,6 +1249,9 @@ public void usesClass(String cls) { getAccountsPermission = true; } + if (cls.indexOf("com/codename1/security/") == 0) { + usesBiometrics = true; + } } @@ -1339,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 e25d9fb304..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,6 +1569,18 @@ public void usesClassMethod(String cls, String method) { } } + // 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 { if (!runPods && googleAdUnitId != null && googleAdUnitId.length() > 0) { unzip(getResourceAsStream("/google-play-services_lib-ios.zip"), classesDir, buildinRes, buildinRes); 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[]{