From 0b7fc9ec7d3986ec8a129e984b6136a9ae10f773 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 10:13:42 +0300
Subject: [PATCH 01/96] Add App Shield attestation API, and fix the broken iOS
App Attest linkage
Introduces com.codename1.security.shield, the client half of an enterprise
attestation and API-shielding service, and fixes several pre-existing defects
found while building it.
Fixes that stand on their own:
- iOS App Attest never linked. IOSNative.java declares `native boolean
isAppAttestSupported()` but IOSNative.m defined it without ParparVM's
`_R_boolean` mangling in both #ifdef arms, so `ios.appAttest=true` failed at
link time. Renamed; the App Attest path now builds and links.
- App Attest also re-generated a hardware key on every request and never
asserted. Apple's model is attest once, then assert many times against the
recorded key; the old flow burned Apple's per-device attestation budget and
made device continuity impossible. Rewritten as a keychain-backed state
machine with DCError-driven recovery and throttle backoff.
- The non-prompting SecureStorage tier was missing on Android and JavaSE, so
Secrets.get() re-hit the network on every call on Android. Implemented with a
dedicated AndroidKeyStore key (separate from the biometric key, which is
invalidated on re-enrolment) and PBKDF2/AES-GCM in the simulator.
- CN1JailbreakDetector's fork() probe exited the child rather than the app, so
it never did what its comment claimed. Removed, and the detector split into a
signal source plus a thin exit wrapper -- iOS getCompromiseReasons() was
previously only a canExecute("cydia://") probe that returns false on modern
iOS unless the app also declares the scheme.
New API:
- AppShield plus an SPI seam (ShieldEngine/EngineContext/ShieldEngineRegistry).
Discovery is a registered singleton rather than Class.forName, matching the
convention documented in SystemBrowser: class names are obfuscated, so
name-based lookup is unreliable by construction. The in-tree UnprotectedEngine
is the open-source default and fails open in every direction, so an app
written against this API compiles and runs unchanged without an engine.
- NetworkGuard, a narrow interception seam in com.codename1.io, wired into
ConnectionRequest for header attachment and certificate vetting. The
per-request checkSSLCertificates hook still runs first and unchanged.
- SSLCertificate gains public-key digests. The existing fingerprints are
whole-certificate digests, which change on every renewal even on the same key
-- pinning those is how pinning earns its reputation for causing outages. The
richer form is opt-in, so existing callers see byte-identical data.
- WebSocket.header(), emitted by the ports that build the handshake themselves
and documented as unavailable on iOS and in the browser rather than silently
dropped.
Simulator: JavaSEPort had no DeviceIntegrity overrides at all, so the branches
an app takes on a compromised device were unreachable off real hardware. Adds
Simulate > App Shield covering attestation outcomes, device signals, token
expiry and forced pin mismatch.
Verified: core suite (4309 tests) green; Android, JavaSE, iOS, Windows and Linux
ports compile; the generated iOS project builds and links both with and without
ios.appAttest. The iOS DER walk for public-key digests was checked against
openssl output for EC and RSA chains before being written in Objective-C.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/CodenameOneImplementation.java | 45 ++
.../src/com/codename1/impl/WebSocketImpl.java | 58 +++
.../com/codename1/io/ConnectionRequest.java | 182 +++++++-
.../src/com/codename1/io/NetworkGuard.java | 78 ++++
.../src/com/codename1/io/NetworkManager.java | 46 ++
.../src/com/codename1/io/WebSocket.java | 34 ++
.../codename1/security/DeviceIntegrity.java | 17 +
.../com/codename1/security/SecureStorage.java | 7 +-
.../codename1/security/shield/AppShield.java | 434 ++++++++++++++++++
.../security/shield/FailureMode.java | 44 ++
.../codename1/security/shield/HostPolicy.java | 81 ++++
.../com/codename1/security/shield/PinSet.java | 147 ++++++
.../security/shield/ShieldConfig.java | 165 +++++++
.../security/shield/ShieldException.java | 47 ++
.../security/shield/ShieldListener.java | 43 ++
.../security/shield/ShieldSignal.java | 85 ++++
.../security/shield/ShieldSignals.java | 148 ++++++
.../security/shield/ShieldStatus.java | 133 ++++++
.../security/shield/ShieldToken.java | 109 +++++
.../security/shield/package-info.java | 50 ++
.../shield/spi/DefaultEngineContext.java | 111 +++++
.../security/shield/spi/EngineContext.java | 76 +++
.../security/shield/spi/ShieldEngine.java | 112 +++++
.../shield/spi/ShieldEngineRegistry.java | 100 ++++
.../shield/spi/UnprotectedEngine.java | 139 ++++++
.../security/shield/spi/package-info.java | 33 ++
CodenameOne/src/com/codename1/ui/Display.java | 13 +
.../impl/android/AndroidImplementation.java | 381 +++++++++------
.../impl/android/AndroidSecureStorage.java | 190 ++++++++
.../impl/android/AndroidWebSocketImpl.java | 1 +
.../com/codename1/impl/javase/JavaSEPort.java | 279 ++++++++++-
.../impl/javase/JavaSESecureStorage.java | Bin 4001 -> 8027 bytes
.../codename1/impl/javase/JavaSEShield.java | 167 +++++++
.../impl/javase/JavaSEWebSocketImpl.java | 1 +
.../impl/linux/LinuxWebSocketImpl.java | 1 +
.../impl/windows/WindowsWebSocketImpl.java | 1 +
.../nativeSources/CN1JailbreakDetector.h | 20 +-
.../nativeSources/CN1JailbreakDetector.m | 99 ++--
Ports/iOSPort/nativeSources/IOSNative.m | 147 ++++--
.../nativeSources/NetworkConnectionImpl.m | 114 ++++-
.../impl/ios/IOSDeviceIntegrity.java | 354 ++++++++++++--
.../codename1/impl/ios/IOSImplementation.java | 90 +++-
.../src/com/codename1/impl/ios/IOSNative.java | 39 +-
.../io/SSLCertificateChainParsingTest.java | 145 ++++++
.../security/shield/ShieldApiTest.java | 282 ++++++++++++
45 files changed, 4585 insertions(+), 263 deletions(-)
create mode 100644 CodenameOne/src/com/codename1/io/NetworkGuard.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/AppShield.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/FailureMode.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/HostPolicy.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/PinSet.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/ShieldException.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/ShieldListener.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/ShieldSignal.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/ShieldSignals.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/ShieldStatus.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/ShieldToken.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/package-info.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/spi/ShieldEngine.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/spi/ShieldEngineRegistry.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/spi/UnprotectedEngine.java
create mode 100644 CodenameOne/src/com/codename1/security/shield/spi/package-info.java
create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShield.java
create mode 100644 maven/core-unittests/src/test/java/com/codename1/io/SSLCertificateChainParsingTest.java
create mode 100644 maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
index 362b8b74b4e..d2cb55765fb 100644
--- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
+++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
@@ -6363,6 +6363,28 @@ public boolean canGetSSLCertificates() {
return false;
}
+ /// True when this port can report a digest of each certificate's subject public key info,
+ /// enabling public-key pinning through [#getSSLCertificatesEx(Object, String)].
+ ///
+ /// Public-key pins survive certificate renewal on the same key pair; whole-certificate
+ /// fingerprints do not, which is why a renewal can otherwise take a pinning app offline.
+ public boolean canGetPublicKeyDigests() {
+ return false;
+ }
+
+ /// The richer certificate list, grouped per certificate.
+ ///
+ /// Same `algorithm:value` encoding as [#getSSLCertificates(Object, String)], with two
+ /// additions: a `CHAIN:` entry starts each certificate's group (0 is the leaf), and a
+ /// `SPKI-SHA-256:` entry carries the public-key digest.
+ ///
+ /// Called only when something asked for public-key digests, so a port that does not override
+ /// this simply never sees it. The default delegates to the flat form, which parses correctly
+ /// and just yields no digests.
+ public String[] getSSLCertificatesEx(Object connection, String url) throws IOException {
+ return getSSLCertificates(connection, url);
+ }
+
/// SSL certificate checks must be performed via a callback from the native side,
/// rather than explicitly checking as part of NetworkManager's connection
/// flow. This is mainly for iOS POST requests. If we try to get the SSL certs
@@ -11239,6 +11261,29 @@ public String[] getEnabledAccessibilityServices() {
return new String[0];
}
+ /// Discards cached platform attestation state so the next [#requestIntegrityToken(String)] starts
+ /// from a fresh hardware key.
+ ///
+ /// Apple's App Attest model is attest once, then assert many times against the key the server
+ /// recorded. When the server no longer recognises that key -- the app was reinstalled, the device
+ /// was restored from a backup, or the key was invalidated by the OS -- the client has to throw the
+ /// key away and attest again. This is how the attestation layer is told to do that. No-op where
+ /// attestation is unsupported or stateless (Play Integrity holds no client-side key).
+ public void resetAttestation() {
+ }
+
+ /// Returns digests of the certificates the running application is actually signed with, so a build
+ /// can be compared against the identity it was built under and repackaging can be reported.
+ ///
+ /// Deliberately not surfaced on [com.codename1.security.DeviceIntegrity]: an app has no use for its
+ /// own signature, and a comparison performed on the device is defeated by the same patch that did
+ /// the repackaging. The value of this is that it is reported to a verifying service, which checks it
+ /// against what the build server recorded. Returns an empty array where the platform has no such
+ /// concept.
+ public String[] getAppSignerDigests() {
+ return new String[0];
+ }
+
/// Marks the current screen as secure, blocking OS screenshots, screen recording and accessibility
/// screen scraping while it is displayed (Android `FLAG_SECURE`). No-op where unsupported.
///
diff --git a/CodenameOne/src/com/codename1/impl/WebSocketImpl.java b/CodenameOne/src/com/codename1/impl/WebSocketImpl.java
index c4c99892583..2c2267b2d7f 100644
--- a/CodenameOne/src/com/codename1/impl/WebSocketImpl.java
+++ b/CodenameOne/src/com/codename1/impl/WebSocketImpl.java
@@ -36,6 +36,7 @@ public abstract class WebSocketImpl {
private final String url;
private WebSocketEventSink sink;
private String[] requestedSubprotocols;
+ private final java.util.Hashtable requestHeaders = new java.util.Hashtable();
// Written by the port's connect/handshake thread, read by the user's
// connect handler -- volatile to publish the value across threads.
@SuppressWarnings("PMD.AvoidUsingVolatile")
@@ -71,6 +72,63 @@ protected final String[] requestedSubprotocols() {
return requestedSubprotocols;
}
+ /// Adds a header to the opening handshake. Called by the public facade
+ /// before `connect(int)`. A null or empty name is ignored; a null value
+ /// removes a previously set header.
+ ///
+ /// Ports that build the handshake themselves emit these; ports built on a
+ /// platform WebSocket that does not expose the handshake ignore them. See
+ /// the facade's `header` method for which those are.
+ public final void setRequestHeader(String name, String value) {
+ if (name == null || name.length() == 0) {
+ return;
+ }
+ if (value == null) {
+ requestHeaders.remove(name);
+ } else {
+ requestHeaders.put(name, value);
+ }
+ }
+
+ /// The extra handshake headers, keyed by name. Never null; ports read this
+ /// while building the handshake. Ports must not emit an entry whose name
+ /// collides with a header the handshake sets itself.
+ protected final java.util.Hashtable requestHeaders() {
+ return requestHeaders;
+ }
+
+ /// Appends the extra handshake headers in `name: value` CRLF form, skipping
+ /// any that would collide with a header the handshake already wrote.
+ ///
+ /// Shared here rather than copied per port so the collision list and the
+ /// header-injection guard stay in one place: a header value carrying CR or
+ /// LF would otherwise let a caller inject arbitrary handshake headers.
+ protected final void appendRequestHeaders(StringBuilder req) {
+ java.util.Enumeration keys = requestHeaders.keys();
+ while (keys.hasMoreElements()) {
+ String name = (String) keys.nextElement();
+ String value = (String) requestHeaders.get(name);
+ if (isReservedHandshakeHeader(name)) {
+ continue;
+ }
+ if (containsCrLf(name) || containsCrLf(value)) {
+ continue;
+ }
+ req.append(name).append(": ").append(value).append("\r\n");
+ }
+ }
+
+ private static boolean isReservedHandshakeHeader(String name) {
+ String n = name.toLowerCase();
+ return n.equals("host") || n.equals("upgrade") || n.equals("connection")
+ || n.equals("sec-websocket-key") || n.equals("sec-websocket-version")
+ || n.equals("sec-websocket-protocol") || n.equals("content-length");
+ }
+
+ private static boolean containsCrLf(String s) {
+ return s.indexOf('\r') >= 0 || s.indexOf('\n') >= 0;
+ }
+
/// Records the subprotocol the server selected. Ports call this once
/// the handshake completes, before firing `sink().onConnect()`, so the
/// value is visible to the user's connect handler.
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index f310829981b..5559f46c1bd 100644
--- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java
+++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
@@ -191,6 +191,16 @@ public class ConnectionRequest implements IOProgressListener {
private String destinationStorage;
private SSLCertificate[] sslCertificates;
private boolean checkSSLCertificates;
+
+ /// Set when a [NetworkGuard] rejected this request's certificate chain, so the failure can be
+ /// rethrown as itself instead of surfacing as a generic connection error (or, worse, as a
+ /// successful empty response).
+ private IOException pinFailure;
+
+ /// Whether to ask the platform for the richer certificate details that include public-key
+ /// digests. Off unless a guard says this host is pinned, so every existing caller keeps
+ /// receiving byte-identical data from [#getSSLCertificates()].
+ private boolean collectPublicKeyDigests;
/// A flag that turns off checking for invalid certificates.
private boolean insecure;
/// The request body can be used instead of arguments to pass JSON data to a restful request,
@@ -881,21 +891,54 @@ boolean checkCertificatesNativeCallback() {
//throw new RuntimeException("checkCertificates() can only be explicitly called on platforms that require native callbacks for checking certificates.");
return true;
}
- if (!checkSSLCertificates) {
+ if (!shouldInspectCertificates()) {
// If the request doesn't require checking SSL certificates, then this returns true.
// meaning that it checks out OK.
return true;
}
try {
- checkSSLCertificates(getSSLCertificates());
+ SSLCertificate[] certs = getSSLCertificates();
+ checkSSLCertificates(certs);
+ NetworkGuard guard = NetworkManager.getNetworkGuard();
+ if (guard != null) {
+ guard.checkCertificates(this, certs);
+ }
return !shouldStop();
} catch (IOException ex) {
+ // Retained so the failure surfaces as a real error rather than an
+ // empty successful response. This callback can only answer with a
+ // boolean; performOperationComplete rethrows it.
+ pinFailure = ex;
Log.e(ex);
return false;
}
}
+ /// True when the certificate chain for this request should be fetched and vetted, either
+ /// because the request opted in itself or because the installed [NetworkGuard] pins this host.
+ private boolean shouldInspectCertificates() {
+ if (checkSSLCertificates) {
+ return true;
+ }
+ NetworkGuard guard = NetworkManager.getNetworkGuard();
+ if (guard == null) {
+ return false;
+ }
+ try {
+ if (guard.isCertificateCheckRequired(url)) {
+ // Ask the platform for the richer chain details (public key
+ // digests, per-certificate grouping). Off by default so every
+ // existing caller keeps seeing byte-identical data.
+ collectPublicKeyDigests = true;
+ return true;
+ }
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ return false;
+ }
+
/// Performs the actual network request on behalf of the network manager
void performOperation() throws IOException {
performOperationComplete();
@@ -910,6 +953,16 @@ boolean performOperationComplete() throws IOException {
if (shouldStop()) {
return true;
}
+ pinFailure = null;
+ NetworkGuard requestGuard = NetworkManager.getNetworkGuard();
+ if (requestGuard != null) {
+ // Deliberately here rather than in initConnection(): that method is
+ // protected, and a subclass that overrides it without calling super
+ // would silently lose the decoration. Also deliberately not in
+ // NetworkThread.prepare(), which runs inside the queue lock where a
+ // blocking token fetch would stall every other request.
+ requestGuard.beforeRequest(this);
+ }
if (cacheMode == CachingMode.OFFLINE || cacheMode == CachingMode.OFFLINE_FIRST) {
InputStream is = null; //NOPMD CloseResource
try {
@@ -980,17 +1033,31 @@ boolean performOperationComplete() throws IOException {
}
}
}
- if (checkSSLCertificates && canGetSSLCertificates() &&
+ if (shouldInspectCertificates() && canGetSSLCertificates() &&
// For iOS only... it needs to use a callback from native code
// for checking the SSL certificates - otherwise it will send
// empty POST bodies.
!Util.getImplementation().checkSSLCertificatesRequiresCallbackFromNative()) {
sslCertificates = getSSLCertificatesImpl(connection, url);
+ // The request's own hook runs first and unchanged, so an app that
+ // already pins by overriding it keeps working; the guard layers on.
checkSSLCertificates(sslCertificates);
+ NetworkGuard certGuard = NetworkManager.getNetworkGuard();
+ if (certGuard != null) {
+ certGuard.checkCertificates(this, sslCertificates);
+ }
if (shouldStop()) {
return true;
}
}
+ if (pinFailure != null) {
+ // Raised by the iOS native callback, which can only answer with a
+ // boolean. Surfacing it here is what stops a rejected chain from
+ // looking like a successful zero-byte response to the caller.
+ IOException toThrow = pinFailure;
+ pinFailure = null;
+ throw toThrow;
+ }
if (isWriteRequest()) {
progress = NetworkEvent.PROGRESS_TYPE_OUTPUT;
output = impl.openOutputStream(connection);
@@ -1139,6 +1206,17 @@ boolean performOperationComplete() throws IOException {
}
}
}
+ } catch (IOException ioe) {
+ // On iOS the certificate check runs in a native callback that can only
+ // answer with a boolean, so the connection fails with a generic error
+ // some way after the real cause. Substitute the recorded cause, or the
+ // caller sees "connection reset" for what was actually a pin mismatch.
+ if (pinFailure != null) {
+ IOException cause = pinFailure;
+ pinFailure = null;
+ throw cause;
+ }
+ throw ioe;
} finally {
// always cleanup connections/streams even in case of an exception
impl.cleanup(output);
@@ -1550,8 +1628,61 @@ public SSLCertificate[] getSSLCertificates() throws IOException {
return sslCertificates;
}
+ /// Parses the richer per-certificate form of the platform's certificate list.
+ ///
+ /// Entries arrive as `algorithm:value`, exactly as in the flat form, with a `CHAIN:`
+ /// delimiter starting each certificate's group. Anything before the first delimiter is treated
+ /// as the leaf, so a port that reports digests without grouping still yields usable data.
+ static SSLCertificate[] parseGroupedCertificates(String[] entries) {
+ java.util.Vector out = new java.util.Vector();
+ SSLCertificate current = null;
+ int index = 0;
+ for (int i = 0; i < entries.length; i++) {
+ String entry = entries[i];
+ if (entry == null) {
+ continue;
+ }
+ int splitPos = entry.indexOf(':');
+ if (splitPos == -1) {
+ continue;
+ }
+ String algorithm = entry.substring(0, splitPos);
+ String value = entry.substring(splitPos + 1);
+ if ("CHAIN".equals(algorithm)) {
+ current = new SSLCertificate();
+ try {
+ current.chainIndex = Integer.parseInt(value);
+ } catch (NumberFormatException nfe) {
+ current.chainIndex = index;
+ }
+ index++;
+ out.addElement(current);
+ continue;
+ }
+ if (current == null) {
+ current = new SSLCertificate();
+ current.chainIndex = index++;
+ out.addElement(current);
+ }
+ if ("SPKI-SHA-256".equals(algorithm)) {
+ current.publicKeyDigest = value;
+ current.publicKeyDigestAlgorithm = "SHA-256";
+ } else if (current.certificateUniqueKey == null) {
+ current.certificateAlgorithm = algorithm;
+ current.certificateUniqueKey = value;
+ }
+ }
+ SSLCertificate[] arr = new SSLCertificate[out.size()];
+ out.copyInto(arr);
+ return arr;
+ }
+
private SSLCertificate[] getSSLCertificatesImpl(Object connection, String url) throws IOException {
- String[] sslCerts = Util.getImplementation().getSSLCertificates(connection, url);
+ CodenameOneImplementation impl = Util.getImplementation();
+ if (collectPublicKeyDigests && impl.canGetPublicKeyDigests()) {
+ return parseGroupedCertificates(impl.getSSLCertificatesEx(connection, url));
+ }
+ String[] sslCerts = impl.getSSLCertificates(connection, url);
SSLCertificate[] out = new SSLCertificate[sslCerts.length];
int i = 0;
for (String sslCertStr : sslCerts) {
@@ -3032,6 +3163,9 @@ public static final class SSLCertificate {
private String certificateUniqueKey;
private String certificateAlgorithm;
+ private String publicKeyDigest;
+ private String publicKeyDigestAlgorithm;
+ private int chainIndex;
/// Gets a fingerprint for the SSL certificate encoded using the algorithm
/// specified by `#getCertificteAlgorithm()`
@@ -3047,6 +3181,46 @@ public String getCertificteUniqueKey() {
public String getCertificteAlgorithm() {
return certificateAlgorithm;
}
+
+ /// Same value as [#getCertificteUniqueKey()], under a spelling that is not a typo.
+ /// The original name is kept because existing code calls it.
+ public String getFingerprint() {
+ return certificateUniqueKey;
+ }
+
+ /// Same value as [#getCertificteAlgorithm()], under a spelling that is not a typo.
+ public String getFingerprintAlgorithm() {
+ return certificateAlgorithm;
+ }
+
+ /// A base64 digest of this certificate's subject public key info, or null when the
+ /// platform did not supply one.
+ ///
+ /// Prefer this over [#getFingerprint()] when pinning. A whole-certificate fingerprint
+ /// changes every time the certificate is renewed, even on the same key pair, so pinning it
+ /// means an expiry can take the app offline. The public key survives renewal.
+ ///
+ /// Populated only when something asked for it -- an installed [NetworkGuard] that pins
+ /// this host. Otherwise it stays null so existing certificate handling is unaffected.
+ public String getPublicKeyDigest() {
+ return publicKeyDigest;
+ }
+
+ /// The digest algorithm behind [#getPublicKeyDigest()], normally `SHA-256`.
+ public String getPublicKeyDigestAlgorithm() {
+ return publicKeyDigestAlgorithm;
+ }
+
+ /// Position in the chain the server presented; 0 is the leaf. Meaningful only when the
+ /// platform reported per-certificate grouping, otherwise 0.
+ public int getChainIndex() {
+ return chainIndex;
+ }
+
+ /// True for the server's own certificate as opposed to an issuer in the chain.
+ public boolean isLeaf() {
+ return chainIndex == 0;
+ }
}
private static class ImageStorageSuccessCallback implements SuccessCallback {
diff --git a/CodenameOne/src/com/codename1/io/NetworkGuard.java b/CodenameOne/src/com/codename1/io/NetworkGuard.java
new file mode 100644
index 00000000000..6150c006875
--- /dev/null
+++ b/CodenameOne/src/com/codename1/io/NetworkGuard.java
@@ -0,0 +1,78 @@
+/*
+ * 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.io;
+
+import java.io.IOException;
+
+/// Interception point for cross-cutting network policy: decorating outgoing requests, and vetting
+/// the TLS certificate chain before a request body is written.
+///
+/// At most one guard is installed per app, via
+/// [NetworkManager#setNetworkGuard(NetworkGuard)], and the slot seals after the first call.
+/// [com.codename1.security.shield.AppShield] is the intended consumer, but the interface is
+/// deliberately generic and carries no dependency on it.
+///
+/// #### This does not replace the per-request certificate hook
+///
+/// [ConnectionRequest#checkSSLCertificates(ConnectionRequest.SSLCertificate[])] remains the way an
+/// individual request pins for itself, and it still runs first and unchanged. The guard runs
+/// afterwards, so an app that already pins manually keeps working and simply gains a second,
+/// app-wide layer.
+public interface NetworkGuard {
+
+ /// Called on the network thread immediately before the connection is opened, and again on each
+ /// retry or redirect so a stale token is never reused.
+ ///
+ /// May block -- it is off the EDT and outside the network queue's lock. Add headers here.
+ /// Throwing fails the request through the normal error path.
+ void beforeRequest(ConnectionRequest request) throws IOException;
+
+ /// Whether this URL's certificate chain needs to be inspected, i.e. whether the host has pins.
+ ///
+ /// Must be fast and purely local. On iOS this is consulted from the TLS delegate thread while
+ /// the handshake is held open.
+ ///
+ /// Returning true has a cost beyond the check itself: the framework asks the platform for the
+ /// richer certificate details, including public-key digests, which it does not otherwise
+ /// collect. Return false for hosts with no pins.
+ boolean isCertificateCheckRequired(String url);
+
+ /// Vets the certificate chain, after the handshake and before any request body is written.
+ ///
+ /// Throwing an `IOException` aborts the request and surfaces through the request's normal
+ /// error handling, rather than completing it with an empty response.
+ ///
+ /// Must be local and non-blocking, for the same delegate-thread reason as
+ /// [#isCertificateCheckRequired(String)]. In particular it must not try to fetch a fresh pin
+ /// set: use the last known one and let a mismatch stand.
+ void checkCertificates(ConnectionRequest request,
+ ConnectionRequest.SSLCertificate[] certificates) throws IOException;
+
+ /// Called once the response code is known, including for failed requests.
+ ///
+ /// This is how a token layer learns its token was refused -- a 401 or 403 from a protected host
+ /// usually means the cached token should be discarded before the next attempt. Must not throw;
+ /// exceptions here are logged and swallowed so a telemetry problem cannot fail a request that
+ /// otherwise succeeded.
+ void afterResponse(ConnectionRequest request, int responseCode);
+}
diff --git a/CodenameOne/src/com/codename1/io/NetworkManager.java b/CodenameOne/src/com/codename1/io/NetworkManager.java
index 92e750854b4..5e400072bd5 100644
--- a/CodenameOne/src/com/codename1/io/NetworkManager.java
+++ b/CodenameOne/src/com/codename1/io/NetworkManager.java
@@ -207,6 +207,42 @@ public static NetworkManager getInstance() {
return INSTANCE;
}
+ private static volatile NetworkGuard networkGuard;
+ private static boolean networkGuardSealed;
+
+ /// Installs the app-wide [NetworkGuard].
+ ///
+ /// The first call wins and the slot then seals. A guard can veto requests and enforce
+ /// certificate pins, so allowing it to be replaced at runtime would let any code that runs
+ /// later -- including code an attacker injected -- swap in a permissive one.
+ ///
+ /// @throws IllegalStateException if a guard is already installed
+ public static void setNetworkGuard(NetworkGuard guard) {
+ if (guard == null) {
+ throw new IllegalArgumentException("guard is null");
+ }
+ synchronized (NetworkManager.class) {
+ if (networkGuardSealed) {
+ throw new IllegalStateException("A network guard is already installed");
+ }
+ networkGuard = guard;
+ networkGuardSealed = true;
+ }
+ }
+
+ /// The installed guard, or null when none was installed.
+ public static NetworkGuard getNetworkGuard() {
+ return networkGuard;
+ }
+
+ /// Test hook: drops the installed guard and unseals the slot.
+ static void resetNetworkGuardForTesting() {
+ synchronized (NetworkManager.class) {
+ networkGuard = null;
+ networkGuardSealed = false;
+ }
+ }
+
void resetAPN() {
autoDetected = false;
}
@@ -1132,6 +1168,16 @@ private boolean runCurrentRequest(@Async.Execute ConnectionRequest req) {
if (requestWasCompleted) {
req.complete = true;
}
+ NetworkGuard guard = networkGuard;
+ if (guard != null) {
+ try {
+ guard.afterResponse(req, req.getResponseCode());
+ } catch (Throwable t) {
+ // A guard's bookkeeping must never turn a completed
+ // request into a failed one.
+ Log.e(t);
+ }
+ }
// Read once into a local. A listener removed from the EDT --
// which is where postResponse() runs, queued while this thread
// was still finishing the request -- can null the field between
diff --git a/CodenameOne/src/com/codename1/io/WebSocket.java b/CodenameOne/src/com/codename1/io/WebSocket.java
index 9ba59014f65..e01a5d5e69b 100644
--- a/CodenameOne/src/com/codename1/io/WebSocket.java
+++ b/CodenameOne/src/com/codename1/io/WebSocket.java
@@ -237,6 +237,40 @@ public WebSocket subprotocols(String... protocols) {
return this;
}
+ /// Add a header to the opening handshake. Must be called before [connect].
+ /// Passing a null value removes a previously set header. Returns `this` for
+ /// chaining.
+ ///
+ /// Typically used to carry an authorization or attestation token, since a
+ /// WebSocket has no other place to put one.
+ ///
+ /// ```
+ /// WebSocket.build("wss://api.example.com/stream")
+ /// .header("X-CN1-Attest", token)
+ /// .connect();
+ /// ```
+ ///
+ /// #### Not supported everywhere
+ ///
+ /// Emitted on Android, desktop, Windows and Linux, which build the opening
+ /// handshake themselves. **Silently dropped on iOS and in the browser**,
+ /// which hand the handshake to a platform WebSocket that exposes no way to
+ /// add headers to it.
+ ///
+ /// Where headers are unavailable, obtain a short-lived ticket over an
+ /// ordinary HTTPS request -- which can be attested and pinned normally --
+ /// and pass it in the URL query instead. That also avoids leaking a
+ /// long-lived credential into a URL.
+ ///
+ /// Headers the handshake sets itself -- `Host`, `Upgrade`, `Connection`,
+ /// `Sec-WebSocket-Key`, `Sec-WebSocket-Version`, `Sec-WebSocket-Protocol`
+ /// -- are reserved and are ignored if passed here. Use [subprotocols] for
+ /// the last of those.
+ public WebSocket header(String name, String value) {
+ impl.setRequestHeader(name, value);
+ return this;
+ }
+
/// The subprotocol the server selected during the handshake, or null
/// when none was negotiated. Valid once the [ConnectHandler] has fired.
public String getSelectedSubprotocol() {
diff --git a/CodenameOne/src/com/codename1/security/DeviceIntegrity.java b/CodenameOne/src/com/codename1/security/DeviceIntegrity.java
index 801c70155a2..371372902d5 100644
--- a/CodenameOne/src/com/codename1/security/DeviceIntegrity.java
+++ b/CodenameOne/src/com/codename1/security/DeviceIntegrity.java
@@ -110,6 +110,23 @@ public static boolean isAttestationSupported() {
return Display.getInstance().isAttestationSupported();
}
+ /// Discards the cached platform attestation state, so the next [#requestIntegrityToken(String)]
+ /// attests from a fresh hardware key.
+ ///
+ /// Only iOS holds client-side attestation state. Apple's model is: generate a hardware key once,
+ /// attest it once, then produce cheap assertions against it for every subsequent request. Your
+ /// backend records the key when it accepts the attestation. If the backend later rejects a request
+ /// because it does not recognise the key -- the app was reinstalled, the device was restored from a
+ /// backup, or the OS invalidated the key -- call this, then request a token again. That is the only
+ /// correct recovery; retrying with the same key will keep failing.
+ ///
+ /// Do not call this on every failure. Attestation is rate limited by Apple, and re-attesting in a
+ /// loop will get the app throttled. No-op on Android, where Play Integrity keeps no client key, and
+ /// where attestation is unsupported.
+ public static void resetAttestation() {
+ Display.getInstance().resetAttestation();
+ }
+
/// Non-exiting RASP check. Returns true when the device shows signs of being rooted, jailbroken,
/// running under dynamic instrumentation (e.g. Frida) or otherwise tampered. Unlike the
/// `android.rootCheck` / `ios.detectJailbreak` launch gates this never terminates the app, so it is
diff --git a/CodenameOne/src/com/codename1/security/SecureStorage.java b/CodenameOne/src/com/codename1/security/SecureStorage.java
index 91cc9416d86..b3eca7d6024 100644
--- a/CodenameOne/src/com/codename1/security/SecureStorage.java
+++ b/CodenameOne/src/com/codename1/security/SecureStorage.java
@@ -144,9 +144,10 @@ public void setKeychainAccessGroup(String group) {
// `SecAccessControl`. Entries survive app updates and OS
// reboots; they are extracted only after the user unlocks the
// device at least once after each reboot.
- // - Android: `EncryptedSharedPreferences` (Tink-backed AES-GCM)
- // without `setUserAuthenticationRequired(true)`. No biometric
- // prompt.
+ // - Android: AES-GCM under a dedicated AndroidKeyStore key created
+ // without `setUserAuthenticationRequired(true)`, persisted to a
+ // private preferences file. No biometric prompt. Devices below
+ // API 23 fall back to obfuscated (not encrypted) storage.
// - JavaSE simulator: `java.util.prefs.Preferences` encrypted
// with an AES key derived from the OS user account. Useful
// for round-tripping `LlmClient.openai(SecureStorage.getInstance().get("openai_key"))`
diff --git a/CodenameOne/src/com/codename1/security/shield/AppShield.java b/CodenameOne/src/com/codename1/security/shield/AppShield.java
new file mode 100644
index 00000000000..9bde5485ff9
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/AppShield.java
@@ -0,0 +1,434 @@
+/*
+ * 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.shield;
+
+import com.codename1.io.ConnectionRequest;
+import com.codename1.io.Log;
+import com.codename1.security.shield.spi.ShieldEngine;
+import com.codename1.security.shield.spi.ShieldEngineRegistry;
+import com.codename1.ui.Display;
+import com.codename1.util.AsyncResource;
+import java.util.Hashtable;
+import java.util.Vector;
+
+/// API shielding: proves to your own backend that a request came from a genuine, unmodified build
+/// of your app running on a device that has not been tampered with.
+///
+/// #### How it differs from [com.codename1.security.DeviceIntegrity]
+///
+/// `DeviceIntegrity` is the raw platform primitive -- it hands you a Play Integrity or App Attest
+/// blob and leaves verification, policy and enforcement to you. `AppShield` is the managed layer
+/// on top: the attestation is verified server side against Apple and Google, evaluated against a
+/// policy you control, and turned into a short-lived signed token your backend can check with a
+/// few lines of middleware. Both remain available and `DeviceIntegrity` keeps working unchanged;
+/// an app that only needs the raw blob should keep using it.
+///
+/// #### The shape of the thing
+///
+/// ```java
+/// AppShield.init(new ShieldConfig()
+/// .protect("api.mybank.example", HostPolicy.PROTECTED));
+///
+/// // ...then just make requests. Protected hosts get the header and the pin check.
+/// ConnectionRequest r = new ConnectionRequest("https://api.mybank.example/transfer", true);
+/// NetworkManager.getInstance().addToQueueAndWait(r);
+/// ```
+///
+/// Your backend rejects any request whose token is missing, expired or unsigned. That check is
+/// where the security actually lives -- not in this class. A device the attacker fully controls
+/// can always strip a header; what it cannot do is mint a token, because the token is signed by a
+/// service the attacker does not control, on the strength of a statement from Apple or Google.
+///
+/// #### When the engine is absent
+///
+/// Builds without the enterprise attestation engine -- open-source builds, and any project not
+/// entitled to it -- get a working, inert implementation. [#isProtected()] returns false,
+/// [#fetchToken()] completes with [ShieldStatus#UNPROTECTED] rather than hanging, [#attach] does
+/// nothing, and no request is ever blocked. The API is safe to call unconditionally; there is no
+/// need to guard call sites.
+///
+/// #### Threading
+///
+/// [#fetchToken()] is asynchronous. [#attach(ConnectionRequest)] blocks and must not be called on
+/// the EDT -- in normal use you never call it yourself, because a protected host is handled
+/// automatically on the network thread.
+public final class AppShield {
+
+ private static ShieldConfig config;
+ private static boolean initialized;
+ private static ShieldStatus lastStatus = ShieldStatus.NOT_INITIALIZED;
+ private static final Vector listeners = new Vector();
+ private static final Hashtable runtimeHosts = new Hashtable();
+
+ private AppShield() {
+ }
+
+ // -----------------------------------------------------------------
+ // Lifecycle
+ // -----------------------------------------------------------------
+
+ /// Initializes the shield. Call once during app startup, after `Display.init`.
+ ///
+ /// Safe to call in a build with no attestation engine: it logs one line and leaves the shield
+ /// inert. Calling it twice is a no-op.
+ public static void init(ShieldConfig cfg) {
+ synchronized (AppShield.class) {
+ if (initialized) {
+ return;
+ }
+ config = cfg == null ? new ShieldConfig() : cfg;
+ initialized = true;
+ }
+ ShieldEngine engine = ShieldEngineRegistry.getEngine();
+ try {
+ engine.initialize(contextForEngine(), config);
+ setStatus(engine.isAvailable() ? ShieldStatus.OK : ShieldStatus.UNPROTECTED);
+ } catch (Throwable t) {
+ // A failure inside the engine must not stop the app from starting.
+ Log.e(t);
+ setStatus(ShieldStatus.UNPROTECTED);
+ }
+ }
+
+ /// True when a real attestation engine is present and available. False in an open-source or
+ /// unentitled build, and in the simulator unless simulation is switched on.
+ public static boolean isProtected() {
+ return ShieldEngineRegistry.getEngine().isAvailable();
+ }
+
+ /// The active engine's name, for diagnostics and support logs.
+ public static String getEngineName() {
+ return ShieldEngineRegistry.getEngine().getName();
+ }
+
+ /// The configuration passed to [#init(ShieldConfig)], or defaults if it has not been called.
+ public static ShieldConfig getConfig() {
+ synchronized (AppShield.class) {
+ if (config == null) {
+ config = new ShieldConfig();
+ }
+ return config;
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // Tokens
+ // -----------------------------------------------------------------
+
+ /// Fetches a time-limited token, reusing the cached one when it is still good.
+ public static AsyncResource fetchToken() {
+ return fetchToken(null);
+ }
+
+ /// Fetches a token bound to specific request data.
+ ///
+ /// Binding ties the token to one request, so a token lifted off a captured request cannot be
+ /// replayed against a different one. Worth the extra round trip on the calls that matter --
+ /// a transfer, a password change -- and not worth it on the rest, which should use the plain
+ /// [#fetchToken()].
+ ///
+ /// @param bindingData the data to bind to, typically a digest of the request body
+ public static AsyncResource fetchToken(final String bindingData) {
+ final AsyncResource result = new AsyncResource();
+ if (!initialized) {
+ result.error(new ShieldException(ShieldStatus.NOT_INITIALIZED,
+ "AppShield.init(...) has not been called"));
+ return result;
+ }
+ Display.getInstance().scheduleBackgroundTask(new Runnable() {
+ public void run() {
+ try {
+ ShieldToken token = ShieldEngineRegistry.getEngine().fetchToken(bindingData);
+ setStatus(token.getStatus());
+ result.complete(token);
+ } catch (ShieldException e) {
+ setStatus(e.getStatus());
+ result.error(e);
+ } catch (Throwable t) {
+ setStatus(ShieldStatus.SERVICE_DOWN);
+ result.error(t);
+ }
+ }
+ });
+ return result;
+ }
+
+ /// Discards any cached token. Call this when your backend rejects a token, so the next request
+ /// re-attests rather than replaying the token that was just refused.
+ public static void invalidateToken() {
+ try {
+ ShieldEngineRegistry.getEngine().invalidate();
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // Request binding
+ // -----------------------------------------------------------------
+
+ /// Attaches the attestation header to a request.
+ ///
+ /// **Blocks** while a token is fetched, so it must be called on a network thread. Requests to
+ /// hosts registered via [ShieldConfig#protect(String, HostPolicy)] are handled automatically
+ /// and do not need this; use it for a request built outside the normal path.
+ ///
+ /// Honours the host's [FailureMode]: under [FailureMode#OPEN] a token failure leaves the
+ /// request untouched, under [FailureMode#CLOSED] it propagates.
+ public static void attach(ConnectionRequest request) throws ShieldException {
+ if (request == null || !initialized) {
+ return;
+ }
+ String host = hostOf(request.getUrl());
+ HostPolicy policy = policyFor(host);
+ if (!policy.isAttachToken()) {
+ return;
+ }
+ try {
+ ShieldToken token = ShieldEngineRegistry.getEngine().fetchToken(null);
+ setStatus(token.getStatus());
+ if (token.isValid()) {
+ request.addRequestHeader(getConfig().getTokenHeader(), token.getValue());
+ return;
+ }
+ failOrContinue(policy, new ShieldException(token.getStatus(),
+ "No valid attestation token for " + host));
+ } catch (ShieldException e) {
+ setStatus(e.getStatus());
+ failOrContinue(policy, e);
+ }
+ }
+
+ private static void failOrContinue(HostPolicy policy, ShieldException e) throws ShieldException {
+ if (policy.getFailureMode() == FailureMode.CLOSED) {
+ throw e;
+ }
+ Log.p("AppShield: continuing without a token (" + e.getStatus().getId()
+ + "); host policy is fail-open.");
+ }
+
+ /// The headers a protected URL should carry, for network paths that do not go through
+ /// `ConnectionRequest` -- notably `BrowserComponent.setURL(url, headers)`.
+ ///
+ /// Returns an empty table when the host is unprotected or no token is available. Never blocks:
+ /// it uses the cached token only, because the callers are typically on the EDT.
+ ///
+ /// Note this covers only the initial navigation. Requests the loaded page makes itself are not
+ /// visible to the framework and cannot be given a token or pinned.
+ public static Hashtable headersFor(String url) {
+ Hashtable out = new Hashtable();
+ if (!initialized || url == null) {
+ return out;
+ }
+ if (!policyFor(hostOf(url)).isAttachToken()) {
+ return out;
+ }
+ ShieldToken token = getCachedToken();
+ if (token != null && token.isValid()) {
+ out.put(getConfig().getTokenHeader(), token.getValue());
+ }
+ return out;
+ }
+
+ /// The cached token without triggering a fetch. May be null or lapsed. Never blocks.
+ public static ShieldToken getCachedToken() {
+ try {
+ return ShieldEngineRegistry.getEngine().getCachedToken();
+ } catch (Throwable t) {
+ return null;
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // Host policy
+ // -----------------------------------------------------------------
+
+ /// Registers a protected host after [#init(ShieldConfig)], for a backend discovered at
+ /// runtime.
+ public static void addProtectedHost(String host, HostPolicy policy) {
+ if (host != null && host.length() > 0) {
+ runtimeHosts.put(host.toLowerCase(),
+ policy == null ? HostPolicy.PROTECTED : policy);
+ }
+ }
+
+ /// The policy in force for a host. Returns [HostPolicy#UNPROTECTED] for anything not
+ /// registered, which is the great majority of hosts an app talks to.
+ public static HostPolicy policyFor(String host) {
+ if (host == null) {
+ return HostPolicy.UNPROTECTED;
+ }
+ Object runtime = runtimeHosts.get(host.toLowerCase());
+ if (runtime != null) {
+ return (HostPolicy) runtime;
+ }
+ return getConfig().policyFor(host);
+ }
+
+ // -----------------------------------------------------------------
+ // Pinning
+ // -----------------------------------------------------------------
+
+ /// The pin set currently in force. Never null; may be [PinSet#EMPTY].
+ public static PinSet getPinSet() {
+ try {
+ PinSet set = ShieldEngineRegistry.getEngine().getPinSet();
+ return set == null ? PinSet.EMPTY : set;
+ } catch (Throwable t) {
+ return PinSet.EMPTY;
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // RASP
+ // -----------------------------------------------------------------
+
+ /// The runtime self-protection observations recorded so far, combining what the engine
+ /// detected with anything the app or a library reported to [ShieldSignals].
+ ///
+ /// Informational. The attestation service applies the policy, and it may reach a different
+ /// conclusion than a naive reading of this array -- an emulator signal, for instance, is
+ /// normal on a developer's machine.
+ public static ShieldSignal[] getSignals() {
+ ShieldSignal[] fromEngine;
+ try {
+ fromEngine = ShieldEngineRegistry.getEngine().collectSignals();
+ } catch (Throwable t) {
+ fromEngine = new ShieldSignal[0];
+ }
+ if (fromEngine != null) {
+ for (int i = 0; i < fromEngine.length; i++) {
+ ShieldSignals.add(fromEngine[i]);
+ }
+ }
+ return ShieldSignals.snapshot();
+ }
+
+ /// The most recent token status. [ShieldStatus#NOT_INITIALIZED] before [#init(ShieldConfig)].
+ public static ShieldStatus getStatus() {
+ synchronized (AppShield.class) {
+ return lastStatus;
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // Observation
+ // -----------------------------------------------------------------
+
+ /// Registers a listener for status and signal changes. Callbacks arrive on the EDT.
+ public static void addListener(ShieldListener l) {
+ if (l == null) {
+ return;
+ }
+ synchronized (listeners) {
+ if (!listeners.contains(l)) {
+ listeners.addElement(l);
+ }
+ }
+ ShieldSignals.addListener(l);
+ }
+
+ public static void removeListener(ShieldListener l) {
+ synchronized (listeners) {
+ listeners.removeElement(l);
+ }
+ ShieldSignals.removeListener(l);
+ }
+
+ // -----------------------------------------------------------------
+ // Internals
+ // -----------------------------------------------------------------
+
+ private static void setStatus(ShieldStatus status) {
+ if (status == null) {
+ return;
+ }
+ ShieldListener[] copy;
+ synchronized (AppShield.class) {
+ if (status.equals(lastStatus)) {
+ return;
+ }
+ lastStatus = status;
+ }
+ synchronized (listeners) {
+ if (listeners.isEmpty()) {
+ return;
+ }
+ copy = new ShieldListener[listeners.size()];
+ listeners.copyInto(copy);
+ }
+ Display.getInstance().callSerially(new StatusDispatch(copy, status));
+ }
+
+ private static final class StatusDispatch implements Runnable {
+ private final ShieldListener[] targets;
+ private final ShieldStatus status;
+
+ StatusDispatch(ShieldListener[] targets, ShieldStatus status) {
+ this.targets = targets;
+ this.status = status;
+ }
+
+ public void run() {
+ for (int i = 0; i < targets.length; i++) {
+ targets[i].statusChanged(status);
+ }
+ }
+ }
+
+ /// Extracts the host from a URL without pulling in a URL parser. Returns null when the URL is
+ /// not absolute, in which case the host is treated as unprotected.
+ static String hostOf(String url) {
+ if (url == null) {
+ return null;
+ }
+ int scheme = url.indexOf("://");
+ if (scheme < 0) {
+ return null;
+ }
+ int start = scheme + 3;
+ int end = url.length();
+ for (int i = start; i < url.length(); i++) {
+ char c = url.charAt(i);
+ if (c == '/' || c == '?' || c == '#') {
+ end = i;
+ break;
+ }
+ }
+ String authority = url.substring(start, end);
+ // Strip userinfo and port.
+ int at = authority.lastIndexOf('@');
+ if (at >= 0) {
+ authority = authority.substring(at + 1);
+ }
+ int colon = authority.lastIndexOf(':');
+ if (colon >= 0 && authority.indexOf(']') < colon) {
+ authority = authority.substring(0, colon);
+ }
+ return authority.length() == 0 ? null : authority.toLowerCase();
+ }
+
+ private static com.codename1.security.shield.spi.EngineContext contextForEngine() {
+ return ShieldEngineRegistry.getDefaultContext();
+ }
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/FailureMode.java b/CodenameOne/src/com/codename1/security/shield/FailureMode.java
new file mode 100644
index 00000000000..cf688260f26
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/FailureMode.java
@@ -0,0 +1,44 @@
+/*
+ * 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.shield;
+
+/// What a protected host should do when a token cannot be obtained.
+///
+/// This is deliberately a per-host decision. An app typically wants [#CLOSED] on the handful of
+/// endpoints that move money or read personal data, and [#OPEN] everywhere else, so that a shield
+/// service outage degrades one feature rather than bricking the app.
+public enum FailureMode {
+
+ /// Send the request without a token. The customer's backend still decides what to do with an
+ /// unattested request; this only means the client does not block it locally.
+ ///
+ /// This is the default, and it is the only behaviour available when the app was built without
+ /// the enterprise engine.
+ OPEN,
+
+ /// Refuse to send the request, failing it with a [ShieldException] carrying the reason. Use
+ /// this only where a false negative is more acceptable than an unattested call, and only after
+ /// reading [ShieldStatus#isTransient()] -- most token failures are network problems, not
+ /// compromised devices.
+ CLOSED
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/HostPolicy.java b/CodenameOne/src/com/codename1/security/shield/HostPolicy.java
new file mode 100644
index 00000000000..f76ddbf5db4
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/HostPolicy.java
@@ -0,0 +1,81 @@
+/*
+ * 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.shield;
+
+/// What the shield does for a given host: whether to attach an attestation token, whether to
+/// enforce certificate pins, and what to do when a token cannot be obtained.
+///
+/// Hosts are opt-in. A host with no policy registered gets [#UNPROTECTED] and is left completely
+/// alone -- no header, no pin check, no possibility of a blocked request. That default is what
+/// lets an existing app adopt the shield on its own API without disturbing its analytics, CDN,
+/// map-tile or ad traffic.
+public final class HostPolicy {
+
+ /// The policy for any host the app did not explicitly register. Does nothing at all.
+ public static final HostPolicy UNPROTECTED = new HostPolicy(false, false, FailureMode.OPEN);
+
+ /// Attach a token, enforce pins if the service has published any, and let the request through
+ /// when no token is available. The sensible starting point for a protected host.
+ public static final HostPolicy PROTECTED = new HostPolicy(true, true, FailureMode.OPEN);
+
+ /// As [#PROTECTED] but refuses to send the request without a valid token. Adopt only after
+ /// running with [#PROTECTED] long enough to know the real token-failure rate for your users.
+ public static final HostPolicy ENFORCED = new HostPolicy(true, true, FailureMode.CLOSED);
+
+ private final boolean attachToken;
+ private final boolean enforcePins;
+ private final FailureMode failureMode;
+
+ public HostPolicy(boolean attachToken, boolean enforcePins, FailureMode failureMode) {
+ this.attachToken = attachToken;
+ this.enforcePins = enforcePins;
+ this.failureMode = failureMode == null ? FailureMode.OPEN : failureMode;
+ }
+
+ /// True when requests to this host carry the attestation header.
+ public boolean isAttachToken() {
+ return attachToken;
+ }
+
+ /// True when the certificate chain for this host is checked against the published pin set.
+ /// Note that enforcement still only happens if a pin set for the host actually exists; see
+ /// [PinSet] for the never-brick rules.
+ public boolean isEnforcePins() {
+ return enforcePins;
+ }
+
+ /// What to do when no token could be obtained.
+ public FailureMode getFailureMode() {
+ return failureMode;
+ }
+
+ /// True when this policy does nothing, so callers can skip work entirely.
+ public boolean isNoOp() {
+ return !attachToken && !enforcePins;
+ }
+
+ public String toString() {
+ return "HostPolicy[token=" + attachToken + ", pins=" + enforcePins
+ + ", onFailure=" + failureMode + "]";
+ }
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/PinSet.java b/CodenameOne/src/com/codename1/security/shield/PinSet.java
new file mode 100644
index 00000000000..7b417799158
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/PinSet.java
@@ -0,0 +1,147 @@
+/*
+ * 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.shield;
+
+import java.util.Hashtable;
+import java.util.Vector;
+
+/// An immutable set of certificate pins, keyed by host, as published by the attestation service.
+///
+/// Pins are over the **subject public key info**, not the whole certificate, so a host can renew
+/// its certificate on the same key pair without invalidating the pin. A chain matches if *any*
+/// certificate in it matches *any* pin for the host, which is what makes it safe to pin an issuing
+/// CA as the backup.
+///
+/// #### The never-brick rules
+///
+/// Client-side pinning is the one part of the shield that can take an app offline for reasons the
+/// developer cannot fix without an app store release, so the failure behaviour is deliberately
+/// asymmetric:
+///
+/// - A host with **no pins** is never enforced. That covers first run, a cold start with no
+/// network, and any host the service has not published pins for.
+/// - A **failed pin fetch** never fails a request. The last known set is kept.
+/// - Pins carry a soft expiry, after which a refresh is attempted, and a much later hard expiry.
+/// Past the hard expiry the set is dropped and enforcement stops. A device that cannot reach the
+/// service for weeks loses pinning; it does not lose the app.
+/// - Only an actual mismatch -- a host that *has* pins presenting a chain that matches none of
+/// them -- fails a request, and it fails before any request body is written.
+public final class PinSet {
+
+ /// A pin set with no hosts. Enforces nothing.
+ public static final PinSet EMPTY = new PinSet(new Hashtable(), 0, 0, 0);
+
+ private final Hashtable hostToPins;
+ private final int version;
+ private final long softExpiry;
+ private final long hardExpiry;
+
+ /// @param hostToPins host (lowercase) to a `Vector` of base64 SHA-256 SPKI digests
+ /// @param version monotonic version from the service, used to detect a newer published set
+ /// @param softExpiry local millis after which a refresh should be attempted, 0 for never
+ /// @param hardExpiry local millis after which the set is discarded entirely, 0 for never
+ public PinSet(Hashtable hostToPins, int version, long softExpiry, long hardExpiry) {
+ this.hostToPins = hostToPins == null ? new Hashtable() : hostToPins;
+ this.version = version;
+ this.softExpiry = softExpiry;
+ this.hardExpiry = hardExpiry;
+ }
+
+ public int getVersion() {
+ return version;
+ }
+
+ /// True once the set should be refreshed. Does not mean it has stopped being enforced.
+ public boolean isStale() {
+ return softExpiry > 0 && System.currentTimeMillis() > softExpiry;
+ }
+
+ /// True once the set is too old to keep enforcing. At this point pinning disables itself
+ /// rather than risk locking a long-offline device out of its own app.
+ public boolean isExpired() {
+ return hardExpiry > 0 && System.currentTimeMillis() > hardExpiry;
+ }
+
+ /// True when this set has at least one pin for the host and has not hard-expired, i.e. when a
+ /// chain for this host is actually going to be checked.
+ public boolean isEnforcedFor(String host) {
+ if (host == null || isExpired()) {
+ return false;
+ }
+ Vector pins = pinsFor(host);
+ return pins != null && !pins.isEmpty();
+ }
+
+ /// The pins registered for a host, honouring a leading `*.` wildcard, or null when the host is
+ /// not pinned.
+ public Vector pinsFor(String host) {
+ if (host == null) {
+ return null;
+ }
+ String h = host.toLowerCase();
+ Object exact = hostToPins.get(h);
+ if (exact != null) {
+ return (Vector) exact;
+ }
+ // Walk up the labels so a "*.example.com" entry covers "api.example.com".
+ int dot = h.indexOf('.');
+ while (dot >= 0 && dot < h.length() - 1) {
+ Object wild = hostToPins.get("*." + h.substring(dot + 1));
+ if (wild != null) {
+ return (Vector) wild;
+ }
+ dot = h.indexOf('.', dot + 1);
+ }
+ return null;
+ }
+
+ /// True when at least one of the supplied chain digests matches a pin for the host.
+ ///
+ /// Returns true when the host is not pinned at all -- "no opinion" must never be reported as a
+ /// mismatch, or an unpinned host would start failing.
+ public boolean matches(String host, String[] chainSpkiDigests) {
+ if (!isEnforcedFor(host)) {
+ return true;
+ }
+ if (chainSpkiDigests == null || chainSpkiDigests.length == 0) {
+ return false;
+ }
+ Vector pins = pinsFor(host);
+ for (int i = 0; i < chainSpkiDigests.length; i++) {
+ if (chainSpkiDigests[i] != null && pins.contains(chainSpkiDigests[i])) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /// True when no host is pinned.
+ public boolean isEmpty() {
+ return hostToPins.isEmpty();
+ }
+
+ public String toString() {
+ return "PinSet[version=" + version + ", hosts=" + hostToPins.size()
+ + ", stale=" + isStale() + ", expired=" + isExpired() + "]";
+ }
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java b/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
new file mode 100644
index 00000000000..3060313e787
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
@@ -0,0 +1,165 @@
+/*
+ * 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.shield;
+
+import java.util.Enumeration;
+import java.util.Hashtable;
+
+/// Configuration for [AppShield#init(ShieldConfig)]. Chainable.
+///
+/// ```java
+/// AppShield.init(new ShieldConfig()
+/// .protect("api.mybank.example", HostPolicy.PROTECTED)
+/// .protect("*.mybank.example", HostPolicy.PROTECTED));
+/// ```
+///
+/// The defaults are chosen so that adding the shield to an existing app changes nothing until
+/// hosts are explicitly protected: no host is touched, failures are open, and signal collection is
+/// on because reporting costs nothing and is what makes the service useful.
+public final class ShieldConfig {
+
+ /// Header that carries the attestation token.
+ ///
+ /// Deliberately not `Authorization`: that slot belongs to the app's own user authentication,
+ /// and the two answer different questions -- who the user is, versus whether this is a genuine
+ /// unmodified app on an uncompromised device. A backend needs both, so they must compose.
+ public static final String DEFAULT_TOKEN_HEADER = "X-CN1-Attest";
+
+ private static final String DEFAULT_ENDPOINT = "https://cloud.codenameone.com/api/v2/attest";
+
+ private String endpoint = DEFAULT_ENDPOINT;
+ private String tokenHeader = DEFAULT_TOKEN_HEADER;
+ private FailureMode defaultFailureMode = FailureMode.OPEN;
+ private int refreshThresholdPercent = 50;
+ private boolean collectSignals = true;
+ private final Hashtable hostPolicies = new Hashtable();
+
+ /// Overrides the attestation service endpoint. Only needed for a private deployment or a test
+ /// double.
+ public ShieldConfig endpoint(String url) {
+ if (url != null) {
+ this.endpoint = url;
+ }
+ return this;
+ }
+
+ /// Overrides the header used to carry the token. Change this only if it collides with
+ /// something already in use on your backend.
+ public ShieldConfig tokenHeader(String name) {
+ if (name != null && name.length() > 0) {
+ this.tokenHeader = name;
+ }
+ return this;
+ }
+
+ /// The failure mode applied to hosts registered without an explicit one.
+ public ShieldConfig defaultFailureMode(FailureMode mode) {
+ if (mode != null) {
+ this.defaultFailureMode = mode;
+ }
+ return this;
+ }
+
+ /// How far through a token's lifetime to trigger a background refresh, as a percentage.
+ /// Refreshing early is what stops a request ever having to wait on the network.
+ public ShieldConfig refreshThresholdPercent(int percent) {
+ if (percent > 0 && percent < 100) {
+ this.refreshThresholdPercent = percent;
+ }
+ return this;
+ }
+
+ /// Whether to gather runtime self-protection observations. On by default; they ride along with
+ /// the token fetch, so there is no extra request and no extra battery cost.
+ public ShieldConfig collectSignals(boolean collect) {
+ this.collectSignals = collect;
+ return this;
+ }
+
+ /// Registers a host to protect. Accepts an exact host or a leading `*.` wildcard covering its
+ /// subdomains. Hosts not registered here are never touched.
+ public ShieldConfig protect(String hostPattern, HostPolicy policy) {
+ if (hostPattern != null && hostPattern.length() > 0) {
+ hostPolicies.put(hostPattern.toLowerCase(),
+ policy == null ? HostPolicy.PROTECTED : policy);
+ }
+ return this;
+ }
+
+ /// Registers a host with [HostPolicy#PROTECTED].
+ public ShieldConfig protect(String hostPattern) {
+ return protect(hostPattern, HostPolicy.PROTECTED);
+ }
+
+ public String getEndpoint() {
+ return endpoint;
+ }
+
+ public String getTokenHeader() {
+ return tokenHeader;
+ }
+
+ public FailureMode getDefaultFailureMode() {
+ return defaultFailureMode;
+ }
+
+ public int getRefreshThresholdPercent() {
+ return refreshThresholdPercent;
+ }
+
+ public boolean isCollectSignals() {
+ return collectSignals;
+ }
+
+ /// Resolves a host to its policy: exact match first, then the nearest `*.` wildcard, then
+ /// [HostPolicy#UNPROTECTED]. Never returns null.
+ public HostPolicy policyFor(String host) {
+ if (host == null) {
+ return HostPolicy.UNPROTECTED;
+ }
+ String h = host.toLowerCase();
+ Object exact = hostPolicies.get(h);
+ if (exact != null) {
+ return (HostPolicy) exact;
+ }
+ int dot = h.indexOf('.');
+ while (dot >= 0 && dot < h.length() - 1) {
+ Object wild = hostPolicies.get("*." + h.substring(dot + 1));
+ if (wild != null) {
+ return (HostPolicy) wild;
+ }
+ dot = h.indexOf('.', dot + 1);
+ }
+ return HostPolicy.UNPROTECTED;
+ }
+
+ /// True when at least one host is registered, so callers can skip work entirely.
+ public boolean hasProtectedHosts() {
+ return !hostPolicies.isEmpty();
+ }
+
+ /// The registered host patterns.
+ public Enumeration protectedHosts() {
+ return hostPolicies.keys();
+ }
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldException.java b/CodenameOne/src/com/codename1/security/shield/ShieldException.java
new file mode 100644
index 00000000000..a35b7dd3f2a
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldException.java
@@ -0,0 +1,47 @@
+/*
+ * 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.shield;
+
+import java.io.IOException;
+
+/// Raised when a shield operation cannot produce a usable token, or when a request to a protected
+/// host is refused because its certificate chain matched no configured pin.
+///
+/// Extends `IOException` so it flows through the normal `ConnectionRequest` error path rather than
+/// needing its own handling. Always check [#getStatus()] before deciding what to show the user:
+/// [ShieldStatus#isTransient()] distinguishes "could not reach the service" from "this device was
+/// rejected", and those deserve very different UX.
+public class ShieldException extends IOException {
+
+ private final ShieldStatus status;
+
+ public ShieldException(ShieldStatus status, String message) {
+ super(message);
+ this.status = status == null ? ShieldStatus.NOT_INITIALIZED : status;
+ }
+
+ /// Why the operation failed. Never null.
+ public ShieldStatus getStatus() {
+ return status;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldListener.java b/CodenameOne/src/com/codename1/security/shield/ShieldListener.java
new file mode 100644
index 00000000000..5872b55350a
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldListener.java
@@ -0,0 +1,43 @@
+/*
+ * 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.shield;
+
+/// Callback for shield state changes. Register with [AppShield#addListener(ShieldListener)].
+///
+/// All callbacks are delivered on the EDT, so they may touch the UI directly. The framework itself
+/// never shows a dialog or terminates the app over a shield event -- what the user sees is entirely
+/// the app's decision, made here.
+public interface ShieldListener {
+
+ /// The token status changed, for example from [ShieldStatus#OK] to [ShieldStatus#REJECTED].
+ ///
+ /// Branch on [ShieldStatus#isTransient()] before reacting. A transient status means the
+ /// service was unreachable and will likely be reachable again shortly; reacting to it the same
+ /// way as [ShieldStatus#REJECTED] is how an app ends up locking out users on a bad connection.
+ void statusChanged(ShieldStatus status);
+
+ /// A new runtime self-protection observation was recorded. Informational: the attestation
+ /// service decides what a signal means for token issuance, and it may reach a different
+ /// conclusion than the device would.
+ void signalRaised(ShieldSignal signal);
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldSignal.java b/CodenameOne/src/com/codename1/security/shield/ShieldSignal.java
new file mode 100644
index 00000000000..13c6c6f4a15
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldSignal.java
@@ -0,0 +1,85 @@
+/*
+ * 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.shield;
+
+/// One runtime self-protection observation, such as "a hooking framework is loaded".
+///
+/// Signals are **reports, not verdicts**. The device never decides it is compromised and never
+/// terminates itself over one of these; it reports what it saw and the attestation service decides
+/// whether to keep issuing tokens. That ordering matters for two reasons: a hard local exit is
+/// trivially patched out of the binary, and it destroys the telemetry that would have told the
+/// developer an attack was happening at all.
+public final class ShieldSignal {
+
+ /// A rooted Android device.
+ public static final String ROOT = "root";
+ /// A jailbroken iOS device.
+ public static final String JAILBREAK = "jailbreak";
+ /// A dynamic instrumentation or hooking framework is present.
+ public static final String HOOK = "hook";
+ /// The app is running on an emulator or simulator.
+ public static final String EMULATOR = "emulator";
+ /// A debugger is attached to the process.
+ public static final String DEBUGGER = "debugger";
+ /// The app's signing certificate does not match the one it was built with.
+ public static final String REPACKAGED = "repackaged";
+ /// An accessibility service that is not on the allow list is enabled.
+ public static final String ACCESSIBILITY = "accessibility";
+
+ private final String id;
+ private final int severity;
+ private final String detail;
+ private final long timestamp;
+
+ public ShieldSignal(String id, int severity, String detail) {
+ this.id = id;
+ this.severity = severity < 0 ? 0 : (severity > 100 ? 100 : severity);
+ this.detail = detail;
+ this.timestamp = System.currentTimeMillis();
+ }
+
+ /// A stable identifier such as [#HOOK]. Engines may report ids this build predates.
+ public String getId() {
+ return id;
+ }
+
+ /// How strongly this points at an attack, 0 to 100. Advisory only -- the service applies the
+ /// policy, so a low severity here does not mean the service will ignore it.
+ public int getSeverity() {
+ return severity;
+ }
+
+ /// What was actually observed, for example the offending package or library name. May be null.
+ public String getDetail() {
+ return detail;
+ }
+
+ /// When the observation was made.
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ public String toString() {
+ return id + "(" + severity + (detail == null ? "" : ", " + detail) + ")";
+ }
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldSignals.java b/CodenameOne/src/com/codename1/security/shield/ShieldSignals.java
new file mode 100644
index 00000000000..6a08e395890
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldSignals.java
@@ -0,0 +1,148 @@
+/*
+ * 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.shield;
+
+import com.codename1.ui.Display;
+import java.util.Vector;
+
+/// Collection point for runtime self-protection observations.
+///
+/// The framework's own detections report here, and so can a cn1lib or the app itself when it
+/// notices something the platform checks cannot see -- a failed server-side consistency check, a
+/// suspicious sequence of user actions. Everything recorded here is offered to the attestation
+/// service on the next token fetch, where the policy engine decides what it means.
+///
+/// The bus is bounded: it keeps only the most recent observations, and repeat reports of the same
+/// id collapse onto the existing entry rather than accumulating. A hooking framework that trips a
+/// detector on every frame must not be able to exhaust memory.
+public final class ShieldSignals {
+
+ private static final int MAX_SIGNALS = 32;
+
+ private static final Vector signals = new Vector();
+ private static final Vector listeners = new Vector();
+
+ private ShieldSignals() {
+ }
+
+ /// Records an observation. Repeat reports of an id already present update that entry in place.
+ /// Safe to call from any thread; listeners are notified on the EDT.
+ public static void add(ShieldSignal signal) {
+ if (signal == null || signal.getId() == null) {
+ return;
+ }
+ synchronized (signals) {
+ for (int i = 0; i < signals.size(); i++) {
+ if (((ShieldSignal) signals.elementAt(i)).getId().equals(signal.getId())) {
+ signals.setElementAt(signal, i);
+ notifyListeners(signal);
+ return;
+ }
+ }
+ if (signals.size() >= MAX_SIGNALS) {
+ signals.removeElementAt(0);
+ }
+ signals.addElement(signal);
+ }
+ notifyListeners(signal);
+ }
+
+ /// Convenience overload for the common case.
+ public static void add(String id, int severity, String detail) {
+ add(new ShieldSignal(id, severity, detail));
+ }
+
+ /// The observations recorded so far. Never null.
+ public static ShieldSignal[] snapshot() {
+ synchronized (signals) {
+ ShieldSignal[] out = new ShieldSignal[signals.size()];
+ signals.copyInto(out);
+ return out;
+ }
+ }
+
+ /// True when any recorded observation is at or above the given severity.
+ public static boolean hasSignalAtLeast(int severity) {
+ synchronized (signals) {
+ for (int i = 0; i < signals.size(); i++) {
+ if (((ShieldSignal) signals.elementAt(i)).getSeverity() >= severity) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /// Discards every recorded observation. Intended for tests and for the simulator's
+ /// signal-faking menu.
+ public static void clear() {
+ synchronized (signals) {
+ signals.removeAllElements();
+ }
+ }
+
+ static void addListener(ShieldListener l) {
+ if (l == null) {
+ return;
+ }
+ synchronized (listeners) {
+ if (!listeners.contains(l)) {
+ listeners.addElement(l);
+ }
+ }
+ }
+
+ static void removeListener(ShieldListener l) {
+ synchronized (listeners) {
+ listeners.removeElement(l);
+ }
+ }
+
+ private static void notifyListeners(ShieldSignal signal) {
+ ShieldListener[] copy;
+ synchronized (listeners) {
+ if (listeners.isEmpty()) {
+ return;
+ }
+ copy = new ShieldListener[listeners.size()];
+ listeners.copyInto(copy);
+ }
+ Display.getInstance().callSerially(new SignalDispatch(copy, signal));
+ }
+
+ private static final class SignalDispatch implements Runnable {
+ private final ShieldListener[] targets;
+ private final ShieldSignal signal;
+
+ SignalDispatch(ShieldListener[] targets, ShieldSignal signal) {
+ this.targets = targets;
+ this.signal = signal;
+ }
+
+ public void run() {
+ for (int i = 0; i < targets.length; i++) {
+ targets[i].signalRaised(signal);
+ }
+ }
+ }
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldStatus.java b/CodenameOne/src/com/codename1/security/shield/ShieldStatus.java
new file mode 100644
index 00000000000..b2e2c891d90
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldStatus.java
@@ -0,0 +1,133 @@
+/*
+ * 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.shield;
+
+/// Outcome of a shield operation.
+///
+/// The single most important distinction in this class is between *"I could not reach the
+/// attestation service"* ([#NO_NETWORK], [#POOR_NETWORK], [#SERVICE_DOWN], [#RATE_LIMITED]) and
+/// *"the attestation service looked at this device and said no"* ([#REJECTED]). An app should
+/// almost always treat the first group as a transient condition to retry through, and only the
+/// second as evidence that something is actually wrong with the device it is running on.
+/// Collapsing the two into a single "attestation failed" boolean is the most common way to build
+/// an app that either locks out users on a train or trusts a rooted phone.
+///
+/// This is a class of constants rather than an enum because the vocabulary is wire-visible: the
+/// attestation engine may report a status that this build of the framework predates, and
+/// [#getId()] round-trips it rather than failing to resolve.
+public final class ShieldStatus {
+
+ /// The operation succeeded and any token returned is usable.
+ public static final ShieldStatus OK = new ShieldStatus("ok", true);
+
+ /// The app was built without the enterprise attestation engine. Everything degrades to a
+ /// no-op: no token is issued, no pin is enforced, and no request is blocked.
+ public static final ShieldStatus UNPROTECTED = new ShieldStatus("unprotected", false);
+
+ /// [AppShield#init(ShieldConfig)] has not been called yet.
+ public static final ShieldStatus NOT_INITIALIZED = new ShieldStatus("notInitialized", false);
+
+ /// The device has no connectivity. Transient.
+ public static final ShieldStatus NO_NETWORK = new ShieldStatus("noNetwork", false);
+
+ /// The request timed out or DNS failed. Transient.
+ public static final ShieldStatus POOR_NETWORK = new ShieldStatus("poorNetwork", false);
+
+ /// The attestation service answered with a server error. Transient.
+ public static final ShieldStatus SERVICE_DOWN = new ShieldStatus("serviceUnavailable", false);
+
+ /// This device is asking too often and is being throttled. Transient, but back off before
+ /// retrying rather than looping.
+ public static final ShieldStatus RATE_LIMITED = new ShieldStatus("rateLimited", false);
+
+ /// The service evaluated this device and declined to issue a token. **Not** transient: the
+ /// device itself is what failed the policy. Retrying will not help.
+ public static final ShieldStatus REJECTED = new ShieldStatus("rejected", false);
+
+ /// The certificate chain presented by a protected host matched no configured pin. The request
+ /// was refused before any request body was sent.
+ public static final ShieldStatus PIN_MISMATCH = new ShieldStatus("pinMismatch", false);
+
+ private static final ShieldStatus[] KNOWN = {
+ OK, UNPROTECTED, NOT_INITIALIZED, NO_NETWORK, POOR_NETWORK,
+ SERVICE_DOWN, RATE_LIMITED, REJECTED, PIN_MISMATCH
+ };
+
+ private final String id;
+ private final boolean success;
+
+ private ShieldStatus(String id, boolean success) {
+ this.id = id;
+ this.success = success;
+ }
+
+ /// The stable wire identifier, e.g. `rateLimited`.
+ public String getId() {
+ return id;
+ }
+
+ /// True only for [#OK]. Every other status means no usable token was produced.
+ public boolean isSuccess() {
+ return success;
+ }
+
+ /// True when the failure is about reaching the service rather than about this device. Retrying
+ /// later may succeed. False for [#REJECTED] and [#PIN_MISMATCH], which describe the device and
+ /// the connection respectively.
+ public boolean isTransient() {
+ return this == NO_NETWORK || this == POOR_NETWORK
+ || this == SERVICE_DOWN || this == RATE_LIMITED;
+ }
+
+ /// Resolves a wire identifier to a constant, or synthesises a non-success status for an
+ /// identifier this build does not know about. Never returns null.
+ public static ShieldStatus forId(String id) {
+ if (id == null) {
+ return NOT_INITIALIZED;
+ }
+ for (int i = 0; i < KNOWN.length; i++) {
+ if (KNOWN[i].id.equals(id)) {
+ return KNOWN[i];
+ }
+ }
+ return new ShieldStatus(id, false);
+ }
+
+ public String toString() {
+ return id;
+ }
+
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof ShieldStatus)) {
+ return false;
+ }
+ return id.equals(((ShieldStatus) o).id);
+ }
+
+ public int hashCode() {
+ return id.hashCode();
+ }
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldToken.java b/CodenameOne/src/com/codename1/security/shield/ShieldToken.java
new file mode 100644
index 00000000000..43c9ee6d5f8
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldToken.java
@@ -0,0 +1,109 @@
+/*
+ * 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.shield;
+
+/// A short-lived attestation token, ready to be attached to a request.
+///
+/// The value is opaque to the app. It is meaningful only to the backend that verifies it against
+/// the published Codename One signing keys -- do not parse it, and do not make a security decision
+/// on the device based on its contents, because a device the attacker controls can be made to say
+/// anything.
+///
+/// #### Expiry is measured locally, on purpose
+///
+/// Validity is tracked as "fetched at + time to live" using local elapsed time, never by reading an
+/// expiry field out of the token and comparing it against the device clock. On a rooted device the
+/// clock is attacker-controlled, so a token-embedded expiry can be made to look valid forever. The
+/// verifying backend does its own absolute-time check regardless; [#isValid()] exists so the client
+/// knows when to refresh, not to enforce anything.
+public final class ShieldToken {
+
+ private final String value;
+ private final ShieldStatus status;
+ private final long fetchedAt;
+ private final long ttlMillis;
+ private final String binding;
+
+ public ShieldToken(String value, ShieldStatus status, long fetchedAt,
+ long ttlMillis, String binding) {
+ this.value = value;
+ this.status = status == null ? ShieldStatus.OK : status;
+ this.fetchedAt = fetchedAt;
+ this.ttlMillis = ttlMillis;
+ this.binding = binding;
+ }
+
+ /// The opaque token to place in the request header. May be null when [#getStatus()] is not
+ /// [ShieldStatus#OK].
+ public String getValue() {
+ return value;
+ }
+
+ /// Outcome of the fetch that produced this token.
+ public ShieldStatus getStatus() {
+ return status;
+ }
+
+ /// Milliseconds until this token stops being worth sending, or 0 once it has lapsed.
+ public long getMillisUntilExpiry() {
+ long remaining = (fetchedAt + ttlMillis) - System.currentTimeMillis();
+ return remaining > 0 ? remaining : 0;
+ }
+
+ /// True when the token has a value, was fetched successfully, and has not lapsed.
+ public boolean isValid() {
+ return value != null && status.isSuccess() && getMillisUntilExpiry() > 0;
+ }
+
+ /// True once the token is far enough through its lifetime to be worth refreshing in the
+ /// background. Refreshing before expiry is what keeps a request from ever having to block.
+ public boolean shouldRefresh(int thresholdPercent) {
+ if (ttlMillis <= 0) {
+ return true;
+ }
+ long used = System.currentTimeMillis() - fetchedAt;
+ return used * 100 >= ttlMillis * thresholdPercent;
+ }
+
+ /// The request-binding data this token was minted for, or null when it is a plain
+ /// time-limited token not tied to a specific request.
+ public String getBinding() {
+ return binding;
+ }
+
+ /// True when this token was minted for exactly the supplied binding data. A token bound to one
+ /// request must not be reused for another; that is the whole point of binding.
+ public boolean isBoundTo(String data) {
+ if (binding == null) {
+ return data == null;
+ }
+ return binding.equals(data);
+ }
+
+ /// Never renders the token value -- these strings end up in logs.
+ public String toString() {
+ return "ShieldToken[status=" + status.getId()
+ + ", validMs=" + getMillisUntilExpiry()
+ + ", bound=" + (binding != null) + "]";
+ }
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/package-info.java b/CodenameOne/src/com/codename1/security/shield/package-info.java
new file mode 100644
index 00000000000..01dff0e4a22
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/package-info.java
@@ -0,0 +1,50 @@
+/*
+ * 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.
+ */
+
+/// API shielding: attestation tokens, over-the-air certificate pinning, and runtime
+/// self-protection reporting.
+///
+/// Start at [com.codename1.security.shield.AppShield].
+///
+/// #### What this can and cannot do
+///
+/// Worth being precise about, because the category attracts overclaiming. On a device the attacker
+/// fully controls, no client-side check is unbypassable -- detection code can be patched out and
+/// headers can be stripped. What this buys you is threefold:
+///
+/// 1. Your backend gets a **cryptographically verifiable statement from Apple or Google** about the
+/// app and device, evaluated by a service the attacker does not control. That is a categorically
+/// different thing from a boolean your own app computed about itself.
+/// 2. The cost of scripted abuse rises from "reproduce the API calls with a shell script" to
+/// "reverse-engineer and re-sign a native binary, per release".
+/// 3. Certificate pins rotate over the air, so a pin change no longer needs an app store release --
+/// which is what makes pinning practical to run at all.
+///
+/// It does not make an app unhackable, and any product in this space that says otherwise is selling
+/// something.
+///
+/// #### The load-bearing part is on your server
+///
+/// The token means nothing until your backend refuses to serve requests without a valid one. Until
+/// that check exists, adding the shield changes nothing about your security.
+package com.codename1.security.shield;
diff --git a/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java b/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java
new file mode 100644
index 00000000000..45c8067e222
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java
@@ -0,0 +1,111 @@
+/*
+ * 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.shield.spi;
+
+import com.codename1.io.Log;
+import com.codename1.security.DeviceIntegrity;
+import com.codename1.security.SecureStorage;
+import com.codename1.security.shield.ShieldSignal;
+import com.codename1.security.shield.ShieldSignals;
+import com.codename1.ui.Display;
+import com.codename1.util.AsyncResource;
+
+/// The framework-backed [EngineContext] handed to an engine at initialization.
+///
+/// Every method is defensive: an engine runs early in startup, often on a device in an unusual
+/// state, and a platform probe that throws must degrade to an empty answer rather than take the
+/// app down before it has drawn a frame.
+final class DefaultEngineContext implements EngineContext {
+
+ static final DefaultEngineContext INSTANCE = new DefaultEngineContext();
+
+ private DefaultEngineContext() {
+ }
+
+ public SecureStorage getSecureStorage() {
+ return SecureStorage.getInstance();
+ }
+
+ public AsyncResource requestPlatformAttestation(String nonce) {
+ return DeviceIntegrity.requestIntegrityToken(nonce);
+ }
+
+ public boolean isPlatformAttestationSupported() {
+ try {
+ return DeviceIntegrity.isAttestationSupported();
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+
+ public void resetPlatformAttestation() {
+ try {
+ DeviceIntegrity.resetAttestation();
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ public String[] getPlatformCompromiseReasons() {
+ try {
+ String[] r = DeviceIntegrity.getCompromiseReasons();
+ return r == null ? new String[0] : r;
+ } catch (Throwable t) {
+ return new String[0];
+ }
+ }
+
+ public String[] getEnabledAccessibilityServices() {
+ try {
+ String[] r = DeviceIntegrity.getEnabledAccessibilityServices();
+ return r == null ? new String[0] : r;
+ } catch (Throwable t) {
+ return new String[0];
+ }
+ }
+
+ public String[] getAppSignerDigests() {
+ try {
+ String[] r = Display.getInstance().getAppSignerDigests();
+ return r == null ? new String[0] : r;
+ } catch (Throwable t) {
+ return new String[0];
+ }
+ }
+
+ public String getProperty(String key, String defaultValue) {
+ try {
+ return Display.getInstance().getProperty(key, defaultValue);
+ } catch (Throwable t) {
+ return defaultValue;
+ }
+ }
+
+ public void log(String message) {
+ Log.p(message);
+ }
+
+ public void publishSignal(ShieldSignal signal) {
+ ShieldSignals.add(signal);
+ }
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java b/CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java
new file mode 100644
index 00000000000..824217b8c2a
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java
@@ -0,0 +1,76 @@
+/*
+ * 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.shield.spi;
+
+import com.codename1.security.SecureStorage;
+import com.codename1.security.shield.ShieldSignal;
+import com.codename1.util.AsyncResource;
+
+/// The narrow set of framework services lent to a [ShieldEngine].
+///
+/// Handed to the engine rather than reached for, so the engine never needs access to the platform
+/// implementation object. That keeps the framework's implementation accessor package-private, and
+/// it keeps the list of things an engine can do small enough to review.
+public interface EngineContext {
+
+ /// Non-prompting secure storage, for the attestation key identifier and cached tokens. On
+ /// device this is the platform keychain or keystore.
+ SecureStorage getSecureStorage();
+
+ /// Requests a raw platform attestation (Play Integrity or App Attest) bound to the nonce.
+ ///
+ /// The result is opaque and must be forwarded to the verifying service; the engine must not
+ /// try to interpret it on the device, because a device the attacker controls can be made to
+ /// produce any interpretation.
+ AsyncResource requestPlatformAttestation(String nonce);
+
+ /// True when the platform provides attestation and this build bundled it.
+ boolean isPlatformAttestationSupported();
+
+ /// Clears cached platform attestation state, forcing a fresh hardware key on the next request.
+ /// Used when the service reports that the device's attestation key is unknown to it.
+ void resetPlatformAttestation();
+
+ /// Platform-detected compromise reasons, such as `root` or `frida`.
+ String[] getPlatformCompromiseReasons();
+
+ /// Component identifiers of the accessibility services currently enabled.
+ String[] getEnabledAccessibilityServices();
+
+ /// Digests of the certificates the running app is actually signed with, for comparison against
+ /// what it was built with. Empty where the platform cannot report it.
+ ///
+ /// Not exposed as public framework API: nothing in an app needs this, and publishing it would
+ /// only tell an attacker exactly which value to fake.
+ String[] getAppSignerDigests();
+
+ /// A build-stamped property, such as the build key or the per-build hardening manifest.
+ String getProperty(String key, String defaultValue);
+
+ /// Writes to the framework log.
+ void log(String message);
+
+ /// Publishes an observation to [com.codename1.security.shield.ShieldSignals], where the app
+ /// can see it and from where it is offered to the service on the next token fetch.
+ void publishSignal(ShieldSignal signal);
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngine.java b/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngine.java
new file mode 100644
index 00000000000..06d83e186b7
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngine.java
@@ -0,0 +1,112 @@
+/*
+ * 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.shield.spi;
+
+import com.codename1.security.shield.PinSet;
+import com.codename1.security.shield.ShieldConfig;
+import com.codename1.security.shield.ShieldException;
+import com.codename1.security.shield.ShieldSignal;
+import com.codename1.security.shield.ShieldToken;
+
+/// The service-provider seam between the public shield API and the attestation engine that
+/// implements it.
+///
+/// Codename One ships an inert default that reports itself unavailable, so an app written against
+/// [com.codename1.security.shield.AppShield] compiles and runs everywhere. A build entitled to the
+/// enterprise engine has a real implementation registered by the build server before
+/// `Display.init`, via [ShieldEngineRegistry#setEngine(ShieldEngine)].
+///
+/// #### What must never move into the framework
+///
+/// The split is only worth anything if the engine keeps the parts an attacker would want to reach.
+/// An implementation must own, and must never delegate to open framework code:
+///
+/// - challenge and nonce generation -- predictable nonces make replay possible;
+/// - any key material, and any code that touches it;
+/// - the pin **comparison** and the decision to fail a request. The framework may hold a
+/// [PinSet]; patching the framework's copy must not be enough to disable pinning;
+/// - the detection heuristics themselves. Published heuristics are bypassed heuristics, so the
+/// framework only ever sees finished [ShieldSignal] results -- and only the ones the engine
+/// chooses to publish;
+/// - interpretation of the raw platform attestation. Those responses go to the verifying service,
+/// which the attacker does not control, rather than being judged on the device.
+///
+/// The security property this preserves is not "the app refuses to make the call" -- an attacker
+/// who controls the device can always strip a header. It is that the customer's backend refuses to
+/// *serve* a request without a valid, unexpired, service-signed token, which a substituted engine
+/// cannot mint.
+public interface ShieldEngine {
+
+ /// A stable name for diagnostics, for example `unprotected`, `simulator` or the enterprise
+ /// engine's own identifier.
+ String getName();
+
+ /// True when this engine can actually attest. False for the inert default, which is how
+ /// [com.codename1.security.shield.AppShield#isProtected()] is answered.
+ boolean isAvailable();
+
+ /// Called once from [com.codename1.security.shield.AppShield#init(ShieldConfig)]. Must not
+ /// block on the network; do warm-up work on a background thread.
+ void initialize(EngineContext ctx, ShieldConfig config);
+
+ /// Obtains a token, blocking until it has one or fails. Called on a network thread, never the
+ /// EDT.
+ ///
+ /// @param bindingData request data to bind the token to, or null for a plain time-limited
+ /// token. A bound token is only valid for the request whose data was supplied.
+ /// @throws ShieldException carrying the [com.codename1.security.shield.ShieldStatus] that
+ /// explains whether the failure was about reaching the service or about this device
+ ShieldToken fetchToken(String bindingData) throws ShieldException;
+
+ /// The cached token, without contacting the service. Returns null when nothing is cached.
+ ///
+ /// Must never block: callers are typically on the EDT, deciding whether they can decorate a
+ /// request right now.
+ ShieldToken getCachedToken();
+
+ /// Decides whether a certificate chain is acceptable for a host.
+ ///
+ /// Must be purely local and non-blocking: on iOS this is invoked synchronously from the TLS
+ /// delegate thread while the handshake is held open, so any network call or blocking wait here
+ /// deadlocks the connection.
+ ///
+ /// Returns true when the host is not pinned -- "no opinion" must never read as a mismatch.
+ ///
+ /// @param spkiDigests base64 SHA-256 digests of each chain certificate's public key info
+ /// @param certDigests whole-certificate digests, for engines that pin those instead
+ boolean verifyPins(String host, String[] spkiDigests, String[] certDigests);
+
+ /// The pin set currently in force, never null. May be [PinSet#EMPTY].
+ PinSet getPinSet();
+
+ /// The runtime self-protection observations this engine wants reported. May legitimately be a
+ /// subset of what it detected.
+ ShieldSignal[] collectSignals();
+
+ /// Discards any cached token, forcing the next fetch to go to the service. Called when a
+ /// backend rejects a token, which usually means the device's attestation state is stale.
+ void invalidate();
+
+ /// Releases resources. Called when the app is shutting down.
+ void shutdown();
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngineRegistry.java b/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngineRegistry.java
new file mode 100644
index 00000000000..cb4c8419a10
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngineRegistry.java
@@ -0,0 +1,100 @@
+/*
+ * 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.shield.spi;
+
+/// Where the attestation engine registers itself.
+///
+/// Registration is by direct instance, not by class name. Codename One obfuscates and renames
+/// classes -- ProGuard/R8 on Android, the bytecode-to-C translation on iOS -- so a
+/// `Class.forName` lookup is unreliable by construction. The port or the build-server-generated
+/// bootstrap instantiates the engine itself and passes the instance here, which survives renaming
+/// because it is an ordinary symbol reference. The same convention is used elsewhere in the
+/// framework for port-supplied implementations.
+///
+/// Registration happens in one of three places:
+///
+/// - **Device builds**: the build server splices a bootstrap into the generated application stub,
+/// ahead of `Display.init`, when the project is entitled to the enterprise engine.
+/// - **Simulator**: the desktop port's post-init bootstrap scan picks it up, which is the one
+/// place a name-based lookup is safe because the desktop port is not obfuscated.
+/// - **Tests**: call [#setEngine(ShieldEngine)] directly.
+///
+/// The first registration wins and the registry then seals. Without that, any code running later
+/// in the process -- including code an attacker injected -- could swap in an engine that returns
+/// whatever it likes. Sealing does not make the app tamper-proof (an attacker who can patch the
+/// binary can patch this too); it removes the version of the attack that needs no patching at all.
+public final class ShieldEngineRegistry {
+
+ private static ShieldEngine engine;
+ private static boolean sealed;
+
+ private ShieldEngineRegistry() {
+ }
+
+ /// Registers the engine. The first call wins.
+ ///
+ /// @throws IllegalStateException if an engine is already registered
+ public static void setEngine(ShieldEngine e) {
+ if (e == null) {
+ throw new IllegalArgumentException("engine is null");
+ }
+ synchronized (ShieldEngineRegistry.class) {
+ if (sealed) {
+ throw new IllegalStateException(
+ "A shield engine is already registered: " + engine.getName());
+ }
+ engine = e;
+ sealed = true;
+ }
+ }
+
+ /// The registered engine, or the inert default when none was registered. Never null, so no
+ /// caller needs a null check and no code path can silently skip a check that should have run.
+ public static ShieldEngine getEngine() {
+ synchronized (ShieldEngineRegistry.class) {
+ return engine != null ? engine : UnprotectedEngine.INSTANCE;
+ }
+ }
+
+ /// The framework-backed [EngineContext] an engine is initialized with. Exposed so a port or a
+ /// test can construct an engine against the real services without reimplementing them.
+ public static EngineContext getDefaultContext() {
+ return DefaultEngineContext.INSTANCE;
+ }
+
+ /// True when a real engine was registered.
+ public static boolean isEngineRegistered() {
+ synchronized (ShieldEngineRegistry.class) {
+ return engine != null;
+ }
+ }
+
+ /// Test hook: drops the registration and unseals. Not for application use -- there is no
+ /// legitimate reason for a shipping app to replace its engine at runtime.
+ static void resetForTesting() {
+ synchronized (ShieldEngineRegistry.class) {
+ engine = null;
+ sealed = false;
+ }
+ }
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/spi/UnprotectedEngine.java b/CodenameOne/src/com/codename1/security/shield/spi/UnprotectedEngine.java
new file mode 100644
index 00000000000..d29b2b8aed7
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/spi/UnprotectedEngine.java
@@ -0,0 +1,139 @@
+/*
+ * 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.shield.spi;
+
+import com.codename1.security.DeviceIntegrity;
+import com.codename1.security.shield.PinSet;
+import com.codename1.security.shield.ShieldConfig;
+import com.codename1.security.shield.ShieldException;
+import com.codename1.security.shield.ShieldSignal;
+import com.codename1.security.shield.ShieldStatus;
+import com.codename1.security.shield.ShieldToken;
+import java.util.Vector;
+
+/// The engine used when no attestation engine was registered -- an open-source build, a build not
+/// entitled to the enterprise engine, or a unit test.
+///
+/// The contract it implements is the degradation promise the public API makes: an app written
+/// against the shield must run everywhere, and must never fail closed just because attestation is
+/// unavailable.
+///
+/// - `fetchToken` **completes**, with a [ShieldStatus#UNPROTECTED] failure. It never hangs and
+/// never throws synchronously, so callers written for the real engine follow their normal error
+/// path instead of deadlocking.
+/// - `verifyPins` returns true. There is no pin set to enforce, and reporting "no opinion" as a
+/// mismatch would break every request.
+/// - Nothing here can block a request.
+///
+/// It is not entirely inert: [#collectSignals()] still reports what the free platform checks
+/// found, so an app can react to a rooted device without an enterprise entitlement.
+final class UnprotectedEngine implements ShieldEngine {
+
+ static final UnprotectedEngine INSTANCE = new UnprotectedEngine();
+
+ private UnprotectedEngine() {
+ }
+
+ public String getName() {
+ return "unprotected";
+ }
+
+ public boolean isAvailable() {
+ return false;
+ }
+
+ public void initialize(EngineContext ctx, ShieldConfig config) {
+ if (ctx != null) {
+ ctx.log("AppShield: no attestation engine registered; running unprotected. "
+ + "Tokens are not issued and certificate pins are not enforced.");
+ }
+ }
+
+ public ShieldToken fetchToken(String bindingData) throws ShieldException {
+ throw new ShieldException(ShieldStatus.UNPROTECTED,
+ "This build has no attestation engine, so no token can be issued.");
+ }
+
+ public ShieldToken getCachedToken() {
+ return null;
+ }
+
+ public boolean verifyPins(String host, String[] spkiDigests, String[] certDigests) {
+ return true;
+ }
+
+ public PinSet getPinSet() {
+ return PinSet.EMPTY;
+ }
+
+ public ShieldSignal[] collectSignals() {
+ String[] reasons;
+ try {
+ reasons = DeviceIntegrity.getCompromiseReasons();
+ } catch (Throwable t) {
+ // Never let a platform probe break the caller; an absent signal is
+ // strictly better than a crashed app.
+ return new ShieldSignal[0];
+ }
+ if (reasons == null || reasons.length == 0) {
+ return new ShieldSignal[0];
+ }
+ Vector out = new Vector();
+ for (int i = 0; i < reasons.length; i++) {
+ ShieldSignal s = toSignal(reasons[i]);
+ if (s != null) {
+ out.addElement(s);
+ }
+ }
+ ShieldSignal[] arr = new ShieldSignal[out.size()];
+ out.copyInto(arr);
+ return arr;
+ }
+
+ private static ShieldSignal toSignal(String reason) {
+ if (reason == null) {
+ return null;
+ }
+ if ("root".equals(reason)) {
+ return new ShieldSignal(ShieldSignal.ROOT, 70, null);
+ }
+ if ("jailbreak".equals(reason)) {
+ return new ShieldSignal(ShieldSignal.JAILBREAK, 70, null);
+ }
+ if ("frida".equals(reason)) {
+ return new ShieldSignal(ShieldSignal.HOOK, 90, "frida");
+ }
+ if ("emulator".equals(reason)) {
+ // Low severity on purpose: every developer's device is an emulator.
+ // The service weighs it against the other signals.
+ return new ShieldSignal(ShieldSignal.EMULATOR, 30, null);
+ }
+ return new ShieldSignal(reason, 50, null);
+ }
+
+ public void invalidate() {
+ }
+
+ public void shutdown() {
+ }
+}
diff --git a/CodenameOne/src/com/codename1/security/shield/spi/package-info.java b/CodenameOne/src/com/codename1/security/shield/spi/package-info.java
new file mode 100644
index 00000000000..062b0127f6b
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/spi/package-info.java
@@ -0,0 +1,33 @@
+/*
+ * 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.
+ */
+
+/// Service-provider interface between the public shield API and the attestation engine.
+///
+/// Application code does not use this package. It exists so the engine that performs attestation,
+/// pin enforcement and tamper detection can be supplied separately from the framework, while
+/// [com.codename1.security.shield.AppShield] keeps a single stable surface that compiles and runs
+/// whether or not an engine is present.
+///
+/// See [com.codename1.security.shield.spi.ShieldEngine] for the contract, and in particular for
+/// the list of responsibilities an engine must not delegate back into open framework code.
+package com.codename1.security.shield.spi;
diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java
index e92ec26b328..26c6232b16f 100644
--- a/CodenameOne/src/com/codename1/ui/Display.java
+++ b/CodenameOne/src/com/codename1/ui/Display.java
@@ -6859,6 +6859,19 @@ public String[] getEnabledAccessibilityServices() {
return impl.getEnabledAccessibilityServices();
}
+ /// Discards cached platform attestation state, forcing the next attestation to start from a fresh
+ /// hardware key. See `com.codename1.security.DeviceIntegrity#resetAttestation()`.
+ public void resetAttestation() {
+ impl.resetAttestation();
+ }
+
+ /// Returns digests of the certificates the running app is signed with. Low level hook for the
+ /// attestation layer, which reports them to a verifying service; an on-device comparison proves
+ /// nothing on its own. Empty where the platform has no such concept.
+ public String[] getAppSignerDigests() {
+ return impl.getAppSignerDigests();
+ }
+
/// Marks the current screen secure (Android `FLAG_SECURE`), blocking screenshots/recording/scraping.
public void setSecureScreen(boolean secure) {
impl.setSecureScreen(secure);
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
index cf0e724c03b..469b8a115fb 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
@@ -221,7 +221,7 @@
import java.security.MessageDigest;
import java.text.ParseException;
import java.util.*;
-import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicLong;
import javax.net.ssl.HttpsURLConnection;
import javax.xml.parsers.ParserConfigurationException;
@@ -231,10 +231,10 @@
import org.xml.sax.SAXException;
//import android.webkit.JavascriptInterface;
-public class AndroidImplementation extends CodenameOneImplementation implements IntentResultListener {
- private AndroidCalendarSource calendarSource;
- private static final AtomicLong V3_NOTIFICATION_SEQUENCE = new AtomicLong();
-
+public class AndroidImplementation extends CodenameOneImplementation implements IntentResultListener {
+ private AndroidCalendarSource calendarSource;
+ private static final AtomicLong V3_NOTIFICATION_SEQUENCE = new AtomicLong();
+
public static final Thread.UncaughtExceptionHandler exceptionHandler = new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread t, Throwable e) {
@@ -821,94 +821,94 @@ private static byte[] readInputStream(InputStream i) throws IOException {
}
- public static void appendNotification(String type, String body, Context a) {
+ public static void appendNotification(String type, String body, Context a) {
appendNotification(type, body, null, null, a);
- }
-
- /** Receives the managed typed envelope from FCM without applying legacy push decoding. */
- public static void handleV3Push(final String envelope, Context context,
- boolean appRunning, Class appStubClass) {
- if (appRunning && Display.isInitialized()
- && com.codename1.push.PushClient.hasActiveClient()) {
- Display.getInstance().callSerially(new Runnable() {
- public void run() {
- com.codename1.push.PushClient.dispatch(envelope);
- }
- });
- return;
- }
- try {
- org.json.JSONObject message = new org.json.JSONObject(envelope);
- // The pending-push file explicitly encodes whether a legacy type is present.
- // A missing type is the sentinel for a typed V3 envelope and is replayed intact.
- appendNotification(null, envelope, context);
- if (message.optBoolean("silent", false)) {
- return;
- }
- String title = message.optString("title", "");
- String body = message.optString("body", "");
- String image = message.optString("image", "");
- if (title.length() == 0 && body.length() == 0 && image.length() == 0) {
- return;
- }
- if (title.length() == 0) {
- title = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString();
- }
- Intent intent = new Intent(context, appStubClass);
- PendingIntent contentIntent = createPendingIntent(context, 0, intent);
- int smallIcon = context.getResources().getIdentifier("ic_stat_notify", "drawable",
- context.getPackageName());
- if (smallIcon == 0) {
- smallIcon = context.getApplicationInfo().icon;
- }
- NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
- .setContentTitle(title)
- .setContentText(body)
- .setSmallIcon(smallIcon)
- .setContentIntent(contentIntent)
- .setAutoCancel(true)
- .setWhen(System.currentTimeMillis());
- NotificationManager manager = (NotificationManager)
- context.getSystemService(Context.NOTIFICATION_SERVICE);
- setNotificationChannel(manager, builder, context);
- String collapseKey = message.optString("collapseKey", null);
- String messageId = message.optString("id", null);
- String notificationTag;
- if (collapseKey != null && collapseKey.length() > 0) {
- notificationTag = v3NotificationTag("CN1_PUSH_V3_COLLAPSE:", collapseKey);
- } else if (messageId != null && messageId.length() > 0) {
- notificationTag = v3NotificationTag("CN1_PUSH_V3_MESSAGE:", messageId);
- } else {
- notificationTag = "CN1_PUSH_V3_EPHEMERAL:" + System.currentTimeMillis()
- + ":" + V3_NOTIFICATION_SEQUENCE.incrementAndGet();
- }
- manager.notify(notificationTag, 0, builder.build());
- } catch (Exception error) {
- Log.e("Codename One", "Failed to handle a Push V3 envelope", error);
- }
- }
-
- private static String v3NotificationTag(String prefix, String value) {
- if (prefix.length() + value.length() <= 128) {
- return prefix + value;
- }
- try {
- byte[] digest = MessageDigest.getInstance("SHA-256")
- .digest(value.getBytes(StandardCharsets.UTF_8));
- StringBuilder out = new StringBuilder(prefix.length() + digest.length * 2);
- out.append(prefix);
- for (byte item : digest) {
- int unsigned = item & 0xff;
- if (unsigned < 0x10) {
- out.append('0');
- }
- out.append(Integer.toHexString(unsigned));
- }
- return out.toString();
- } catch (Exception error) {
- return prefix + Integer.toHexString(value.hashCode());
- }
- }
+ }
+
+ /** Receives the managed typed envelope from FCM without applying legacy push decoding. */
+ public static void handleV3Push(final String envelope, Context context,
+ boolean appRunning, Class appStubClass) {
+ if (appRunning && Display.isInitialized()
+ && com.codename1.push.PushClient.hasActiveClient()) {
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ com.codename1.push.PushClient.dispatch(envelope);
+ }
+ });
+ return;
+ }
+ try {
+ org.json.JSONObject message = new org.json.JSONObject(envelope);
+ // The pending-push file explicitly encodes whether a legacy type is present.
+ // A missing type is the sentinel for a typed V3 envelope and is replayed intact.
+ appendNotification(null, envelope, context);
+ if (message.optBoolean("silent", false)) {
+ return;
+ }
+ String title = message.optString("title", "");
+ String body = message.optString("body", "");
+ String image = message.optString("image", "");
+ if (title.length() == 0 && body.length() == 0 && image.length() == 0) {
+ return;
+ }
+ if (title.length() == 0) {
+ title = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString();
+ }
+ Intent intent = new Intent(context, appStubClass);
+ PendingIntent contentIntent = createPendingIntent(context, 0, intent);
+ int smallIcon = context.getResources().getIdentifier("ic_stat_notify", "drawable",
+ context.getPackageName());
+ if (smallIcon == 0) {
+ smallIcon = context.getApplicationInfo().icon;
+ }
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
+ .setContentTitle(title)
+ .setContentText(body)
+ .setSmallIcon(smallIcon)
+ .setContentIntent(contentIntent)
+ .setAutoCancel(true)
+ .setWhen(System.currentTimeMillis());
+ NotificationManager manager = (NotificationManager)
+ context.getSystemService(Context.NOTIFICATION_SERVICE);
+ setNotificationChannel(manager, builder, context);
+ String collapseKey = message.optString("collapseKey", null);
+ String messageId = message.optString("id", null);
+ String notificationTag;
+ if (collapseKey != null && collapseKey.length() > 0) {
+ notificationTag = v3NotificationTag("CN1_PUSH_V3_COLLAPSE:", collapseKey);
+ } else if (messageId != null && messageId.length() > 0) {
+ notificationTag = v3NotificationTag("CN1_PUSH_V3_MESSAGE:", messageId);
+ } else {
+ notificationTag = "CN1_PUSH_V3_EPHEMERAL:" + System.currentTimeMillis()
+ + ":" + V3_NOTIFICATION_SEQUENCE.incrementAndGet();
+ }
+ manager.notify(notificationTag, 0, builder.build());
+ } catch (Exception error) {
+ Log.e("Codename One", "Failed to handle a Push V3 envelope", error);
+ }
+ }
+
+ private static String v3NotificationTag(String prefix, String value) {
+ if (prefix.length() + value.length() <= 128) {
+ return prefix + value;
+ }
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256")
+ .digest(value.getBytes(StandardCharsets.UTF_8));
+ StringBuilder out = new StringBuilder(prefix.length() + digest.length * 2);
+ out.append(prefix);
+ for (byte item : digest) {
+ int unsigned = item & 0xff;
+ if (unsigned < 0x10) {
+ out.append('0');
+ }
+ out.append(Integer.toHexString(unsigned));
+ }
+ return out.toString();
+ } catch (Exception error) {
+ return prefix + Integer.toHexString(value.hashCode());
+ }
+ }
public static void appendNotification(String type, String body, String image, String category, Context a) {
try {
@@ -7175,6 +7175,46 @@ public boolean canGetSSLCertificates() {
return true;
}
+ @Override
+ public boolean canGetPublicKeyDigests() {
+ return true;
+ }
+
+ @Override
+ public String[] getSSLCertificatesEx(Object connection, String url) throws IOException {
+ if (connection instanceof HttpsURLConnection) {
+ HttpsURLConnection conn = (HttpsURLConnection) connection;
+ try {
+ conn.connect();
+ java.security.cert.Certificate[] certs = conn.getServerCertificates();
+ java.util.List out = new java.util.ArrayList();
+ for (int i = 0; i < certs.length; i++) {
+ java.security.cert.Certificate cert = certs[i];
+ out.add("CHAIN:" + i);
+ MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
+ sha256.update(cert.getEncoded());
+ out.add("SHA-256:" + dumpHex(sha256.digest()));
+ MessageDigest sha1 = MessageDigest.getInstance("SHA1");
+ sha1.update(cert.getEncoded());
+ out.add("SHA1:" + dumpHex(sha1.digest()));
+ // getPublicKey().getEncoded() is already the DER SubjectPublicKeyInfo,
+ // which is exactly what a public-key pin is computed over.
+ java.security.PublicKey pk = cert.getPublicKey();
+ if (pk != null && pk.getEncoded() != null) {
+ MessageDigest spki = MessageDigest.getInstance("SHA-256");
+ spki.update(pk.getEncoded());
+ out.add("SPKI-SHA-256:"
+ + com.codename1.util.Base64.encodeNoNewline(spki.digest()));
+ }
+ }
+ return out.toArray(new String[out.size()]);
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ }
+ }
+ return new String[0];
+ }
+
/**
* @inheritDoc
*/
@@ -8411,20 +8451,20 @@ public boolean isContactsPermissionGranted() {
@Override
- public String[] getAllContacts(boolean withNumbers) {
+ public String[] getAllContacts(boolean withNumbers) {
if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){
return new String[]{};
}
return AndroidContactsManager.getInstance().getContacts(getContext(), withNumbers);
- }
-
- @Override
- public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() {
- if (calendarSource == null) {
- calendarSource = new AndroidCalendarSource(getContext());
- }
- return calendarSource;
- }
+ }
+
+ @Override
+ public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() {
+ if (calendarSource == null) {
+ calendarSource = new AndroidCalendarSource(getContext());
+ }
+ return calendarSource;
+ }
@Override
public Contact getContactById(String id) {
@@ -9026,10 +9066,10 @@ private ClipData enrichClipWithBinaryContent(ClipboardContent content, ClipData
imageExt = "gif";
}
if (imageBytes != null) {
- // AndroidGradleBuilder exposes cache/intent_files through the app's
- // FileProvider. Keep generated clipboard payloads inside that root so
- // FileProvider can safely create a content:// URI for paste targets.
- File imageFile = new File(new File(getContext().getCacheDir(), "intent_files"),
+ // AndroidGradleBuilder exposes cache/intent_files through the app's
+ // FileProvider. Keep generated clipboard payloads inside that root so
+ // FileProvider can safely create a content:// URI for paste targets.
+ File imageFile = new File(new File(getContext().getCacheDir(), "intent_files"),
"cn1-clip-image-" + System.currentTimeMillis() + "." + imageExt);
imageFile.getParentFile().mkdirs();
OutputStream os = new FileOutputStream(imageFile);
@@ -9063,14 +9103,14 @@ private ClipData enrichClipWithBinaryContent(ClipboardContent content, ClipData
continue;
}
Uri u;
- if (pathOrUri.startsWith("content:")) {
- u = Uri.parse(pathOrUri);
- } else {
- File file = pathOrUri.startsWith("file:")
- ? new File(Uri.parse(pathOrUri).getPath())
- : new File(pathOrUri);
- u = FileProvider.getUriForFile(getContext(), authority, file);
- getContext().grantUriPermission("android", u, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ if (pathOrUri.startsWith("content:")) {
+ u = Uri.parse(pathOrUri);
+ } else {
+ File file = pathOrUri.startsWith("file:")
+ ? new File(Uri.parse(pathOrUri).getPath())
+ : new File(pathOrUri);
+ u = FileProvider.getUriForFile(getContext(), authority, file);
+ getContext().grantUriPermission("android", u, Intent.FLAG_GRANT_READ_URI_PERMISSION);
}
if (clip == null) {
clip = new ClipData("Codename One", new String[]{ "text/uri-list" }, new ClipData.Item(u));
@@ -10593,7 +10633,7 @@ public static boolean hasAndroidMarket(Context activity) {
}
@Override
- public void registerPush(Hashtable metaData, boolean noFallback) {
+ public void registerPush(Hashtable metaData, boolean noFallback) {
if (getActivity() == null) {
return;
}
@@ -10604,18 +10644,18 @@ public void registerPush(Hashtable metaData, boolean noFallback) {
}
}
- boolean huawei = "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", ""));
- if (!hasAndroidMarket() && !huawei) {
- Log.d("Codename One", "Device doesn't have Android market/google play can't register for push!");
- return;
- }
- String id = "";
- if (!huawei) {
- id = (String)metaData.get(com.codename1.push.Push.GOOGLE_PUSH_KEY);
- if (id == null) {
- id = Display.getInstance().getProperty("gcm.sender_id", null);
- }
- }
+ boolean huawei = "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", ""));
+ if (!hasAndroidMarket() && !huawei) {
+ Log.d("Codename One", "Device doesn't have Android market/google play can't register for push!");
+ return;
+ }
+ String id = "";
+ if (!huawei) {
+ id = (String)metaData.get(com.codename1.push.Push.GOOGLE_PUSH_KEY);
+ if (id == null) {
+ id = Display.getInstance().getProperty("gcm.sender_id", null);
+ }
+ }
Log.d("Codename One", "Sending async push request for id: " + id);
((CodenameOneActivity) getActivity()).registerForPush(id);
}
@@ -10629,9 +10669,9 @@ public static void registerPolling() {
}
@Override
- public void deregisterPush() {
- boolean has = hasAndroidMarket()
- || "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", ""));
+ public void deregisterPush() {
+ boolean has = hasAndroidMarket()
+ || "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", ""));
if (has) {
((CodenameOneActivity) getActivity()).stopReceivingPush();
deregisterPushFromServer();
@@ -13563,6 +13603,87 @@ public boolean isDeviceCompromised() {
return getCompromiseReasons().length > 0;
}
+ /**
+ * Base64 SHA-256 digests of the certificates this APK is actually signed with.
+ *
+ * Uses the v2/v3 signing-block API on API 28 and up, which reports the full
+ * signing lineage after a key rotation; below that only the legacy v1 signature
+ * is available. Note that under Play App Signing the digest seen here is
+ * Google's app signing key, not the developer's upload key -- comparing
+ * against the upload key is the classic way to make every production install
+ * report itself as repackaged.
+ */
+ @Override
+ public String[] getAppSignerDigests() {
+ try {
+ Context ctx = getContext();
+ if (ctx == null) {
+ return new String[0];
+ }
+ PackageManager pm = ctx.getPackageManager();
+ String pkg = ctx.getPackageName();
+ Signature[] signatures = null;
+ if (android.os.Build.VERSION.SDK_INT >= 28) {
+ // Reflection because the port compiles against an older android.jar
+ // than the devices it runs on, the same reason the Play Integrity
+ // call in this file is reflective.
+ signatures = signingCertificatesViaReflection(pm, pkg);
+ }
+ if (signatures == null) {
+ PackageInfo info = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES);
+ signatures = info.signatures;
+ }
+ if (signatures == null) {
+ return new String[0];
+ }
+ java.util.ArrayList out = new java.util.ArrayList();
+ for (int i = 0; i < signatures.length; i++) {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ md.update(signatures[i].toByteArray());
+ out.add(com.codename1.util.Base64.encodeNoNewline(md.digest()));
+ }
+ return out.toArray(new String[out.size()]);
+ } catch (Throwable t) {
+ // Reporting nothing is better than failing a request over a
+ // package-manager quirk on some OEM build.
+ com.codename1.io.Log.e(t);
+ return new String[0];
+ }
+ }
+
+ /**
+ * PackageManager.GET_SIGNING_CERTIFICATES. Inlined because the port compiles
+ * against an android.jar that predates it.
+ */
+ private static final int FLAG_GET_SIGNING_CERTIFICATES = 0x08000000;
+
+ /**
+ * Reads the v2/v3 signing certificates on API 28+, or null when unavailable so
+ * the caller falls back to the legacy v1 signatures.
+ */
+ private static Signature[] signingCertificatesViaReflection(PackageManager pm, String pkg) {
+ try {
+ PackageInfo info = pm.getPackageInfo(pkg, FLAG_GET_SIGNING_CERTIFICATES);
+ java.lang.reflect.Field signingInfoField =
+ PackageInfo.class.getField("signingInfo");
+ Object signingInfo = signingInfoField.get(info);
+ if (signingInfo == null) {
+ return null;
+ }
+ Class> signingInfoClass = signingInfo.getClass();
+ boolean multipleSigners = ((Boolean) signingInfoClass
+ .getMethod("hasMultipleSigners").invoke(signingInfo)).booleanValue();
+ // With one signer the history includes the pre-rotation certificates,
+ // which a server comparing against an older build still needs to accept.
+ String method = multipleSigners
+ ? "getApkContentsSigners"
+ : "getSigningCertificateHistory";
+ return (Signature[]) signingInfoClass.getMethod(method).invoke(signingInfo);
+ } catch (Throwable t) {
+ return null;
+ }
+ }
+
@Override
public String[] getCompromiseReasons() {
java.util.ArrayList reasons = new java.util.ArrayList();
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
index c1329f53e74..556273f1664 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
@@ -54,6 +54,7 @@
import javax.crypto.KeyGenerator;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.IvParameterSpec;
/**
@@ -76,6 +77,13 @@
* key and recreate it on first failure to recover.
* See Google issue 65578763.
*
+ *
+ * The non-prompting tier ({@code set(account, value)} and friends) uses a
+ * separate keystore key ({@code CN1PlainKey}) and preferences file,
+ * created without {@code setUserAuthenticationRequired}, with AES/GCM. Keeping
+ * it separate matters: the biometric key is invalidated whenever the user
+ * re-enrols biometrics, and secrets read on every network call must survive
+ * that.
*/
public final class AndroidSecureStorage extends SecureStorage {
@@ -83,6 +91,16 @@ public final class AndroidSecureStorage extends SecureStorage {
private static final String PREFS = "CN1BiometricSecureStorage";
private static final String ANDROID_KEY_STORE = "AndroidKeyStore";
+ /**
+ * Deliberately distinct from {@link #KEY_ID}: the biometric key is created
+ * with {@code setUserAuthenticationRequired(true)} and is invalidated when
+ * the user re-enrols biometrics. The non-prompting tier must survive that,
+ * so it gets its own key and its own preferences file.
+ */
+ private static final String PLAIN_KEY_ID = "CN1PlainKey";
+ private static final String PLAIN_PREFS = "CN1PlainSecureStorage";
+ private static final int GCM_TAG_BITS = 128;
+
private KeyStore keyStore;
private KeyGenerator keyGenerator;
private Cipher cipher;
@@ -177,6 +195,178 @@ public AsyncResource remove(String reason, String account) {
return result;
}
+ // --- Non-prompting tier ------------------------------------------------
+ //
+ // AES/GCM under a dedicated AndroidKeyStore key created *without*
+ // setUserAuthenticationRequired, so reads never raise a biometric prompt.
+ // Deliberately not androidx.security EncryptedSharedPreferences: that
+ // would force a transitive dependency on every Android build and it is
+ // itself deprecated. The value is stored as
+ // base64(iv) + ":" + base64(ciphertext) in a private preferences file.
+
+ @Override
+ public boolean set(String account, String value) {
+ if (account == null || value == null) {
+ return false;
+ }
+ if (Build.VERSION.SDK_INT < 23) {
+ return legacyPlainSet(account, value);
+ }
+ try {
+ SecretKey key = plainKey(true);
+ if (key == null) {
+ return false;
+ }
+ Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
+ c.init(Cipher.ENCRYPT_MODE, key);
+ byte[] enc = c.doFinal(value.getBytes("UTF-8"));
+ plainPrefs().edit()
+ .putString(account, Base64.encodeToString(c.getIV(), Base64.NO_WRAP)
+ + ":" + Base64.encodeToString(enc, Base64.NO_WRAP))
+ .apply();
+ return true;
+ } catch (InvalidKeyException e) {
+ // Includes KeyPermanentlyInvalidatedException.
+ resetPlainKey();
+ return false;
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ @Override
+ public String get(String account) {
+ if (account == null) {
+ return null;
+ }
+ if (Build.VERSION.SDK_INT < 23) {
+ return legacyPlainGet(account);
+ }
+ String stored = plainPrefs().getString(account, null);
+ if (stored == null) {
+ return null;
+ }
+ int sep = stored.indexOf(':');
+ if (sep < 0) {
+ return null;
+ }
+ try {
+ SecretKey key = plainKey(false);
+ if (key == null) {
+ return null;
+ }
+ byte[] iv = Base64.decode(stored.substring(0, sep), Base64.NO_WRAP);
+ byte[] enc = Base64.decode(stored.substring(sep + 1), Base64.NO_WRAP);
+ Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
+ c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
+ return new String(c.doFinal(enc), "UTF-8");
+ } catch (InvalidKeyException e) {
+ // The key was invalidated out from under us (device-wide credential
+ // change, or the Samsung 8.0.0 quirk documented on the biometric
+ // tier). Everything encrypted under it is unrecoverable, so drop
+ // the key and the ciphertexts rather than failing forever.
+ resetPlainKey();
+ return null;
+ } catch (UnrecoverableKeyException e) {
+ resetPlainKey();
+ return null;
+ } catch (Throwable t) {
+ Log.e(t);
+ return null;
+ }
+ }
+
+ @Override
+ public boolean remove(String account) {
+ if (account == null) {
+ return false;
+ }
+ plainPrefs().edit().remove(account).apply();
+ return true;
+ }
+
+ private SharedPreferences plainPrefs() {
+ return AndroidNativeUtil.getActivity()
+ .getApplicationContext()
+ .getSharedPreferences(PLAIN_PREFS, Context.MODE_PRIVATE);
+ }
+
+ /**
+ * Loads the non-prompting keystore key, optionally creating it. Returns
+ * null when the key is absent and {@code create} is false, or when
+ * generation fails.
+ */
+ private SecretKey plainKey(boolean create) throws Exception {
+ keyStore().load(null);
+ SecretKey existing = (SecretKey) keyStore.getKey(PLAIN_KEY_ID, null);
+ if (existing != null || !create) {
+ return existing;
+ }
+ KeyGenerator gen = KeyGenerator.getInstance(
+ KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE);
+ gen.init(new KeyGenParameterSpec.Builder(PLAIN_KEY_ID,
+ KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(256)
+ .setRandomizedEncryptionRequired(true)
+ .build());
+ gen.generateKey();
+ return (SecretKey) keyStore.getKey(PLAIN_KEY_ID, null);
+ }
+
+ private void resetPlainKey() {
+ try {
+ keyStore().deleteEntry(PLAIN_KEY_ID);
+ } catch (KeyStoreException e) {
+ Log.e(e);
+ }
+ plainPrefs().edit().clear().apply();
+ }
+
+ // API 22 and below have no KeyGenParameterSpec. The preferences file is
+ // still app-private, but the value is only obfuscated, not encrypted --
+ // it is extractable from a rooted device or a backup.
+ private boolean legacyPlainSet(String account, String value) {
+ warnLegacyPlainStorage();
+ try {
+ plainPrefs().edit()
+ .putString(account, Base64.encodeToString(
+ value.getBytes("UTF-8"), Base64.NO_WRAP))
+ .apply();
+ return true;
+ } catch (IOException e) {
+ Log.e(e);
+ return false;
+ }
+ }
+
+ private String legacyPlainGet(String account) {
+ warnLegacyPlainStorage();
+ String stored = plainPrefs().getString(account, null);
+ if (stored == null) {
+ return null;
+ }
+ try {
+ return new String(Base64.decode(stored, Base64.NO_WRAP), "UTF-8");
+ } catch (IOException e) {
+ Log.e(e);
+ return null;
+ }
+ }
+
+ private boolean legacyPlainWarned;
+
+ private void warnLegacyPlainStorage() {
+ if (!legacyPlainWarned) {
+ legacyPlainWarned = true;
+ Log.p("SecureStorage: this device predates Android API 23, so the "
+ + "non-prompting tier stores values obfuscated rather than "
+ + "encrypted. Do not use it for high-value secrets here.");
+ }
+ }
+
/**
* Generic helper that initialises the cipher under the keystore key,
* prompts the user via {@code BiometricPrompt} (or legacy
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidWebSocketImpl.java b/Ports/Android/src/com/codename1/impl/android/AndroidWebSocketImpl.java
index b844a6d7715..55ff0b54b60 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidWebSocketImpl.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidWebSocketImpl.java
@@ -162,6 +162,7 @@ private void doHandshake(int connectTimeoutMs) throws IOException {
}
req.append("\r\n");
}
+ appendRequestHeaders(req);
req.append("\r\n");
out.write(req.toString().getBytes(ASCII));
out.flush();
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index 357cf751e1d..33fd9ac3787 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -6539,6 +6539,8 @@ public void actionPerformed(ActionEvent ae) {
simulateMenu.add(biometricMenu);
+ final JMenu shieldMenu = installShieldSimulationMenu(simulateMenu, pref);
+
final JMenu nfcMenu = installNfcSimulationMenu(simulateMenu, pref);
final JMenu foldableMenu = installFoldableSimulationMenu(simulateMenu, pref);
@@ -7056,6 +7058,7 @@ public void actionPerformed(ActionEvent e) {
simulateMenu.add(motionSim);
simulateMenu.add(pushSim);
simulateMenu.add(biometricMenu);
+ simulateMenu.add(shieldMenu);
simulateMenu.add(nfcMenu);
simulateMenu.add(foldableMenu);
simulateMenu.add(statusBarTapDiag);
@@ -8316,6 +8319,163 @@ public void run() {
});
}
+ /**
+ * Builds the {@code Simulate > App Shield} menu.
+ *
+ * Every toggle here exists to make an otherwise untestable branch
+ * reachable on a developer's desktop. "Force Pin Mismatch On Next Request"
+ * is the most valuable of them: a fail-closed pinning branch is otherwise
+ * only exercisable by deliberately mis-pinning a live host.
+ */
+ private JMenu installShieldSimulationMenu(JMenu simulateMenu, final Preferences pref) {
+ JMenu shieldMenu = new JMenu("App Shield");
+ shieldMenu.setToolTipText("Simulate attestation outcomes, compromised-device signals "
+ + "and certificate pin failures.");
+
+ final JCheckBoxMenuItem supported = new JCheckBoxMenuItem("Attestation Supported",
+ pref.getBoolean("ShieldSim.supported", true));
+ JavaSEShield.attestationSupported = supported.isSelected();
+ supported.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent ae) {
+ JavaSEShield.attestationSupported = supported.isSelected();
+ pref.putBoolean("ShieldSim.supported", supported.isSelected());
+ }
+ });
+ shieldMenu.add(supported);
+
+ JMenu outcomeMenu = new JMenu("Attestation Result");
+ ButtonGroup outcomeGroup = new ButtonGroup();
+ String storedOutcome = pref.get("ShieldSim.outcome",
+ JavaSEShield.AttestOutcome.PASS.name());
+ for (final JavaSEShield.AttestOutcome outcome : JavaSEShield.AttestOutcome.values()) {
+ JRadioButtonMenuItem item = new JRadioButtonMenuItem(outcome.name());
+ if (outcome.name().equals(storedOutcome)) {
+ item.setSelected(true);
+ JavaSEShield.attestOutcome = outcome;
+ }
+ outcomeGroup.add(item);
+ item.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent ae) {
+ JavaSEShield.attestOutcome = outcome;
+ pref.put("ShieldSim.outcome", outcome.name());
+ }
+ });
+ outcomeMenu.add(item);
+ }
+ shieldMenu.add(outcomeMenu);
+
+ shieldMenu.addSeparator();
+
+ // The signals a compromised device would report. Independent toggles
+ // because an app's response to a rooted device and to a hooking
+ // framework are usually different decisions.
+ shieldMenu.add(shieldToggle(pref, "Rooted / Jailbroken", "ShieldSim.rooted",
+ new ShieldToggleSink() {
+ @Override
+ public void set(boolean v) {
+ JavaSEShield.simRooted = v;
+ }
+ }));
+ shieldMenu.add(shieldToggle(pref, "Hooking Framework (Frida)", "ShieldSim.hooked",
+ new ShieldToggleSink() {
+ @Override
+ public void set(boolean v) {
+ JavaSEShield.simHooked = v;
+ }
+ }));
+ shieldMenu.add(shieldToggle(pref, "Emulator", "ShieldSim.emulator",
+ new ShieldToggleSink() {
+ @Override
+ public void set(boolean v) {
+ JavaSEShield.simEmulator = v;
+ }
+ }));
+ shieldMenu.add(shieldToggle(pref, "Debugger Attached", "ShieldSim.debugger",
+ new ShieldToggleSink() {
+ @Override
+ public void set(boolean v) {
+ JavaSEShield.simDebugger = v;
+ }
+ }));
+ shieldMenu.add(shieldToggle(pref, "Repackaged", "ShieldSim.repackaged",
+ new ShieldToggleSink() {
+ @Override
+ public void set(boolean v) {
+ JavaSEShield.simRepackaged = v;
+ }
+ }));
+ shieldMenu.add(shieldToggle(pref, "Untrusted Accessibility Service",
+ "ShieldSim.accessibility", new ShieldToggleSink() {
+ @Override
+ public void set(boolean v) {
+ JavaSEShield.simUntrustedAccessibility = v;
+ }
+ }));
+
+ shieldMenu.addSeparator();
+
+ shieldMenu.add(shieldToggle(pref, "Serve Expired Token", "ShieldSim.expiredToken",
+ new ShieldToggleSink() {
+ @Override
+ public void set(boolean v) {
+ JavaSEShield.serveExpiredToken = v;
+ }
+ }));
+
+ // The one branch that is otherwise effectively untestable.
+ shieldMenu.add(shieldToggle(pref, "Force Pin Mismatch On Next Request",
+ "ShieldSim.pinMismatch", new ShieldToggleSink() {
+ @Override
+ public void set(boolean v) {
+ JavaSEShield.forcePinMismatch = v;
+ }
+ }));
+ shieldMenu.add(shieldToggle(pref, "Fail Pin Fetch", "ShieldSim.pinFetchFail",
+ new ShieldToggleSink() {
+ @Override
+ public void set(boolean v) {
+ JavaSEShield.failPinFetch = v;
+ }
+ }));
+
+ shieldMenu.addSeparator();
+
+ JMenuItem status = new JMenuItem("Show Shield Status...");
+ status.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent ae) {
+ JOptionPane.showMessageDialog(canvas, JavaSEShield.describe(),
+ "App Shield Simulation", JOptionPane.INFORMATION_MESSAGE);
+ }
+ });
+ shieldMenu.add(status);
+
+ simulateMenu.add(shieldMenu);
+ return shieldMenu;
+ }
+
+ /** Lambda stand-in so the toggle wiring is written once rather than nine times. */
+ private interface ShieldToggleSink {
+ void set(boolean value);
+ }
+
+ private JCheckBoxMenuItem shieldToggle(final Preferences pref, String label,
+ final String prefKey, final ShieldToggleSink sink) {
+ final JCheckBoxMenuItem item = new JCheckBoxMenuItem(label,
+ pref.getBoolean(prefKey, false));
+ sink.set(item.isSelected());
+ item.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent ae) {
+ sink.set(item.isSelected());
+ pref.putBoolean(prefKey, item.isSelected());
+ }
+ });
+ return item;
+ }
+
private JMenu installNfcSimulationMenu(JMenu simulateMenu, final Preferences pref) {
JMenu nfcMenu = new JMenu("NFC");
@@ -14254,9 +14414,47 @@ public String[] getSSLCertificates(Object connection, String url) throws IOExcep
public boolean canGetSSLCertificates() {
return true;
}
-
-
-
+
+ @Override
+ public boolean canGetPublicKeyDigests() {
+ return true;
+ }
+
+ @Override
+ public String[] getSSLCertificatesEx(Object connection, String url) throws IOException {
+ if (connection instanceof HttpsURLConnection) {
+ HttpsURLConnection conn = (HttpsURLConnection) connection;
+ try {
+ conn.connect();
+ java.security.cert.Certificate[] certs = conn.getServerCertificates();
+ java.util.List out = new java.util.ArrayList();
+ for (int i = 0; i < certs.length; i++) {
+ java.security.cert.Certificate cert = certs[i];
+ out.add("CHAIN:" + i);
+ MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
+ sha256.update(cert.getEncoded());
+ out.add("SHA-256:" + dumpHex(sha256.digest()));
+ MessageDigest sha1 = MessageDigest.getInstance("SHA1");
+ sha1.update(cert.getEncoded());
+ out.add("SHA1:" + dumpHex(sha1.digest()));
+ // getPublicKey().getEncoded() is already the DER SubjectPublicKeyInfo,
+ // which is exactly what a public-key pin is computed over.
+ java.security.PublicKey pk = cert.getPublicKey();
+ if (pk != null && pk.getEncoded() != null) {
+ MessageDigest spki = MessageDigest.getInstance("SHA-256");
+ spki.update(pk.getEncoded());
+ out.add("SPKI-SHA-256:"
+ + com.codename1.util.Base64.encodeNoNewline(spki.digest()));
+ }
+ }
+ return out.toArray(new String[out.size()]);
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ }
+ }
+ return new String[0];
+ }
+
/**
* @inheritDoc
*/
@@ -18797,6 +18995,81 @@ public boolean isJailbrokenDevice() {
return super.isJailbrokenDevice();
}
+ // --- DeviceIntegrity / App Shield simulation --------------------------
+ //
+ // Without these the simulator reports "no attestation, clean device" for
+ // everything, so the branches an app takes when a device looks compromised
+ // are unreachable until it is on real hardware. Driven by the
+ // Simulate > App Shield menu; see JavaSEShield.
+
+ @Override
+ public boolean isAttestationSupported() {
+ return JavaSEShield.attestationSupported;
+ }
+
+ @Override
+ public com.codename1.util.AsyncResource requestIntegrityToken(String nonce) {
+ final com.codename1.util.AsyncResource result =
+ new com.codename1.util.AsyncResource();
+ if (!JavaSEShield.attestationSupported
+ || JavaSEShield.attestOutcome == JavaSEShield.AttestOutcome.UNSUPPORTED) {
+ result.error(new UnsupportedOperationException(
+ "Simulated: attestation is not supported on this device"));
+ return result;
+ }
+ switch (JavaSEShield.attestOutcome) {
+ case PASS:
+ // Stamped as simulated so it cannot be mistaken for, or accepted
+ // as, a real attestation by any backend.
+ result.complete("cn1sim:attest:" + (nonce == null ? "" : nonce));
+ break;
+ case FAIL_REJECTED:
+ result.error(new RuntimeException(
+ "Simulated: the attestation service rejected this device"));
+ break;
+ case FAIL_NO_NETWORK:
+ result.error(new java.io.IOException("Simulated: no network"));
+ break;
+ case FAIL_SERVICE_DOWN:
+ result.error(new java.io.IOException(
+ "Simulated: the attestation service is unavailable"));
+ break;
+ case FAIL_RATE_LIMITED:
+ result.error(new RuntimeException("Simulated: rate limited, back off"));
+ break;
+ default:
+ result.error(new RuntimeException("Simulated attestation failure"));
+ }
+ return result;
+ }
+
+ @Override
+ public void resetAttestation() {
+ // Nothing is cached in the simulator; the menu is the state.
+ }
+
+ @Override
+ public boolean isDeviceCompromised() {
+ return JavaSEShield.simReasons().length > 0;
+ }
+
+ @Override
+ public String[] getCompromiseReasons() {
+ return JavaSEShield.simReasons();
+ }
+
+ @Override
+ public String[] getEnabledAccessibilityServices() {
+ return JavaSEShield.simAccessibility();
+ }
+
+ @Override
+ public void setSecureScreen(boolean secure) {
+ // No OS-level equivalent on the desktop. Recorded so the menu can show
+ // whether the app asked for it, which is what a developer is checking.
+ JavaSEShield.secureScreen = secure;
+ }
+
@Override
public Boolean canExecute(String url) {
// If this is a registered simulator hook URL, report it as
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSESecureStorage.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSESecureStorage.java
index 4c175eca4a6c42be7ad3ba926bb52277ff307dd1..608602e72d7751c6581b80cb57cfebd4a4c34eea 100644
GIT binary patch
literal 8027
zcmcIp?^7E&61~s-6}|ql!^Vt(+f8jHBozZDctdQLZIYv|wy5z~#SaCo)~D=w@!n3wP;o|KPBRXH$^`3qk!vP>@Vp
zQc+|y1+!TcM}ZOAr*<5Zou^3&E!0X(eYSVnr(SI&{(>Tn)SL
z&&T|>d(s*4yYYEfx($TFD!k@IwYCI>m~)7?W8cCBfj&2f03)3QJD^Mm1
zmvNv7NCI0;dO7(+gocc?#v3PbpmiFUd4t54P^4^`$E8ZHqN$j&OK_yGf)ECx#Ll_^
zqPPtnhFy{
zu_iA74k;L-vDGkM0?&NU4XDoS)f=#y6O+vp(zZ}pR5WkHu*dtj^si>Si+7{
zQayiko~h5C@l8)gybm!lQUQiX%tq3`kT;LKT$(8M+j^aZL!sqTg^yXO4P3^iM3#~E
z3qYMmL(~JgDAu$?tc7C)<>k-y$zS|Xty3fYlPH~w5>K90yn|gO%m=Zq8GZQr3`~^j
zkx@Doq5uBm;~-GrAryav2ipzWp@ZWNR{X}G!ZSB+r}dMS&r!61gu@q_fTa4GAOAMGZ@yARzZvmL%C5aXcQ#sw@)9ravPMSJnN6r
zw`0g_B9lGm3h&KCD5DU}Be8fZC$pt?3Dsm-BAsR1Q44Q<>O%Pw8*=LzR|BkcE7nk8
zWu
zoysBz0j{m6{4BJ5GvbS(y*;!CYt%sOMJ!NkS^%S8*>98i#IEWIS&0Es+
zhnz415Z_xNQfd#nyeG62sC071j-eIP$aBc1`~d0NV*L(42v(I
zcD%-ez_u>Dg9*}UFXU#xMV)1sgA!>(k@?1h&|=&gnfi1pR%l`XVqjQawPhv`VdpGB
z?6i%C5>VlJpr+hX?ac+M7jMQtYNc;68q({!qFKap?L7Dm`8w*oS)2}-_
zginfSGvz@qC@}gGwl#vV0)}4yv_o&GQJ3}t949!r@-<$6)862s-R)g}msQ^e_!+iG
z=wq)xbgtOn>+3K%xMo5=W8$aw#bxJuFzlRl|G|@2*H29X*$CowJ#N3hKJJc3m~n9U
z>l0h&34`Mgr)P)P!~SLO)Xmt(2LfCTfJ1&m5c~mTK`5juxMU@wzSJ`&ae}ZF)Uk)l
zIl7d}Mw7g(ZVU%^ob)quqXdeaxxa;Q-V;`L>8o{vUE!?u%LM^t`NcU~jIvwDfG6Pb
zSs#W{8gJKUUGkV`yPI|jG``bVN!`rky^G$vK9oqI-FM)+gsj!_BbQ8X=|&i@mR`tj
z&AnaiAvdy6*(y%%{dS|M3upwenMB;t)*)U6gW5`HEO9!+R12=p?q!RkVl3mhG7n9n
zmk-BM{IU{9j*|1k2NqI!>`TF^PTp~OActkkBLLA$Svg&u82`1whh0}PUVM^7P2K%~-%0q0ilg((pJ60>h`HJGs#fumwl5rTqv>-3*V^i$N^jQ~tl+~{
ztTvV|cwT5x`ot>tf*P;5&~F)qNt-**65pt0H9okDHaMtzMKeNTGqSj!@r5TgfXvKo
zjrUX4CgUJCRdqU6m)%0Z>T5E4a#hn%e1E
zAL8SNU)z(fx8LObY;voXRZmUlO}?Om&G~<=6r6UpO98rf^xvB|Y+m)$+N5Z;>0Hh<
z%yq0n4(7VSdDf&{E-FO)U-N1kp`J>sT;}VAIj@lw<$Zo?ZB6YBLCcogo=z2lKMsZ9hEl1f(02(waDU`FlB^f#x^vZpvL*^8p@rR>!tR
z@$3r18-d|Uli&Q{#}eGcA*ri1YO}6ba}|4Ak1LtSrMW^)3c+w&t{N%;M*E~td-2N&
zZc=fX_J+LCS{t!o#|8?&*eSF28hl;h^G`7iWESqL8oPxB-Xwoe`ToY0@6qXV{073)
zQNn#APq1(sKi;jYs#mZqyOgIH9NK(ni*3=8xa+FVxDr0cW$oy^efZ1EYFCmGA-^bo
zLo>|h-)W6-hPR)R%Zq$3#|A13Ae;3IvL<0YY^Vt1w7Q?1+8LvYo$$rKm$?K)&{kDl
M-kKNpJNG;P0eC;WFaQ7m
delta 47
zcmV+~0MP&2KA|75!UdDx1w4~72i%hu3Z9c13?q{W3>cGI3>}k~462hx6DqU54f_TH
FeG0825Uc App Shield} menu.
+ *
+ * Exists so a developer can exercise the branches an app takes when a device
+ * looks compromised or attestation fails. Those are exactly the paths that are
+ * otherwise impossible to test: rooting a phone, attaching a hooking framework
+ * or deliberately mis-pinning a real host are all things nobody does casually,
+ * so without simulation the error handling ships untested and is discovered by
+ * users.
+ *
+ * Mirrors {@link JavaSEBiometrics}: plain static fields driven by menu
+ * checkboxes and persisted to preferences, deliberately with no logic of its
+ * own.
+ */
+public final class JavaSEShield {
+
+ private JavaSEShield() {
+ }
+
+ // --- attestation ------------------------------------------------------
+
+ /** What the next attestation request should do. */
+ public enum AttestOutcome {
+ /** Return a simulated token. */
+ PASS,
+ /** The service evaluated the device and said no. Not retryable. */
+ FAIL_REJECTED,
+ /** No connectivity. Retryable. */
+ FAIL_NO_NETWORK,
+ /** The service returned an error. Retryable. */
+ FAIL_SERVICE_DOWN,
+ /** The device is being throttled. Retryable after backoff. */
+ FAIL_RATE_LIMITED,
+ /** The platform has no attestation at all. */
+ UNSUPPORTED
+ }
+
+ public static AttestOutcome attestOutcome = AttestOutcome.PASS;
+
+ /** Whether the simulated platform reports attestation support. */
+ public static boolean attestationSupported = true;
+
+ // --- device signals ---------------------------------------------------
+
+ public static boolean simRooted;
+ public static boolean simHooked;
+ public static boolean simEmulator;
+ public static boolean simDebugger;
+ public static boolean simRepackaged;
+ public static boolean simUntrustedAccessibility;
+
+ // --- token ------------------------------------------------------------
+
+ /** Simulated token lifetime. */
+ public static int tokenTtlSeconds = 300;
+
+ /** Hand out a token that has already lapsed, to exercise refresh handling. */
+ public static boolean serveExpiredToken;
+
+ // --- pinning ----------------------------------------------------------
+
+ /**
+ * Fail the next certificate check.
+ *
+ * The most useful switch here by a distance: it is the only practical way
+ * to test a fail-closed branch, short of deliberately mis-pinning a live
+ * host and waiting for the request to break.
+ */
+ public static boolean forcePinMismatch;
+
+ /** Simulate being unable to fetch a pin set. Must never fail a request. */
+ public static boolean failPinFetch;
+
+ /** True when the window is displaying a screen marked secure. */
+ public static boolean secureScreen;
+
+ /** The compromise reasons the simulated device reports. */
+ public static String[] simReasons() {
+ List out = new ArrayList();
+ if (simRooted) {
+ out.add("root");
+ }
+ if (simHooked) {
+ out.add("frida");
+ }
+ if (simEmulator) {
+ out.add("emulator");
+ }
+ if (simDebugger) {
+ out.add("debugger");
+ }
+ if (simRepackaged) {
+ out.add("repackaged");
+ }
+ return out.toArray(new String[out.size()]);
+ }
+
+ /** The accessibility services the simulated device reports as enabled. */
+ public static String[] simAccessibility() {
+ if (!simUntrustedAccessibility) {
+ return new String[0];
+ }
+ return new String[] {"com.example.malware/.OverlayService"};
+ }
+
+ /** Resets every toggle. Used by the menu's reset item and by tests. */
+ public static void reset() {
+ attestOutcome = AttestOutcome.PASS;
+ attestationSupported = true;
+ simRooted = false;
+ simHooked = false;
+ simEmulator = false;
+ simDebugger = false;
+ simRepackaged = false;
+ simUntrustedAccessibility = false;
+ tokenTtlSeconds = 300;
+ serveExpiredToken = false;
+ forcePinMismatch = false;
+ failPinFetch = false;
+ }
+
+ /** A human-readable dump for the menu's status dialog. */
+ public static String describe() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("Attestation outcome: ").append(attestOutcome).append('\n');
+ sb.append("Attestation supported: ").append(attestationSupported).append('\n');
+ String[] reasons = simReasons();
+ sb.append("Device signals: ")
+ .append(reasons.length == 0 ? "(none)" : String.join(", ", reasons)).append('\n');
+ sb.append("Accessibility: ")
+ .append(simUntrustedAccessibility ? "untrusted service enabled" : "clean").append('\n');
+ sb.append("Token TTL: ").append(tokenTtlSeconds).append("s")
+ .append(serveExpiredToken ? " (serving expired)" : "").append('\n');
+ sb.append("Pinning: ")
+ .append(forcePinMismatch ? "forcing mismatch" : "normal")
+ .append(failPinFetch ? ", pin fetch failing" : "").append('\n');
+ sb.append("Secure screen: ").append(secureScreen).append('\n');
+ return sb.toString();
+ }
+}
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWebSocketImpl.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWebSocketImpl.java
index ed50c455a46..4c21f646eba 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWebSocketImpl.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWebSocketImpl.java
@@ -163,6 +163,7 @@ private void doHandshake(int connectTimeoutMs) throws IOException {
}
req.append("\r\n");
}
+ appendRequestHeaders(req);
req.append("\r\n");
out.write(req.toString().getBytes(StandardCharsets.ISO_8859_1));
out.flush();
diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWebSocketImpl.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWebSocketImpl.java
index a2fe4e80f36..f1d0e8a412e 100644
--- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWebSocketImpl.java
+++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWebSocketImpl.java
@@ -137,6 +137,7 @@ private void doHandshake(int connectTimeoutMs) throws IOException {
req.append("Connection: Upgrade\r\n");
req.append("Sec-WebSocket-Key: ").append(key).append("\r\n");
req.append("Sec-WebSocket-Version: 13\r\n");
+ appendRequestHeaders(req);
req.append("\r\n");
out.write(bytes(req.toString()));
out.flush();
diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsWebSocketImpl.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsWebSocketImpl.java
index 921caa72a6a..3aacd07582f 100644
--- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsWebSocketImpl.java
+++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsWebSocketImpl.java
@@ -152,6 +152,7 @@ private void doHandshake(int connectTimeoutMs) throws IOException {
req.append("Connection: Upgrade\r\n");
req.append("Sec-WebSocket-Key: ").append(key).append("\r\n");
req.append("Sec-WebSocket-Version: 13\r\n");
+ appendRequestHeaders(req);
req.append("\r\n");
out.write(bytes(req.toString()));
out.flush();
diff --git a/Ports/iOSPort/nativeSources/CN1JailbreakDetector.h b/Ports/iOSPort/nativeSources/CN1JailbreakDetector.h
index 96fa9e7ba05..5468066b864 100644
--- a/Ports/iOSPort/nativeSources/CN1JailbreakDetector.h
+++ b/Ports/iOSPort/nativeSources/CN1JailbreakDetector.h
@@ -20,7 +20,25 @@
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
+#import
+
//#define CN1_DETECT_JAILBREAK 1
+
+/**
+ * Runs every jailbreak / hooking probe and returns the ones that fired as a
+ * comma separated list of stable codes, or an empty string on a clean device.
+ * Codes: dyldInsert, hookLib, jailbreakFile, restrictedWrite, traced.
+ *
+ * Always compiled, independent of CN1_DETECT_JAILBREAK, because
+ * DeviceIntegrity.getCompromiseReasons() surfaces these at runtime without
+ * terminating the app. Returns an empty string on the simulator.
+ */
+NSString *cn1JailbreakSignals(void);
+
#ifdef CN1_DETECT_JAILBREAK
-void cn1DetectJailbreakBypassesAndExit();
+/**
+ * Legacy hard gate kept for the ios.detectJailbreak build hint: runs
+ * cn1JailbreakSignals() and terminates the process if anything fired.
+ */
+void cn1DetectJailbreakBypassesAndExit(void);
#endif
diff --git a/Ports/iOSPort/nativeSources/CN1JailbreakDetector.m b/Ports/iOSPort/nativeSources/CN1JailbreakDetector.m
index 1dfee47b79f..d748ab6fa51 100644
--- a/Ports/iOSPort/nativeSources/CN1JailbreakDetector.m
+++ b/Ports/iOSPort/nativeSources/CN1JailbreakDetector.m
@@ -22,25 +22,31 @@
*/
#import "CN1JailbreakDetector.h"
-#ifdef CN1_DETECT_JAILBREAK
-#import
+#import
#import
#import
#import
#import
#import
-void cn1DetectJailbreakBypassesAndExit() {
+// Note: there is deliberately no fork() probe here. The classic form,
+// "if (fork() == 0) { exit(0); }", terminates the *child* and lets the parent
+// sail on, so it never did what it claimed. Reinstating it correctly would
+// mean calling a restricted syscall that trips App Review static analysis, for
+// a signal the dyld-image and restricted-path probes already carry.
+
+NSString *cn1JailbreakSignals(void) {
#if (TARGET_IPHONE_SIMULATOR)
- return;
-#endif
- // Detect common dynamic library injection used by Frida/Objection and similar tools
+ return @"";
+#else
+ NSMutableArray *signals = [NSMutableArray array];
+
+ // Dynamic library injection, as used by Frida/Objection and friends.
if (getenv("DYLD_INSERT_LIBRARIES") != NULL) {
- NSLog(@"DYLD_INSERT_LIBRARIES detected.");
- exit(0);
+ [signals addObject:@"dyldInsert"];
}
- // List of known libraries used by bypass tools like Liberty Lite and Substrate
+ // Known hooking / jailbreak-bypass libraries loaded into the process.
NSArray *bypassLibraries = @[
@"LibertyLite.dylib",
@"Substrate.dylib",
@@ -49,23 +55,22 @@ void cn1DetectJailbreakBypassesAndExit() {
@"tsProtector.dylib",
@"FridaGadget"
];
-
- // Check all loaded dynamic libraries
- for (int i = 0; i < _dyld_image_count(); i++) {
+ for (uint32_t i = 0; i < _dyld_image_count(); i++) {
const char *imageName = _dyld_get_image_name(i);
+ if (imageName == NULL) {
+ continue;
+ }
NSString *libraryName = [NSString stringWithUTF8String:imageName];
-
- // Check if the library name matches any known bypass tool libraries
for (NSString *bypassLibrary in bypassLibraries) {
if ([libraryName containsString:bypassLibrary]) {
- // Jailbreak bypass detected, exit the app
- NSLog(@"Bypass library detected: %@", bypassLibrary);
- exit(0); // Exit the app if a bypass tool is detected
+ [signals addObject:@"hookLib"];
+ i = _dyld_image_count();
+ break;
}
}
}
-
- // Additional check for file access to system areas (indicates potential bypass)
+
+ // Files that only exist once the sandbox has been broken out of.
NSArray *restrictedPaths = @[
@"/Applications/Cydia.app",
@"/Library/MobileSubstrate/MobileSubstrate.dylib",
@@ -74,45 +79,43 @@ void cn1DetectJailbreakBypassesAndExit() {
@"/etc/apt",
@"/private/var/lib/apt/"
];
-
NSFileManager *fileManager = [NSFileManager defaultManager];
for (NSString *path in restrictedPaths) {
if ([fileManager fileExistsAtPath:path]) {
- // Jailbreak files detected, exit the app
- NSLog(@"Jailbreak-related file detected: %@", path);
- exit(0); // Exit the app if a jailbreak-related file is found
+ [signals addObject:@"jailbreakFile"];
+ break;
}
}
-
- // Check if we can write to a restricted area (bypasses may allow this)
- NSString *testPath = @"/private/jailbreakTest.txt";
- NSError *error;
- BOOL wroteFile = [@"Test" writeToFile:testPath atomically:YES encoding:NSUTF8StringEncoding error:&error];
- if (wroteFile && !error) {
+
+ // Writing outside the sandbox should be impossible.
+ NSString *testPath = @"/private/cn1JailbreakTest.txt";
+ NSError *error = nil;
+ BOOL wroteFile = [@"Test" writeToFile:testPath atomically:YES
+ encoding:NSUTF8StringEncoding error:&error];
+ if (wroteFile && error == nil) {
[fileManager removeItemAtPath:testPath error:nil];
- // Able to write to restricted area, exit the app
- NSLog(@"Write access to restricted area detected.");
- exit(0); // Exit the app if write access to restricted areas is detected
- }
-
- // Check for abnormal system behavior like successful fork()
- if (fork() == 0) {
- // fork() should not succeed on non-jailbroken devices, exit if it does
- NSLog(@"Fork succeeded, indicating jailbreak bypass.");
- exit(0); // Exit the app if fork() succeeds
+ [signals addObject:@"restrictedWrite"];
}
-
- // Check for process tracing (which could indicate Liberty Lite tampering)
+
+ // A debugger or instrumentation tool attached to the process.
struct kinfo_proc info;
size_t size = sizeof(info);
int name[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()};
- if (sysctl(name, 4, &info, &size, NULL, 0) == 0 && (info.kp_proc.p_flag & P_TRACED) != 0) {
- // Process is being traced, likely due to a jailbreak bypass
- NSLog(@"Process tracing detected, indicating jailbreak bypass.");
- exit(0); // Exit the app if process tracing is detected
+ if (sysctl(name, 4, &info, &size, NULL, 0) == 0
+ && (info.kp_proc.p_flag & P_TRACED) != 0) {
+ [signals addObject:@"traced"];
+ }
+
+ return [signals componentsJoinedByString:@","];
+#endif
+}
+
+#ifdef CN1_DETECT_JAILBREAK
+void cn1DetectJailbreakBypassesAndExit(void) {
+ NSString *signals = cn1JailbreakSignals();
+ if (signals.length > 0) {
+ NSLog(@"Jailbreak bypass detected: %@", signals);
+ exit(0);
}
-
- // If no jailbreak bypass was detected, the app continues as normal
- NSLog(@"No jailbreak bypass detected.");
}
#endif
diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m
index ef60c5babc3..5593a06c931 100644
--- a/Ports/iOSPort/nativeSources/IOSNative.m
+++ b/Ports/iOSPort/nativeSources/IOSNative.m
@@ -28,6 +28,7 @@
#include "xmlvm.h"
#include "java_lang_String.h"
#import "CN1ES2compat.h"
+#import "CN1JailbreakDetector.h"
#if TARGET_OS_WATCH
#import "CN1CGGraphics.h"
#import "CN1WatchHost.h"
@@ -14572,7 +14573,7 @@ void com_codename1_impl_ios_IOSNative_stopBiometricAuthentication__(CN1_THREAD_S
#import
#import
-JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppAttestSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppAttestSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
#if !TARGET_OS_TV && !TARGET_OS_WATCH
if (@available(iOS 14.0, *)) {
if (NSClassFromString(@"DCAppAttestService") == NULL) {
@@ -14586,66 +14587,150 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppAttestSupported__(CN1_THREAD_
#endif // !TARGET_OS_TV && !TARGET_OS_WATCH
}
-void com_codename1_impl_ios_IOSNative_requestAppAttestToken___int_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT nonce) {
+// Reports a failure back to Java. errorCode carries the raw DCError value so the
+// Java side can tell "the key is invalid, throw it away and re-attest" (2) from
+// "Apple is throttling us, back off" (4) -- treating those the same is how an
+// app burns its attestation budget in a retry loop.
+static void cn1AppAttestFail(JAVA_INT requestId, NSError *err, NSString *fallback) {
+ NSString *m = err != nil ? err.localizedDescription : fallback;
+ JAVA_INT code = err != nil ? (JAVA_INT)err.code : -1;
+ JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), m);
+ com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_int_java_lang_String(getThreadLocalData(), requestId, code, jmsg);
+}
+
+// DCAppAttestService completion handlers run on an arbitrary dispatch queue.
+// Hopping to the main queue means every re-entry into the VM comes from a known
+// thread, matching what the biometrics block above does.
+#define CN1_APP_ATTEST_ON_MAIN(block) dispatch_async(dispatch_get_main_queue(), block)
+
+void com_codename1_impl_ios_IOSNative_appAttestGenerateKey___int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId) {
#if !TARGET_OS_TV && !TARGET_OS_WATCH
POOL_BEGIN();
if (@available(iOS 14.0, *)) {
DCAppAttestService *service = [DCAppAttestService sharedService];
if (!service.isSupported) {
- JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), @"App Attest not supported");
- com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_java_lang_String(getThreadLocalData(), requestId, jmsg);
+ cn1AppAttestFail(requestId, nil, @"App Attest not supported");
POOL_END();
return;
}
- NSString *nsNonce = (nonce == JAVA_NULL) ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG nonce);
- NSData *nonceData = [nsNonce dataUsingEncoding:NSUTF8StringEncoding];
- unsigned char hashBytes[CC_SHA256_DIGEST_LENGTH];
- CC_SHA256(nonceData.bytes, (CC_LONG)nonceData.length, hashBytes);
- NSData *clientDataHash = [NSData dataWithBytes:hashBytes length:CC_SHA256_DIGEST_LENGTH];
[service generateKeyWithCompletionHandler:^(NSString *keyId, NSError *genErr) {
- if (genErr != nil || keyId == nil) {
- NSString *m = genErr ? genErr.localizedDescription : @"App Attest key generation failed";
- JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), m);
- com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_java_lang_String(getThreadLocalData(), requestId, jmsg);
- return;
- }
- [service attestKey:keyId clientDataHash:clientDataHash completionHandler:^(NSData *attestationObject, NSError *attErr) {
+ CN1_APP_ATTEST_ON_MAIN(^{
+ if (genErr != nil || keyId == nil) {
+ cn1AppAttestFail(requestId, genErr, @"App Attest key generation failed");
+ return;
+ }
+ JAVA_OBJECT jkey = fromNSString(getThreadLocalData(), keyId);
+ com_codename1_impl_ios_IOSDeviceIntegrity_nativeKeyGenerated___int_java_lang_String(getThreadLocalData(), requestId, jkey);
+ });
+ }];
+ } else {
+ cn1AppAttestFail(requestId, nil, @"App Attest requires iOS 14+");
+ }
+ POOL_END();
+#else
+ cn1AppAttestFail(requestId, nil, @"App Attest not available on this platform");
+#endif // !TARGET_OS_TV && !TARGET_OS_WATCH
+}
+
+void com_codename1_impl_ios_IOSNative_appAttestAttestKey___int_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT keyId, JAVA_OBJECT clientDataHashB64) {
+#if !TARGET_OS_TV && !TARGET_OS_WATCH
+ POOL_BEGIN();
+ if (@available(iOS 14.0, *)) {
+ DCAppAttestService *service = [DCAppAttestService sharedService];
+ NSString *nsKeyId = (keyId == JAVA_NULL) ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG keyId);
+ NSString *nsHash = (clientDataHashB64 == JAVA_NULL) ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG clientDataHashB64);
+ NSData *clientDataHash = nsHash == nil ? nil
+ : [[NSData alloc] initWithBase64EncodedString:nsHash options:0];
+ if (nsKeyId == nil || clientDataHash == nil) {
+ cn1AppAttestFail(requestId, nil, @"App Attest attestation missing key or hash");
+ POOL_END();
+ return;
+ }
+ [service attestKey:nsKeyId clientDataHash:clientDataHash completionHandler:^(NSData *attestationObject, NSError *attErr) {
+ CN1_APP_ATTEST_ON_MAIN(^{
if (attErr != nil || attestationObject == nil) {
- NSString *m = attErr ? attErr.localizedDescription : @"App Attest attestation failed";
- JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), m);
- com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_java_lang_String(getThreadLocalData(), requestId, jmsg);
+ cn1AppAttestFail(requestId, attErr, @"App Attest attestation failed");
return;
}
- NSData *keyIdData = [keyId dataUsingEncoding:NSUTF8StringEncoding];
- NSString *b64Key = [keyIdData base64EncodedStringWithOptions:0];
NSString *b64Att = [attestationObject base64EncodedStringWithOptions:0];
- NSString *token = [NSString stringWithFormat:@"%@:%@", b64Key, b64Att];
- JAVA_OBJECT jtoken = fromNSString(getThreadLocalData(), token);
- com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestSuccess___int_java_lang_String(getThreadLocalData(), requestId, jtoken);
- }];
+ JAVA_OBJECT jatt = fromNSString(getThreadLocalData(), b64Att);
+ com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestationReady___int_java_lang_String(getThreadLocalData(), requestId, jatt);
+ });
+ }];
+ } else {
+ cn1AppAttestFail(requestId, nil, @"App Attest requires iOS 14+");
+ }
+ POOL_END();
+#else
+ cn1AppAttestFail(requestId, nil, @"App Attest not available on this platform");
+#endif // !TARGET_OS_TV && !TARGET_OS_WATCH
+}
+
+void com_codename1_impl_ios_IOSNative_appAttestGenerateAssertion___int_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT keyId, JAVA_OBJECT clientDataHashB64) {
+#if !TARGET_OS_TV && !TARGET_OS_WATCH
+ POOL_BEGIN();
+ if (@available(iOS 14.0, *)) {
+ DCAppAttestService *service = [DCAppAttestService sharedService];
+ NSString *nsKeyId = (keyId == JAVA_NULL) ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG keyId);
+ NSString *nsHash = (clientDataHashB64 == JAVA_NULL) ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG clientDataHashB64);
+ NSData *clientDataHash = nsHash == nil ? nil
+ : [[NSData alloc] initWithBase64EncodedString:nsHash options:0];
+ if (nsKeyId == nil || clientDataHash == nil) {
+ cn1AppAttestFail(requestId, nil, @"App Attest assertion missing key or hash");
+ POOL_END();
+ return;
+ }
+ [service generateAssertion:nsKeyId clientDataHash:clientDataHash completionHandler:^(NSData *assertion, NSError *assertErr) {
+ CN1_APP_ATTEST_ON_MAIN(^{
+ if (assertErr != nil || assertion == nil) {
+ cn1AppAttestFail(requestId, assertErr, @"App Attest assertion failed");
+ return;
+ }
+ NSString *b64 = [assertion base64EncodedStringWithOptions:0];
+ JAVA_OBJECT jassert = fromNSString(getThreadLocalData(), b64);
+ com_codename1_impl_ios_IOSDeviceIntegrity_nativeAssertionReady___int_java_lang_String(getThreadLocalData(), requestId, jassert);
+ });
}];
} else {
- JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), @"App Attest requires iOS 14+");
- com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_java_lang_String(getThreadLocalData(), requestId, jmsg);
+ cn1AppAttestFail(requestId, nil, @"App Attest requires iOS 14+");
}
POOL_END();
#else
- com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_java_lang_String(getThreadLocalData(), requestId, JAVA_NULL);
+ cn1AppAttestFail(requestId, nil, @"App Attest not available on this platform");
#endif // !TARGET_OS_TV && !TARGET_OS_WATCH
}
#else // CN1_USE_APP_ATTEST
// App Attest not enabled (ios.appAttest build hint off): DeviceCheck.framework
// is neither imported nor linked. Report unsupported / fail the request.
-JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppAttestSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppAttestSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
return JAVA_FALSE;
}
-void com_codename1_impl_ios_IOSNative_requestAppAttestToken___int_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT nonce) {
- com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_java_lang_String(getThreadLocalData(), requestId, JAVA_NULL);
+void com_codename1_impl_ios_IOSNative_appAttestGenerateKey___int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId) {
+ com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_int_java_lang_String(getThreadLocalData(), requestId, -1, JAVA_NULL);
+}
+
+void com_codename1_impl_ios_IOSNative_appAttestAttestKey___int_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT keyId, JAVA_OBJECT clientDataHashB64) {
+ com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_int_java_lang_String(getThreadLocalData(), requestId, -1, JAVA_NULL);
+}
+
+void com_codename1_impl_ios_IOSNative_appAttestGenerateAssertion___int_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT keyId, JAVA_OBJECT clientDataHashB64) {
+ com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_int_java_lang_String(getThreadLocalData(), requestId, -1, JAVA_NULL);
}
#endif // CN1_USE_APP_ATTEST
+// Jailbreak/instrumentation signals. Always compiled, independent of both
+// CN1_USE_APP_ATTEST and CN1_DETECT_JAILBREAK, because DeviceIntegrity reports
+// these at runtime without terminating the app.
+JAVA_OBJECT com_codename1_impl_ios_IOSNative_iosJailbreakSignals___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
+ POOL_BEGIN();
+ NSString *signals = cn1JailbreakSignals();
+ JAVA_OBJECT result = fromNSString(CN1_THREAD_STATE_PASS_ARG (signals == nil ? @"" : signals));
+ POOL_END();
+ return result;
+}
+
// --- CarPlay (CarPlay.framework) ------------------------------------------
// Gated by CN1_USE_CARPLAY: the builder uncomments the define, links
// CarPlay.framework, injects the CarPlay scene into the Info.plist scene manifest
diff --git a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
index 192987c3314..d2bbba98741 100644
--- a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
+++ b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
@@ -178,11 +178,19 @@ -(void) connection: (NSURLConnection*)connection willSendRequestForAuthenticatio
if (i>0) {
[certs appendString:@","];
}
+ // CHAIN: opens each certificate's group; index 0 is the leaf. The
+ // Java side keeps this and the SPKI entry out of the legacy flat list
+ // so existing checkSSLCertificates overrides see unchanged data.
+ [certs appendFormat:@"CHAIN:%d,", i];
[certs appendString:@"SHA-256:"];
[certs appendString:[self getFingerprint256:certRef]];
[certs appendString:@",SHA1:"];
[certs appendString:[self getFingerprint:certRef]];
-
+ NSString* spki = [self getPublicKeyDigest:certRef];
+ if (spki != nil) {
+ [certs appendString:@",SPKI-SHA-256:"];
+ [certs appendString:spki];
+ }
}
sslCertificates = [[NSString stringWithString:certs] retain];
if (com_codename1_io_NetworkManager_checkCertificatesNativeCallback___int_R_boolean(CN1_THREAD_GET_STATE_PASS_ARG connectionId)) {
@@ -207,6 +215,110 @@ - (NSString*) getFingerprint: (SecCertificateRef) cert {
return [fingerprint stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
}
+/**
+ * Reads one DER TLV at `off`. Returns NO when the buffer is too short or the
+ * length encoding is one we do not handle (indefinite length, or a length that
+ * would not fit). `headerLen` is the tag plus length bytes, `totalLen` covers
+ * the whole TLV.
+ */
+static BOOL cn1ReadDerTlv(const uint8_t* buf, NSUInteger len, NSUInteger off,
+ uint8_t* tag, NSUInteger* headerLen, NSUInteger* totalLen) {
+ if (off + 2 > len) {
+ return NO;
+ }
+ *tag = buf[off];
+ NSUInteger n = buf[off + 1];
+ if (n < 0x80) {
+ *headerLen = 2;
+ *totalLen = 2 + n;
+ } else {
+ NSUInteger countBytes = n & 0x7f;
+ // 0x80 is indefinite length, which DER forbids; more than 4 length bytes
+ // would mean a certificate larger than anything we will ever be handed.
+ if (countBytes == 0 || countBytes > 4 || off + 2 + countBytes > len) {
+ return NO;
+ }
+ NSUInteger contentLen = 0;
+ for (NSUInteger i = 0; i < countBytes; i++) {
+ contentLen = (contentLen << 8) | buf[off + 2 + i];
+ }
+ *headerLen = 2 + countBytes;
+ *totalLen = *headerLen + contentLen;
+ }
+ return off + *totalLen <= len;
+}
+
+/**
+ * Base64 SHA-256 over the certificate's SubjectPublicKeyInfo, which is what a
+ * public-key pin is computed over.
+ *
+ * Walks the certificate DER rather than going through SecCertificateCopyKey +
+ * SecKeyCopyExternalRepresentation. That pair hands back the *raw* key, so
+ * reconstructing the SPKI means prepending a hand-maintained ASN.1 header chosen
+ * per key type and size -- a table that silently produces wrong digests for any
+ * key type it does not know about. The DER walk is algorithm-agnostic and
+ * matches `openssl x509 -pubkey | openssl pkey -pubin -outform der` exactly.
+ *
+ * Returns nil if the structure is not what we expect, in which case the caller
+ * simply omits the entry and pinning falls back to whole-certificate digests.
+ */
+- (NSString*) getPublicKeyDigest: (SecCertificateRef) cert {
+ // Plain CoreFoundation rather than a toll-free bridge cast: this file builds
+ // both with and without ARC, and the correct bridging annotation differs
+ // between the two. An explicit CFRelease is unambiguous in either mode.
+ CFDataRef certData = SecCertificateCopyData(cert);
+ if (certData == NULL) {
+ return nil;
+ }
+ const uint8_t* buf = CFDataGetBytePtr(certData);
+ NSUInteger len = (NSUInteger) CFDataGetLength(certData);
+ NSString* result = nil;
+ uint8_t tag;
+ NSUInteger headerLen, totalLen;
+ NSUInteger off;
+ int i;
+
+ // Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature }
+ if (!cn1ReadDerTlv(buf, len, 0, &tag, &headerLen, &totalLen) || tag != 0x30) {
+ goto cleanup;
+ }
+ off = headerLen;
+
+ // tbsCertificate ::= SEQUENCE { ... }
+ if (!cn1ReadDerTlv(buf, len, off, &tag, &headerLen, &totalLen) || tag != 0x30) {
+ goto cleanup;
+ }
+ off += headerLen;
+
+ // [0] EXPLICIT Version is optional and absent in a v1 certificate.
+ if (!cn1ReadDerTlv(buf, len, off, &tag, &headerLen, &totalLen)) {
+ goto cleanup;
+ }
+ if (tag == 0xA0) {
+ off += totalLen;
+ }
+
+ // Skip serialNumber, signature, issuer, validity, subject. The next element
+ // is subjectPublicKeyInfo.
+ for (i = 0; i < 5; i++) {
+ if (!cn1ReadDerTlv(buf, len, off, &tag, &headerLen, &totalLen)) {
+ goto cleanup;
+ }
+ off += totalLen;
+ }
+
+ if (cn1ReadDerTlv(buf, len, off, &tag, &headerLen, &totalLen) && tag == 0x30) {
+ uint8_t digest[CC_SHA256_DIGEST_LENGTH];
+ CC_SHA256(buf + off, (CC_LONG) totalLen, digest);
+ NSData* digestData = [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH];
+ result = [digestData base64EncodedStringWithOptions:0];
+ }
+
+cleanup:
+ CFRelease(certData);
+ return result;
+}
+
- (NSString*) getFingerprint256: (SecCertificateRef) cert {
NSData* keyData = (__bridge NSData*) SecCertificateCopyData(cert);
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 541137161d2..89b836c70b2 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -22,8 +22,12 @@
*/
package com.codename1.impl.ios;
+import com.codename1.io.Log;
+import com.codename1.security.Hash;
+import com.codename1.security.SecureStorage;
import com.codename1.ui.Display;
import com.codename1.util.AsyncResource;
+import com.codename1.util.Base64;
import java.util.HashMap;
import java.util.Map;
@@ -32,34 +36,118 @@
* iOS backing for App Attest (DeviceCheck.framework), surfaced through
* {@link com.codename1.security.DeviceIntegrity#requestIntegrityToken(String)}.
*
- * The native side dispatches results back via the static
- * {@link #nativeAttestSuccess(int, String)} / {@link #nativeAttestError(int, String)}
- * methods. As with {@link IOSBiometrics}, the static initializer invokes each
- * with no-op values so the ParparVM dead-code eliminator does not strip the
- * native callback targets (no Java caller exists).
+ * Attest once, assert many
+ *
+ * Apple's model is: generate a Secure Enclave key once, attest it
+ * once so the server can record its public key, then produce cheap
+ * assertions against that key for every subsequent request. Key generation and
+ * attestation are both rate limited; assertions are not.
+ *
+ * This class implements that state machine over three keychain entries in
+ * {@link IOSSecureStorage}'s non-prompting tier, so the identity survives app
+ * restarts and updates:
+ *
+ *
+ * - {@code cn1.appattest.keyId} -- the key identifier
+ *
- {@code cn1.appattest.state} -- {@code new} or {@code attested}
+ *
- {@code cn1.appattest.retryAfter} -- backoff deadline after a throttle
+ *
+ *
+ * Token format
+ *
+ * Tokens are prefixed so a backend can tell the two forms apart, which the
+ * bare {@code base64:base64} form could not:
+ *
+ *
+ * cn1aa1:attest:<b64 keyId>:<b64 attestationObject>
+ * cn1aa1:assert:<b64 keyId>:<b64 assertion>:<b64 clientData>
+ *
+ *
+ * The client data for an assertion is a small JSON object carrying the
+ * server nonce, so the server can recompute the hash the assertion signed.
+ *
+ * ParparVM note
+ *
+ * The native side dispatches results back through the static callbacks
+ * below. As with {@link IOSBiometrics}, the static initializer invokes each with
+ * no-op values so the dead-code eliminator does not strip them -- no Java caller
+ * exists, and without a reachable reference they become empty stubs and the
+ * native call silently does nothing.
*/
final class IOSDeviceIntegrity {
static {
// Prevents the iOS VM optimizer from eliding these native callbacks.
- nativeAttestSuccess(-1, null);
- nativeAttestError(-1, null);
+ nativeKeyGenerated(-1, null);
+ nativeAttestationReady(-1, null);
+ nativeAssertionReady(-1, null);
+ nativeAttestError(-1, -1, null);
}
- private static final Map> REQUESTS =
- new HashMap>();
+ static final String TOKEN_PREFIX = "cn1aa1";
+
+ private static final String KEY_ID = "cn1.appattest.keyId";
+ private static final String KEY_STATE = "cn1.appattest.state";
+ private static final String KEY_RETRY_AFTER = "cn1.appattest.retryAfter";
+
+ private static final String STATE_NEW = "new";
+ private static final String STATE_ATTESTED = "attested";
+
+ /** DCError.invalidKey -- the server or the OS no longer knows this key. */
+ private static final int DC_ERROR_INVALID_KEY = 2;
+ /** DCError.serverUnavailable -- Apple is throttling or unreachable. */
+ private static final int DC_ERROR_SERVER_UNAVAILABLE = 4;
+
+ private static final long MIN_BACKOFF_MILLIS = 30L * 1000L;
+ private static final long MAX_BACKOFF_MILLIS = 60L * 60L * 1000L;
+
+ private static final Map REQUESTS =
+ new HashMap();
private static int nextRequestId = 1;
+ private static IOSDeviceIntegrity instance;
+
private final IOSNative nativeInstance;
+ /** Guards the whole attest flow so concurrent callers cannot each burn a key. */
+ private final Object flowLock = new Object();
+ private long currentBackoff = MIN_BACKOFF_MILLIS;
IOSDeviceIntegrity(IOSNative nativeInstance) {
this.nativeInstance = nativeInstance;
+ instance = this;
}
boolean isSupported() {
return nativeInstance.isAppAttestSupported();
}
+ /**
+ * Discards the stored key so the next request attests afresh. Called when the
+ * backend reports it does not recognise the key -- after a reinstall, a
+ * restore to a new device, or an OS-side invalidation.
+ */
+ void resetAttestation() {
+ SecureStorage store = SecureStorage.getInstance();
+ store.remove(KEY_ID);
+ store.remove(KEY_STATE);
+ store.remove(KEY_RETRY_AFTER);
+ synchronized (flowLock) {
+ currentBackoff = MIN_BACKOFF_MILLIS;
+ }
+ }
+
+ String[] jailbreakSignals() {
+ try {
+ String signals = nativeInstance.iosJailbreakSignals();
+ if (signals == null || signals.length() == 0) {
+ return new String[0];
+ }
+ return com.codename1.io.Util.split(signals, ",");
+ } catch (Throwable t) {
+ return new String[0];
+ }
+ }
+
AsyncResource requestToken(String nonce) {
AsyncResource r = new AsyncResource();
if (!nativeInstance.isAppAttestSupported()) {
@@ -67,52 +155,252 @@ AsyncResource requestToken(String nonce) {
"App Attest is not supported on this device"));
return r;
}
- int rid;
- synchronized (REQUESTS) {
- rid = nextRequestId++;
- REQUESTS.put(Integer.valueOf(rid), r);
+ if (nonce == null || nonce.length() == 0) {
+ r.error(new IllegalArgumentException(
+ "App Attest requires a server-issued nonce; a client-generated "
+ + "one is not replay-safe"));
+ return r;
+ }
+ synchronized (flowLock) {
+ long retryAfter = readRetryAfter();
+ if (retryAfter > System.currentTimeMillis()) {
+ r.error(new RuntimeException("App Attest is backing off after a throttle; "
+ + "retry in " + ((retryAfter - System.currentTimeMillis()) / 1000)
+ + "s"));
+ return r;
+ }
+ SecureStorage store = SecureStorage.getInstance();
+ String keyId = store.get(KEY_ID);
+ if (keyId == null || keyId.length() == 0) {
+ // No key yet: generate one, then continue into attestation.
+ int rid = register(new PendingRequest(r, nonce, PendingRequest.OP_GENERATE_KEY, null));
+ nativeInstance.appAttestGenerateKey(rid);
+ return r;
+ }
+ if (STATE_ATTESTED.equals(store.get(KEY_STATE))) {
+ assertWithKey(r, nonce, keyId);
+ } else {
+ attestKey(r, nonce, keyId);
+ }
}
- nativeInstance.requestAppAttestToken(rid, nonce);
return r;
}
+ // --- flow steps ------------------------------------------------------
+
+ private void attestKey(AsyncResource r, String nonce, String keyId) {
+ // The attestation binds to SHA-256 of the raw nonce. Hashing here rather
+ // than natively keeps a single hash implementation across both paths.
+ String hash = base64(Hash.sha256(bytes(nonce)));
+ int rid = register(new PendingRequest(r, nonce, PendingRequest.OP_ATTEST, keyId));
+ nativeInstance.appAttestAttestKey(rid, keyId, hash);
+ }
+
+ private void assertWithKey(AsyncResource r, String nonce, String keyId) {
+ String clientData = clientDataJson(nonce, keyId);
+ String hash = base64(Hash.sha256(bytes(clientData)));
+ PendingRequest pending =
+ new PendingRequest(r, nonce, PendingRequest.OP_ASSERT, keyId);
+ pending.clientData = clientData;
+ int rid = register(pending);
+ nativeInstance.appAttestGenerateAssertion(rid, keyId, hash);
+ }
+
+ /**
+ * Minimal JSON, hand-built rather than pulled from a parser: it has to be
+ * byte-identical to what the server recomputes the hash over, so its exact
+ * serialization is part of the wire contract.
+ */
+ private static String clientDataJson(String nonce, String keyId) {
+ return "{\"n\":\"" + escapeJson(nonce) + "\",\"k\":\"" + escapeJson(keyId) + "\"}";
+ }
+
+ private static String escapeJson(String s) {
+ StringBuilder sb = new StringBuilder(s.length());
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (c == '"' || c == '\\') {
+ sb.append('\\').append(c);
+ } else if (c < 0x20) {
+ sb.append(' ');
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
// ---- Callbacks invoked from native code (do not rename) ----------------
- /** Called from native when attestation succeeds with the opaque token. */
- public static void nativeAttestSuccess(final int requestId, final String token) {
- final AsyncResource r = take(requestId);
- if (r == null) {
+ /** Called from native once a fresh hardware key exists. */
+ public static void nativeKeyGenerated(final int requestId, final String keyId) {
+ PendingRequest pending = take(requestId);
+ if (pending == null || instance == null) {
+ return;
+ }
+ if (keyId == null || keyId.length() == 0) {
+ fail(pending, "App Attest key generation returned no identifier");
+ return;
+ }
+ SecureStorage store = SecureStorage.getInstance();
+ store.set(KEY_ID, keyId);
+ store.set(KEY_STATE, STATE_NEW);
+ synchronized (instance.flowLock) {
+ instance.attestKey(pending.result, pending.nonce, keyId);
+ }
+ }
+
+ /** Called from native with the attestation object for a newly attested key. */
+ public static void nativeAttestationReady(final int requestId, final String attestationB64) {
+ PendingRequest pending = take(requestId);
+ if (pending == null) {
return;
}
+ if (attestationB64 == null) {
+ fail(pending, "App Attest attestation returned no data");
+ return;
+ }
+ // Optimistic: Apple accepted the attestation, but only the backend can
+ // confirm it recorded the key. If it later rejects, the app calls
+ // DeviceIntegrity.resetAttestation() and we start over.
+ SecureStorage store = SecureStorage.getInstance();
+ store.set(KEY_STATE, STATE_ATTESTED);
+ if (instance != null) {
+ synchronized (instance.flowLock) {
+ instance.currentBackoff = MIN_BACKOFF_MILLIS;
+ }
+ }
+ succeed(pending, TOKEN_PREFIX + ":attest:" + base64(bytes(pending.keyId))
+ + ":" + attestationB64);
+ }
+
+ /** Called from native with an assertion over an already attested key. */
+ public static void nativeAssertionReady(final int requestId, final String assertionB64) {
+ PendingRequest pending = take(requestId);
+ if (pending == null) {
+ return;
+ }
+ if (assertionB64 == null) {
+ fail(pending, "App Attest assertion returned no data");
+ return;
+ }
+ succeed(pending, TOKEN_PREFIX + ":assert:" + base64(bytes(pending.keyId))
+ + ":" + assertionB64
+ + ":" + base64(bytes(pending.clientData)));
+ }
+
+ /** Called from native when any step fails. errorCode is the raw DCError code. */
+ public static void nativeAttestError(final int requestId, final int errorCode,
+ final String msg) {
+ PendingRequest pending = take(requestId);
+ if (pending == null) {
+ return;
+ }
+ if (errorCode == DC_ERROR_INVALID_KEY && instance != null && !pending.retried) {
+ // The key is gone or was never valid. Wipe it and try once from
+ // scratch; a second failure is reported rather than looped.
+ instance.resetAttestation();
+ synchronized (instance.flowLock) {
+ PendingRequest retry = new PendingRequest(pending.result, pending.nonce,
+ PendingRequest.OP_GENERATE_KEY, null);
+ retry.retried = true;
+ int rid = register(retry);
+ instance.nativeInstance.appAttestGenerateKey(rid);
+ }
+ return;
+ }
+ if (errorCode == DC_ERROR_SERVER_UNAVAILABLE && instance != null) {
+ // Never retry a throttle in a loop -- that is what gets an app's
+ // whole attestation budget suspended.
+ synchronized (instance.flowLock) {
+ long backoff = instance.currentBackoff;
+ SecureStorage.getInstance().set(KEY_RETRY_AFTER,
+ Long.toString(System.currentTimeMillis() + backoff));
+ instance.currentBackoff = Math.min(backoff * 2, MAX_BACKOFF_MILLIS);
+ }
+ }
+ fail(pending, msg == null ? "App Attest failed (code " + errorCode + ")" : msg);
+ }
+
+ // --- helpers ---------------------------------------------------------
+
+ private static long readRetryAfter() {
+ try {
+ String v = SecureStorage.getInstance().get(KEY_RETRY_AFTER);
+ return v == null ? 0 : Long.parseLong(v);
+ } catch (Throwable t) {
+ return 0;
+ }
+ }
+
+ private static int register(PendingRequest pending) {
+ synchronized (REQUESTS) {
+ int rid = nextRequestId++;
+ REQUESTS.put(Integer.valueOf(rid), pending);
+ return rid;
+ }
+ }
+
+ private static PendingRequest take(int requestId) {
+ synchronized (REQUESTS) {
+ return REQUESTS.remove(Integer.valueOf(requestId));
+ }
+ }
+
+ private static void succeed(final PendingRequest pending, final String token) {
Display.getInstance().callSerially(new Runnable() {
- @Override
public void run() {
- if (!r.isDone()) {
- r.complete(token);
+ if (!pending.result.isDone()) {
+ pending.result.complete(token);
}
}
});
}
- /** Called from native when key generation or attestation fails. */
- public static void nativeAttestError(final int requestId, final String msg) {
- final AsyncResource r = take(requestId);
- if (r == null) {
- return;
- }
+ private static void fail(final PendingRequest pending, final String msg) {
Display.getInstance().callSerially(new Runnable() {
- @Override
public void run() {
- if (!r.isDone()) {
- r.error(new RuntimeException(msg == null ? "App Attest failed" : msg));
+ if (!pending.result.isDone()) {
+ pending.result.error(new RuntimeException(msg));
}
}
});
}
- private static AsyncResource take(int requestId) {
- synchronized (REQUESTS) {
- return REQUESTS.remove(Integer.valueOf(requestId));
+ private static byte[] bytes(String s) {
+ if (s == null) {
+ return new byte[0];
+ }
+ try {
+ return s.getBytes("UTF-8");
+ } catch (java.io.UnsupportedEncodingException e) {
+ Log.e(e);
+ return s.getBytes();
+ }
+ }
+
+ private static String base64(byte[] data) {
+ return Base64.encodeNoNewline(data);
+ }
+
+ /** One in-flight native operation and the state needed to finish or resume it. */
+ private static final class PendingRequest {
+ static final int OP_GENERATE_KEY = 0;
+ static final int OP_ATTEST = 1;
+ static final int OP_ASSERT = 2;
+
+ final AsyncResource result;
+ final String nonce;
+ final int op;
+ final String keyId;
+ String clientData;
+ boolean retried;
+
+ PendingRequest(AsyncResource result, String nonce, int op, String keyId) {
+ this.result = result;
+ this.nonce = nonce;
+ this.op = op;
+ this.keyId = keyId;
}
}
}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
index dc327326e4b..b990a7ead15 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
@@ -4572,10 +4572,19 @@ public boolean isAttestationSupported() {
@Override
public com.codename1.util.AsyncResource requestIntegrityToken(String nonce) {
+ return deviceIntegrity().requestToken(nonce);
+ }
+
+ @Override
+ public void resetAttestation() {
+ deviceIntegrity().resetAttestation();
+ }
+
+ private IOSDeviceIntegrity deviceIntegrity() {
if (deviceIntegrity == null) {
deviceIntegrity = new IOSDeviceIntegrity(nativeInstance);
}
- return deviceIntegrity.requestToken(nonce);
+ return deviceIntegrity;
}
@Override
@@ -9821,7 +9830,36 @@ public Object connect(String url, boolean read, boolean write) throws IOExceptio
public String[] getSSLCertificates(Object connection, String url) throws IOException {
NetworkConnection conn = (NetworkConnection)connection;
//conn.ensureConnection();
- return conn.getSSLCertificates(url);
+ return stripExtendedCertificateEntries(conn.getSSLCertificates(url));
+ }
+
+ /**
+ * The native side always emits the chain-grouping and public-key entries,
+ * because the certificate string is built once during the TLS handshake and
+ * cannot be regenerated on demand. Callers of the legacy flat form must not
+ * see them, or an existing checkSSLCertificates override that rejects on any
+ * unrecognised entry would start failing every request.
+ */
+ private static String[] stripExtendedCertificateEntries(String[] entries) {
+ if (entries == null || entries.length == 0) {
+ return new String[0];
+ }
+ java.util.ArrayList out = new java.util.ArrayList(entries.length);
+ for (int i = 0; i < entries.length; i++) {
+ String e = entries[i];
+ if (e == null || e.startsWith("CHAIN:") || e.startsWith("SPKI-SHA-256:")) {
+ continue;
+ }
+ out.add(e);
+ }
+ return out.toArray(new String[out.size()]);
+ }
+
+ @Override
+ public String[] getSSLCertificatesEx(Object connection, String url) throws IOException {
+ NetworkConnection conn = (NetworkConnection)connection;
+ String[] certs = conn.getSSLCertificates(url);
+ return certs == null ? new String[0] : certs;
}
@Override
@@ -9829,6 +9867,11 @@ public boolean canGetSSLCertificates() {
return true;
}
+ @Override
+ public boolean canGetPublicKeyDigests() {
+ return true;
+ }
+
/**
* Checking SSL certificates uses a native callback, instead of the direct approach
* which is used in other ports.
@@ -12518,10 +12561,53 @@ public boolean isPolygon() {
@Override
public boolean isJailbrokenDevice() {
+ if (getCompromiseReasons().length > 0) {
+ return true;
+ }
+ // Kept as a last resort, but note it only ever returns true when the app
+ // also declares cydia in ios.applicationQueriesSchemes -- on iOS 9 and up
+ // canOpenURL returns false for undeclared schemes regardless of what is
+ // installed. That is why it cannot be the primary signal.
Boolean b = canExecute("cydia://package/com.example.package");
return b != null && b.booleanValue();
}
+ /**
+ * Real jailbreak and instrumentation signals, from the same native probes the
+ * {@code ios.detectJailbreak} launch gate uses -- but reported rather than
+ * fatal, so an app can degrade gracefully instead of being killed at launch.
+ */
+ @Override
+ public String[] getCompromiseReasons() {
+ String[] signals = deviceIntegrity().jailbreakSignals();
+ if (signals.length == 0) {
+ return signals;
+ }
+ java.util.ArrayList out = new java.util.ArrayList();
+ boolean jailbreakReported = false;
+ for (int i = 0; i < signals.length; i++) {
+ String s = signals[i];
+ if ("hookLib".equals(s) || "dyldInsert".equals(s)) {
+ if (!out.contains("frida")) {
+ // Reported under the cross-platform name for a hooking
+ // framework so app code does not need a per-platform branch.
+ out.add("frida");
+ }
+ } else if ("traced".equals(s)) {
+ out.add("debugger");
+ } else if (!jailbreakReported) {
+ jailbreakReported = true;
+ out.add("jailbreak");
+ }
+ }
+ return out.toArray(new String[out.size()]);
+ }
+
+ @Override
+ public boolean isDeviceCompromised() {
+ return getCompromiseReasons().length > 0;
+ }
+
@Override
public void announceForAccessibility(final Component cmp, final String text) {
IOSNative.announceForAccessibility(text);
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
index b8477540459..4104cd1bbb0 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
@@ -1009,14 +1009,39 @@ native void walletExtensionAddPassEntry(boolean remote, String identifier, Strin
native boolean isAppAttestSupported();
/**
- * Generates/uses an App Attest hardware key and produces an attestation bound
- * to the SHA-256 of {@code nonce}. Native code calls back into
- * {@code IOSDeviceIntegrity.nativeAttestSuccess(int, String)} or
- * {@code IOSDeviceIntegrity.nativeAttestError(int, String)} with the same
- * requestId. The success token is {@code base64(keyId):base64(attestationObject)}
- * for the backend to verify with Apple.
+ * Generates a fresh App Attest hardware key. Calls back into
+ * {@code IOSDeviceIntegrity.nativeKeyGenerated(int, String)} with the key
+ * identifier, or {@code nativeAttestError(int, int, String)} on failure.
+ *
+ * Apple rate-limits key generation, so this must happen once per install
+ * and the identifier must be persisted -- not once per request.
*/
- native void requestAppAttestToken(int requestId, String nonce);
+ native void appAttestGenerateKey(int requestId);
+
+ /**
+ * Attests a previously generated key against a server challenge. Calls back
+ * into {@code IOSDeviceIntegrity.nativeAttestationReady(int, String)} with a
+ * base64 attestation object for the backend to verify with Apple.
+ *
+ * @param clientDataHashB64 base64 of the SHA-256 the attestation binds to,
+ * computed on the Java side so the attest and assert paths cannot
+ * disagree about what was hashed
+ */
+ native void appAttestAttestKey(int requestId, String keyId, String clientDataHashB64);
+
+ /**
+ * Produces an assertion over a previously attested key -- the cheap,
+ * unlimited operation that every request after the first should use. Calls
+ * back into {@code IOSDeviceIntegrity.nativeAssertionReady(int, String)}.
+ */
+ native void appAttestGenerateAssertion(int requestId, String keyId, String clientDataHashB64);
+
+ /**
+ * Comma separated jailbreak/hooking signal codes observed on this device, or
+ * an empty string when clean. Unlike the {@code ios.detectJailbreak} launch
+ * gate this never terminates the app.
+ */
+ native String iosJailbreakSignals();
// --- CarPlay (CarPlay.framework) ----------------------------------------
// All gated natively by CN1_USE_CARPLAY (the build flips it on when the app references
diff --git a/maven/core-unittests/src/test/java/com/codename1/io/SSLCertificateChainParsingTest.java b/maven/core-unittests/src/test/java/com/codename1/io/SSLCertificateChainParsingTest.java
new file mode 100644
index 00000000000..55e82a76d31
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/io/SSLCertificateChainParsingTest.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (c) 2026, 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.io;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Parsing of the richer, per-certificate form of the platform certificate list.
+ * Getting the grouping wrong would attribute a public key to the wrong
+ * certificate, which is a pin that silently checks the wrong thing.
+ */
+class SSLCertificateChainParsingTest {
+
+ @Test
+ void groupsEntriesByChainDelimiter() {
+ ConnectionRequest.SSLCertificate[] certs = ConnectionRequest.parseGroupedCertificates(
+ new String[] {
+ "CHAIN:0",
+ "SHA-256:leafcert",
+ "SHA1:leafsha1",
+ "SPKI-SHA-256:leafspki",
+ "CHAIN:1",
+ "SHA-256:issuercert",
+ "SHA1:issuersha1",
+ "SPKI-SHA-256:issuerspki"
+ });
+
+ assertEquals(2, certs.length);
+
+ assertEquals(0, certs[0].getChainIndex());
+ assertTrue(certs[0].isLeaf());
+ assertEquals("leafcert", certs[0].getCertificteUniqueKey());
+ assertEquals("SHA-256", certs[0].getCertificteAlgorithm());
+ assertEquals("leafspki", certs[0].getPublicKeyDigest());
+ assertEquals("SHA-256", certs[0].getPublicKeyDigestAlgorithm());
+
+ assertEquals(1, certs[1].getChainIndex());
+ assertFalse(certs[1].isLeaf());
+ assertEquals("issuercert", certs[1].getCertificteUniqueKey());
+ assertEquals("issuerspki", certs[1].getPublicKeyDigest());
+ }
+
+ @Test
+ void nonTypoAliasesReturnTheSameValues() {
+ ConnectionRequest.SSLCertificate[] certs = ConnectionRequest.parseGroupedCertificates(
+ new String[] {"CHAIN:0", "SHA-256:abc"});
+ assertEquals(certs[0].getCertificteUniqueKey(), certs[0].getFingerprint());
+ assertEquals(certs[0].getCertificteAlgorithm(), certs[0].getFingerprintAlgorithm());
+ }
+
+ /**
+ * A port may report digests without grouping. Treat what arrives as the leaf
+ * rather than dropping it.
+ */
+ @Test
+ void entriesBeforeAnyDelimiterBecomeTheLeaf() {
+ ConnectionRequest.SSLCertificate[] certs = ConnectionRequest.parseGroupedCertificates(
+ new String[] {"SHA-256:abc", "SPKI-SHA-256:def"});
+ assertEquals(1, certs.length);
+ assertEquals(0, certs[0].getChainIndex());
+ assertEquals("abc", certs[0].getCertificteUniqueKey());
+ assertEquals("def", certs[0].getPublicKeyDigest());
+ }
+
+ @Test
+ void certificateWithoutASpkiEntryReportsNullRatherThanGuessing() {
+ ConnectionRequest.SSLCertificate[] certs = ConnectionRequest.parseGroupedCertificates(
+ new String[] {"CHAIN:0", "SHA-256:abc", "SHA1:def"});
+ assertEquals(1, certs.length);
+ assertNull(certs[0].getPublicKeyDigest());
+ assertNull(certs[0].getPublicKeyDigestAlgorithm());
+ }
+
+ /**
+ * The first fingerprint wins, so the SHA1 entry that follows SHA-256 does not
+ * overwrite it. Pinning a SHA1 fingerprint by accident would be a downgrade.
+ */
+ @Test
+ void firstFingerprintWinsWithinACertificateGroup() {
+ ConnectionRequest.SSLCertificate[] certs = ConnectionRequest.parseGroupedCertificates(
+ new String[] {"CHAIN:0", "SHA-256:strong", "SHA1:weak"});
+ assertEquals("SHA-256", certs[0].getCertificteAlgorithm());
+ assertEquals("strong", certs[0].getCertificteUniqueKey());
+ }
+
+ @Test
+ void malformedEntriesAreSkippedWithoutFailingTheChain() {
+ ConnectionRequest.SSLCertificate[] certs = ConnectionRequest.parseGroupedCertificates(
+ new String[] {null, "no-colon-here", "CHAIN:0", "SHA-256:abc"});
+ assertEquals(1, certs.length);
+ assertEquals("abc", certs[0].getCertificteUniqueKey());
+ }
+
+ @Test
+ void unparseableChainIndexFallsBackToPositionalOrder() {
+ ConnectionRequest.SSLCertificate[] certs = ConnectionRequest.parseGroupedCertificates(
+ new String[] {"CHAIN:x", "SHA-256:a", "CHAIN:y", "SHA-256:b"});
+ assertEquals(2, certs.length);
+ assertEquals(0, certs[0].getChainIndex());
+ assertEquals(1, certs[1].getChainIndex());
+ }
+
+ @Test
+ void emptyInputYieldsEmptyChain() {
+ assertEquals(0, ConnectionRequest.parseGroupedCertificates(new String[0]).length);
+ }
+
+ /**
+ * Base64 digests contain + / and = but never a comma, which matters because
+ * the iOS port ships the whole chain as one comma-joined string.
+ */
+ @Test
+ void base64DigestsSurviveTheCommaJoinedTransport() {
+ String spki = "o+c2M5zOnK96U55rTfy2G9krOHRxnMwJ3esntXNFMdc=";
+ assertFalse(spki.contains(","));
+ ConnectionRequest.SSLCertificate[] certs = ConnectionRequest.parseGroupedCertificates(
+ new String[] {"CHAIN:0", "SPKI-SHA-256:" + spki});
+ assertEquals(spki, certs[0].getPublicKeyDigest());
+ }
+}
diff --git a/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
new file mode 100644
index 00000000000..a9994ff384b
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
@@ -0,0 +1,282 @@
+/*
+ * Copyright (c) 2026, 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.shield;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Hashtable;
+import java.util.Vector;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Covers the pure value types of the shield API. The behaviours asserted here are
+ * the ones an app relies on to decide whether to block a user, so a regression in
+ * any of them is a production incident rather than a cosmetic bug.
+ */
+class ShieldApiTest {
+
+ // --- ShieldStatus ---------------------------------------------------
+
+ @Test
+ void onlyOkIsSuccess() {
+ assertTrue(ShieldStatus.OK.isSuccess());
+ assertFalse(ShieldStatus.UNPROTECTED.isSuccess());
+ assertFalse(ShieldStatus.REJECTED.isSuccess());
+ assertFalse(ShieldStatus.PIN_MISMATCH.isSuccess());
+ }
+
+ /**
+ * The distinction the whole API is built around: a service we could not reach
+ * is retryable, a device the service refused is not.
+ */
+ @Test
+ void reachabilityFailuresAreTransientButRejectionIsNot() {
+ assertTrue(ShieldStatus.NO_NETWORK.isTransient());
+ assertTrue(ShieldStatus.POOR_NETWORK.isTransient());
+ assertTrue(ShieldStatus.SERVICE_DOWN.isTransient());
+ assertTrue(ShieldStatus.RATE_LIMITED.isTransient());
+
+ assertFalse(ShieldStatus.REJECTED.isTransient());
+ assertFalse(ShieldStatus.PIN_MISMATCH.isTransient());
+ assertFalse(ShieldStatus.UNPROTECTED.isTransient());
+ assertFalse(ShieldStatus.OK.isTransient());
+ }
+
+ @Test
+ void unknownStatusIdRoundTripsAsANonSuccess() {
+ ShieldStatus s = ShieldStatus.forId("somethingThisBuildPredates");
+ assertEquals("somethingThisBuildPredates", s.getId());
+ assertFalse(s.isSuccess());
+ assertSame(ShieldStatus.RATE_LIMITED, ShieldStatus.forId("rateLimited"));
+ assertSame(ShieldStatus.NOT_INITIALIZED, ShieldStatus.forId(null));
+ }
+
+ // --- ShieldToken ----------------------------------------------------
+
+ @Test
+ void tokenIsValidOnlyWhileItHasValueSuccessAndTime() {
+ long now = System.currentTimeMillis();
+ assertTrue(new ShieldToken("t", ShieldStatus.OK, now, 60000, null).isValid());
+ assertFalse(new ShieldToken(null, ShieldStatus.OK, now, 60000, null).isValid());
+ assertFalse(new ShieldToken("t", ShieldStatus.REJECTED, now, 60000, null).isValid());
+ assertFalse(new ShieldToken("t", ShieldStatus.OK, now - 120000, 60000, null).isValid());
+ }
+
+ @Test
+ void expiredTokenReportsZeroRatherThanNegativeRemainingTime() {
+ ShieldToken t = new ShieldToken("t", ShieldStatus.OK,
+ System.currentTimeMillis() - 120000, 60000, null);
+ assertEquals(0, t.getMillisUntilExpiry());
+ }
+
+ @Test
+ void refreshTriggersOnceThresholdShareOfLifetimeIsUsed() {
+ long now = System.currentTimeMillis();
+ // 10% used, 50% threshold -> no refresh yet.
+ assertFalse(new ShieldToken("t", ShieldStatus.OK, now - 1000, 10000, null)
+ .shouldRefresh(50));
+ // 60% used -> refresh.
+ assertTrue(new ShieldToken("t", ShieldStatus.OK, now - 6000, 10000, null)
+ .shouldRefresh(50));
+ }
+
+ @Test
+ void aBoundTokenIsNotReusableForOtherRequests() {
+ ShieldToken bound = new ShieldToken("t", ShieldStatus.OK,
+ System.currentTimeMillis(), 60000, "digest-a");
+ assertTrue(bound.isBoundTo("digest-a"));
+ assertFalse(bound.isBoundTo("digest-b"));
+ assertFalse(bound.isBoundTo(null));
+
+ ShieldToken unbound = new ShieldToken("t", ShieldStatus.OK,
+ System.currentTimeMillis(), 60000, null);
+ assertTrue(unbound.isBoundTo(null));
+ assertFalse(unbound.isBoundTo("digest-a"));
+ }
+
+ @Test
+ void tokenToStringNeverRendersTheTokenValue() {
+ String s = new ShieldToken("super-secret-token-value", ShieldStatus.OK,
+ System.currentTimeMillis(), 60000, null).toString();
+ assertFalse(s.contains("super-secret-token-value"),
+ "token values must not be loggable via toString");
+ }
+
+ // --- HostPolicy / ShieldConfig --------------------------------------
+
+ @Test
+ void unregisteredHostsAreLeftCompletelyAlone() {
+ ShieldConfig c = new ShieldConfig().protect("api.example.com");
+ assertSame(HostPolicy.UNPROTECTED, c.policyFor("cdn.other.com"));
+ assertTrue(HostPolicy.UNPROTECTED.isNoOp());
+ assertSame(HostPolicy.UNPROTECTED, c.policyFor(null));
+ }
+
+ @Test
+ void wildcardCoversSubdomainsButNotTheApexOrSiblings() {
+ ShieldConfig c = new ShieldConfig().protect("*.example.com", HostPolicy.ENFORCED);
+ assertSame(HostPolicy.ENFORCED, c.policyFor("api.example.com"));
+ assertSame(HostPolicy.ENFORCED, c.policyFor("a.b.example.com"));
+ assertSame(HostPolicy.UNPROTECTED, c.policyFor("example.com"));
+ assertSame(HostPolicy.UNPROTECTED, c.policyFor("example.com.evil.test"));
+ }
+
+ @Test
+ void exactHostWinsOverWildcard() {
+ ShieldConfig c = new ShieldConfig()
+ .protect("*.example.com", HostPolicy.PROTECTED)
+ .protect("secure.example.com", HostPolicy.ENFORCED);
+ assertSame(HostPolicy.ENFORCED, c.policyFor("secure.example.com"));
+ assertSame(HostPolicy.PROTECTED, c.policyFor("other.example.com"));
+ }
+
+ @Test
+ void hostMatchingIsCaseInsensitive() {
+ ShieldConfig c = new ShieldConfig().protect("API.Example.COM");
+ assertSame(HostPolicy.PROTECTED, c.policyFor("api.example.com"));
+ }
+
+ @Test
+ void defaultsAreOpenAndNonBlocking() {
+ ShieldConfig c = new ShieldConfig();
+ assertEquals(FailureMode.OPEN, c.getDefaultFailureMode());
+ assertEquals(ShieldConfig.DEFAULT_TOKEN_HEADER, c.getTokenHeader());
+ assertFalse(c.hasProtectedHosts());
+ assertTrue(c.isCollectSignals());
+ assertEquals(FailureMode.OPEN, HostPolicy.PROTECTED.getFailureMode());
+ }
+
+ // --- PinSet ---------------------------------------------------------
+
+ private static PinSet pinSet(String host, String pin, long soft, long hard) {
+ Hashtable t = new Hashtable();
+ Vector v = new Vector();
+ v.addElement(pin);
+ t.put(host, v);
+ return new PinSet(t, 1, soft, hard);
+ }
+
+ /**
+ * The never-brick rule. An unpinned host must report a match, or every request
+ * to it would start failing the moment pinning was switched on anywhere.
+ */
+ @Test
+ void unpinnedHostAlwaysMatches() {
+ PinSet set = pinSet("api.example.com", "AAA", 0, 0);
+ assertTrue(set.matches("other.example.com", new String[] {"ZZZ"}));
+ assertTrue(PinSet.EMPTY.matches("api.example.com", new String[] {"ZZZ"}));
+ assertFalse(PinSet.EMPTY.isEnforcedFor("api.example.com"));
+ }
+
+ @Test
+ void pinnedHostMatchesAnywhereInTheChain() {
+ PinSet set = pinSet("api.example.com", "INTERMEDIATE", 0, 0);
+ assertTrue(set.matches("api.example.com",
+ new String[] {"LEAF", "INTERMEDIATE", "ROOT"}));
+ }
+
+ @Test
+ void pinnedHostWithNoMatchingChainEntryFails() {
+ PinSet set = pinSet("api.example.com", "AAA", 0, 0);
+ assertFalse(set.matches("api.example.com", new String[] {"BBB", "CCC"}));
+ assertFalse(set.matches("api.example.com", new String[0]));
+ assertFalse(set.matches("api.example.com", null));
+ }
+
+ /**
+ * A device that cannot reach the service for a long time must lose pinning
+ * rather than lose the app.
+ */
+ @Test
+ void hardExpiredPinSetStopsEnforcingEntirely() {
+ long past = System.currentTimeMillis() - 1000;
+ PinSet expired = pinSet("api.example.com", "AAA", past, past);
+ assertTrue(expired.isExpired());
+ assertFalse(expired.isEnforcedFor("api.example.com"));
+ assertTrue(expired.matches("api.example.com", new String[] {"WRONG"}));
+ }
+
+ @Test
+ void staleButNotExpiredPinSetKeepsEnforcing() {
+ long now = System.currentTimeMillis();
+ PinSet stale = pinSet("api.example.com", "AAA", now - 1000, now + 600000);
+ assertTrue(stale.isStale());
+ assertFalse(stale.isExpired());
+ assertTrue(stale.isEnforcedFor("api.example.com"));
+ assertFalse(stale.matches("api.example.com", new String[] {"WRONG"}));
+ }
+
+ @Test
+ void pinWildcardsFollowTheSameRulesAsHostPolicies() {
+ PinSet set = pinSet("*.example.com", "AAA", 0, 0);
+ assertTrue(set.isEnforcedFor("api.example.com"));
+ assertFalse(set.isEnforcedFor("example.com"));
+ assertNull(set.pinsFor("other.test"));
+ }
+
+ // --- ShieldSignals --------------------------------------------------
+
+ @Test
+ void repeatedSignalsOfTheSameKindCollapseInsteadOfAccumulating() {
+ ShieldSignals.clear();
+ for (int i = 0; i < 500; i++) {
+ ShieldSignals.add(ShieldSignal.HOOK, 90, "attempt " + i);
+ }
+ ShieldSignal[] snapshot = ShieldSignals.snapshot();
+ assertEquals(1, snapshot.length,
+ "a detector firing every frame must not be able to grow the bus");
+ assertEquals("attempt 499", snapshot[0].getDetail());
+ ShieldSignals.clear();
+ }
+
+ @Test
+ void signalBusIsBoundedAcrossDistinctIds() {
+ ShieldSignals.clear();
+ for (int i = 0; i < 200; i++) {
+ ShieldSignals.add("signal-" + i, 10, null);
+ }
+ assertTrue(ShieldSignals.snapshot().length <= 32);
+ ShieldSignals.clear();
+ }
+
+ @Test
+ void severityIsClampedToTheDocumentedRange() {
+ assertEquals(100, new ShieldSignal("x", 5000, null).getSeverity());
+ assertEquals(0, new ShieldSignal("x", -5, null).getSeverity());
+ }
+
+ @Test
+ void hasSignalAtLeastReflectsRecordedSeverities() {
+ ShieldSignals.clear();
+ assertFalse(ShieldSignals.hasSignalAtLeast(1));
+ ShieldSignals.add(ShieldSignal.EMULATOR, 30, null);
+ assertTrue(ShieldSignals.hasSignalAtLeast(30));
+ assertFalse(ShieldSignals.hasSignalAtLeast(31));
+ ShieldSignals.clear();
+ }
+}
From 19abeb5e33268c2827e5394ddb05338a7a46123a Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 10:39:13 +0300
Subject: [PATCH 02/96] Carry the shield status by id so ShieldException stays
serializable
SpotBugs SE_BAD_FIELD: IOException is serializable, so a non-serializable
ShieldStatus field on ShieldException is both a static-analysis error and a
latent null after a round trip -- which would break getStatus()'s never-null
contract at exactly the moment someone is trying to work out why a request
failed.
Stores the id instead. ShieldStatus.forId resolves it back to the canonical
constant, so identity comparisons and isTransient() still hold; tests pin that.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../security/shield/ShieldException.java | 14 +++++++++---
.../security/shield/ShieldApiTest.java | 22 +++++++++++++++++++
2 files changed, 33 insertions(+), 3 deletions(-)
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldException.java b/CodenameOne/src/com/codename1/security/shield/ShieldException.java
index a35b7dd3f2a..43070c41814 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldException.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldException.java
@@ -33,15 +33,23 @@
/// rejected", and those deserve very different UX.
public class ShieldException extends IOException {
- private final ShieldStatus status;
+ /// The status identifier rather than the [ShieldStatus] itself.
+ ///
+ /// `IOException` is serializable, and holding a non-serializable field on a
+ /// serializable class is both a static-analysis error and a latent null after a
+ /// round trip -- which would break [#getStatus()]'s never-null contract at exactly
+ /// the moment someone is trying to work out why a request failed. A `String`
+ /// survives serialization, and [ShieldStatus#forId(String)] resolves it back to the
+ /// canonical constant, so identity comparisons still hold.
+ private final String statusId;
public ShieldException(ShieldStatus status, String message) {
super(message);
- this.status = status == null ? ShieldStatus.NOT_INITIALIZED : status;
+ this.statusId = (status == null ? ShieldStatus.NOT_INITIALIZED : status).getId();
}
/// Why the operation failed. Never null.
public ShieldStatus getStatus() {
- return status;
+ return ShieldStatus.forId(statusId);
}
}
diff --git a/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
index a9994ff384b..2434736e3ad 100644
--- a/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
@@ -76,6 +76,28 @@ void unknownStatusIdRoundTripsAsANonSuccess() {
assertSame(ShieldStatus.NOT_INITIALIZED, ShieldStatus.forId(null));
}
+ /**
+ * ShieldException carries the status id rather than the object, because
+ * IOException is serializable. getStatus() must still hand back the
+ * canonical constant, or identity checks like isTransient() silently stop
+ * working.
+ */
+ @Test
+ void exceptionStatusResolvesBackToTheCanonicalConstant() {
+ ShieldException e = new ShieldException(ShieldStatus.RATE_LIMITED, "slow down");
+ assertSame(ShieldStatus.RATE_LIMITED, e.getStatus());
+ assertTrue(e.getStatus().isTransient());
+
+ ShieldException pin = new ShieldException(ShieldStatus.PIN_MISMATCH, "bad chain");
+ assertSame(ShieldStatus.PIN_MISMATCH, pin.getStatus());
+ assertFalse(pin.getStatus().isTransient());
+ }
+
+ @Test
+ void exceptionStatusIsNeverNull() {
+ assertSame(ShieldStatus.NOT_INITIALIZED, new ShieldException(null, "x").getStatus());
+ }
+
// --- ShieldToken ----------------------------------------------------
@Test
From 6dc3c5eacbae38643f63ffddb1b515b4e23da361 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 10:58:51 +0300
Subject: [PATCH 03/96] Fix App Attest error handling and guard ordering found
in review
DCError.invalidKey is 3, not 2 -- 2 is invalidInput. With the wrong constant an
invalidated key never entered the reset-and-reattest branch, so a device whose
key the OS had discarded would fail forever, while malformed input would
pointlessly burn a fresh hardware key. Verified against the DeviceCheck header.
Key generation is asynchronous, so holding the flow lock only until the native
call was issued did not serialize anything: a second caller still saw no key and
generated its own, spending a second key against Apple's per-device budget.
Callers arriving mid-bootstrap now queue and assert against the key the first
bootstrap establishes, which is unlimited. They are also released on failure --
otherwise they would wait forever.
The retry marker was dropped when key generation handed off to attestation, so a
recovery whose replacement key also reported invalidKey would recover again
rather than surface the failure.
The network guard ran before the offline-cache check, so a fail-closed guard
could fail a request that needed no network at all by being unable to fetch a
token while offline. Moved after the cache hit.
Also: null-guard parseGroupedCertificates, since a port returning null would
surface as an NPE on the network path rather than as an empty chain; reset
secureScreen in the simulator's reset; and release the CFDataRef in
getFingerprint/getFingerprint256, which leaked one certificate's worth of data
per digest on every connection.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/io/ConnectionRequest.java | 28 ++++---
.../codename1/impl/javase/JavaSEShield.java | 1 +
.../nativeSources/NetworkConnectionImpl.m | 21 +++--
.../impl/ios/IOSDeviceIntegrity.java | 77 +++++++++++++++++--
4 files changed, 106 insertions(+), 21 deletions(-)
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index 5559f46c1bd..1e2456cb253 100644
--- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java
+++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
@@ -954,15 +954,6 @@ boolean performOperationComplete() throws IOException {
return true;
}
pinFailure = null;
- NetworkGuard requestGuard = NetworkManager.getNetworkGuard();
- if (requestGuard != null) {
- // Deliberately here rather than in initConnection(): that method is
- // protected, and a subclass that overrides it without calling super
- // would silently lose the decoration. Also deliberately not in
- // NetworkThread.prepare(), which runs inside the queue lock where a
- // blocking token fetch would stall every other request.
- requestGuard.beforeRequest(this);
- }
if (cacheMode == CachingMode.OFFLINE || cacheMode == CachingMode.OFFLINE_FIRST) {
InputStream is = null; //NOPMD CloseResource
try {
@@ -980,6 +971,20 @@ boolean performOperationComplete() throws IOException {
Util.cleanup(is);
}
}
+ NetworkGuard requestGuard = NetworkManager.getNetworkGuard();
+ if (requestGuard != null) {
+ // After the offline-cache check: a cache hit needs no network, so a
+ // fail-closed guard must not get the chance to block it by failing to
+ // fetch a token on a device that is offline.
+ //
+ // Deliberately here rather than in initConnection(): that method is
+ // protected, and a subclass that overrides it without calling super
+ // would silently lose the decoration. Also deliberately not in
+ // NetworkThread.prepare(), which runs inside the queue lock where a
+ // blocking token fetch would stall every other request.
+ requestGuard.beforeRequest(this);
+ }
+
CodenameOneImplementation impl = Util.getImplementation();
Object connection = null;
input = null;
@@ -1634,6 +1639,11 @@ public SSLCertificate[] getSSLCertificates() throws IOException {
/// delimiter starting each certificate's group. Anything before the first delimiter is treated
/// as the leaf, so a port that reports digests without grouping still yields usable data.
static SSLCertificate[] parseGroupedCertificates(String[] entries) {
+ if (entries == null) {
+ // A port that returns null here would otherwise surface as an NPE on
+ // the network path, i.e. as an unrelated connection failure.
+ return new SSLCertificate[0];
+ }
java.util.Vector out = new java.util.Vector();
SSLCertificate current = null;
int index = 0;
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShield.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShield.java
index 5b1b35788f2..dc384fc8780 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShield.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShield.java
@@ -144,6 +144,7 @@ public static void reset() {
serveExpiredToken = false;
forcePinMismatch = false;
failPinFetch = false;
+ secureScreen = false;
}
/** A human-readable dump for the menu's status dialog. */
diff --git a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
index d2bbba98741..83d1d79e9a6 100644
--- a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
+++ b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
@@ -205,9 +205,15 @@ -(void)setConnectionId:(JAVA_INT)connId {
}
- (NSString*) getFingerprint: (SecCertificateRef) cert {
- NSData* certData = (__bridge NSData*) SecCertificateCopyData(cert);
+ // SecCertificateCopyData follows the Copy rule, so the result is owned here.
+ // The bridged cast alone leaked it on every handshake.
+ CFDataRef certData = SecCertificateCopyData(cert);
+ if (certData == NULL) {
+ return @"";
+ }
unsigned char sha1Bytes[CC_SHA1_DIGEST_LENGTH];
- CC_SHA1(certData.bytes, (int)certData.length, sha1Bytes);
+ CC_SHA1(CFDataGetBytePtr(certData), (CC_LONG)CFDataGetLength(certData), sha1Bytes);
+ CFRelease(certData);
NSMutableString *fingerprint = [NSMutableString stringWithCapacity:CC_SHA1_DIGEST_LENGTH * 3];
for (int i = 0; i < CC_SHA1_DIGEST_LENGTH; ++i) {
[fingerprint appendFormat:@"%02x ", sha1Bytes[i]];
@@ -320,10 +326,15 @@ - (NSString*) getPublicKeyDigest: (SecCertificateRef) cert {
}
- (NSString*) getFingerprint256: (SecCertificateRef) cert {
- NSData* keyData = (__bridge NSData*) SecCertificateCopyData(cert);
-
+ // Same ownership rule as getFingerprint: this was leaking one certificate's
+ // worth of data per digest, on every connection.
+ CFDataRef keyData = SecCertificateCopyData(cert);
+ if (keyData == NULL) {
+ return @"";
+ }
uint8_t digest[CC_SHA256_DIGEST_LENGTH]={0};
- CC_SHA256(keyData.bytes, keyData.length, digest);
+ CC_SHA256(CFDataGetBytePtr(keyData), (CC_LONG)CFDataGetLength(keyData), digest);
+ CFRelease(keyData);
NSData *out=[NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH];
NSString *hash=[out description];
hash = [hash stringByReplacingOccurrencesOfString:@" " withString:@""];
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 89b836c70b2..777a4475dc3 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -93,8 +93,14 @@ final class IOSDeviceIntegrity {
private static final String STATE_NEW = "new";
private static final String STATE_ATTESTED = "attested";
- /** DCError.invalidKey -- the server or the OS no longer knows this key. */
- private static final int DC_ERROR_INVALID_KEY = 2;
+ // Ordinals from DeviceCheck's DCError enum, which declares no explicit
+ // values: unknownSystemFailure, featureUnsupported, invalidInput,
+ // invalidKey, serverUnavailable. Getting invalidKey wrong is quiet and
+ // expensive -- an invalidated key would never enter the reset-and-reattest
+ // branch, so the device would fail forever while malformed input would
+ // pointlessly burn a fresh key.
+ /** DCError.invalidKey -- the OS no longer recognises this key. */
+ private static final int DC_ERROR_INVALID_KEY = 3;
/** DCError.serverUnavailable -- Apple is throttling or unreachable. */
private static final int DC_ERROR_SERVER_UNAVAILABLE = 4;
@@ -111,6 +117,10 @@ final class IOSDeviceIntegrity {
/** Guards the whole attest flow so concurrent callers cannot each burn a key. */
private final Object flowLock = new Object();
private long currentBackoff = MIN_BACKOFF_MILLIS;
+ /** True while a generate-then-attest bootstrap is running. */
+ private boolean bootstrapInFlight;
+ /** Callers that arrived mid-bootstrap and will assert once it completes. */
+ private final java.util.Vector waitingForBootstrap = new java.util.Vector();
IOSDeviceIntegrity(IOSNative nativeInstance) {
this.nativeInstance = nativeInstance;
@@ -133,6 +143,8 @@ void resetAttestation() {
store.remove(KEY_RETRY_AFTER);
synchronized (flowLock) {
currentBackoff = MIN_BACKOFF_MILLIS;
+ bootstrapInFlight = false;
+ failBootstrapWaiters("App Attest state was reset while a bootstrap was in flight");
}
}
@@ -172,8 +184,20 @@ AsyncResource requestToken(String nonce) {
SecureStorage store = SecureStorage.getInstance();
String keyId = store.get(KEY_ID);
if (keyId == null || keyId.length() == 0) {
- // No key yet: generate one, then continue into attestation.
- int rid = register(new PendingRequest(r, nonce, PendingRequest.OP_GENERATE_KEY, null));
+ PendingRequest pending =
+ new PendingRequest(r, nonce, PendingRequest.OP_GENERATE_KEY, null);
+ if (bootstrapInFlight) {
+ // Key generation is asynchronous, so holding the lock only
+ // until the native call is issued is not enough: a second
+ // caller would still see no key and generate its own,
+ // burning a second hardware key against Apple's per-device
+ // budget. Queue instead, and assert once the first bootstrap
+ // lands -- assertions are unlimited.
+ waitingForBootstrap.addElement(pending);
+ return r;
+ }
+ bootstrapInFlight = true;
+ int rid = register(pending);
nativeInstance.appAttestGenerateKey(rid);
return r;
}
@@ -189,10 +213,16 @@ AsyncResource requestToken(String nonce) {
// --- flow steps ------------------------------------------------------
private void attestKey(AsyncResource r, String nonce, String keyId) {
+ attestKey(r, nonce, keyId, false);
+ }
+
+ private void attestKey(AsyncResource r, String nonce, String keyId, boolean retried) {
// The attestation binds to SHA-256 of the raw nonce. Hashing here rather
// than natively keeps a single hash implementation across both paths.
String hash = base64(Hash.sha256(bytes(nonce)));
- int rid = register(new PendingRequest(r, nonce, PendingRequest.OP_ATTEST, keyId));
+ PendingRequest pending = new PendingRequest(r, nonce, PendingRequest.OP_ATTEST, keyId);
+ pending.retried = retried;
+ int rid = register(pending);
nativeInstance.appAttestAttestKey(rid, keyId, hash);
}
@@ -246,7 +276,10 @@ public static void nativeKeyGenerated(final int requestId, final String keyId) {
store.set(KEY_ID, keyId);
store.set(KEY_STATE, STATE_NEW);
synchronized (instance.flowLock) {
- instance.attestKey(pending.result, pending.nonce, keyId);
+ // Carry the marker forward: without it a recovery attempt whose
+ // replacement key also reports invalidKey would recover again, and
+ // again, instead of surfacing the failure.
+ instance.attestKey(pending.result, pending.nonce, keyId, pending.retried);
}
}
@@ -268,6 +301,11 @@ public static void nativeAttestationReady(final int requestId, final String atte
if (instance != null) {
synchronized (instance.flowLock) {
instance.currentBackoff = MIN_BACKOFF_MILLIS;
+ instance.bootstrapInFlight = false;
+ // Callers that arrived mid-bootstrap now assert against the key
+ // this attestation established, rather than each attesting one
+ // of their own.
+ instance.drainBootstrapWaiters(pending.keyId);
}
}
succeed(pending, TOKEN_PREFIX + ":attest:" + base64(bytes(pending.keyId))
@@ -319,7 +357,32 @@ public static void nativeAttestError(final int requestId, final int errorCode,
instance.currentBackoff = Math.min(backoff * 2, MAX_BACKOFF_MILLIS);
}
}
- fail(pending, msg == null ? "App Attest failed (code " + errorCode + ")" : msg);
+ String message = msg == null ? "App Attest failed (code " + errorCode + ")" : msg;
+ if (instance != null && pending.op != PendingRequest.OP_ASSERT) {
+ synchronized (instance.flowLock) {
+ instance.bootstrapInFlight = false;
+ instance.failBootstrapWaiters(message);
+ }
+ }
+ fail(pending, message);
+ }
+
+ /** Assert for everyone who queued behind the bootstrap. Caller holds flowLock. */
+ private void drainBootstrapWaiters(String keyId) {
+ while (!waitingForBootstrap.isEmpty()) {
+ PendingRequest waiting = (PendingRequest) waitingForBootstrap.elementAt(0);
+ waitingForBootstrap.removeElementAt(0);
+ assertWithKey(waiting.result, waiting.nonce, keyId);
+ }
+ }
+
+ /** Caller holds flowLock. Leaving these unresolved would hang the callers. */
+ private void failBootstrapWaiters(String message) {
+ while (!waitingForBootstrap.isEmpty()) {
+ PendingRequest waiting = (PendingRequest) waitingForBootstrap.elementAt(0);
+ waitingForBootstrap.removeElementAt(0);
+ fail(waiting, message);
+ }
}
// --- helpers ---------------------------------------------------------
From f2e6472ccf943140c2ed85554fce5f6ce3ad48ba Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 11:03:33 +0300
Subject: [PATCH 04/96] Build the shield simulation menu without adding it,
like its siblings
installShieldSimulationMenu added the menu to simulateMenu and also returned it,
while installNfcSimulationMenu and installFoldableSimulationMenu only return.
The menu was not actually added twice -- simulateMenu.removeAll() runs between
the two calls, in the block that rebuilds the final menu order -- but the
inconsistency made that non-obvious enough to read as a bug.
Co-Authored-By: Claude Opus 5 (1M context)
---
Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index 33fd9ac3787..33fb9215a19 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -8452,7 +8452,9 @@ public void actionPerformed(ActionEvent ae) {
});
shieldMenu.add(status);
- simulateMenu.add(shieldMenu);
+ // Returned rather than added here, matching installNfcSimulationMenu and
+ // installFoldableSimulationMenu. The caller does the adding, in the
+ // removeAll-then-rebuild block that assembles the final menu order.
return shieldMenu;
}
From 41c16d6f5a6e8b43770782cbb875a674be1e889d Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 11:22:39 +0300
Subject: [PATCH 05/96] Honour the configured failure mode, refresh the chain,
and measure time monotonically
Four more from review.
defaultFailureMode did nothing for hosts registered the short way. protect(host)
stored the HostPolicy.PROTECTED constant, whose mode is always OPEN, so an app
that asked to fail closed still sent requests without a token. The implicit
policy is now built from the configured default. An explicitly supplied policy
still wins.
The certificate chain is cached on the request and survives retries and
redirects, so the guard was vetting the previous connection's certificates: a
request that first connected to a pinned host could then accept an unpinned
certificate on a retry, and a redirect between differently pinned hosts could
fail for no reason. Cleared at the start of each attempt.
Token lifetime used System.currentTimeMillis(), which is the wall clock and
therefore adjustable -- on a rooted device, by the attacker. That contradicted
the class's own documented guarantee. Elapsed time now comes from
System.nanoTime(); fetchedAt is kept for log correlation and is exposed and
rendered rather than sitting unread.
A null status defaulted to OK, so an engine bug could produce a token that
reported itself valid and got attached to requests. It now defaults to a
non-success status.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/io/ConnectionRequest.java | 6 ++
.../security/shield/ShieldConfig.java | 21 +++++-
.../security/shield/ShieldToken.java | 35 ++++++++-
.../security/shield/ShieldApiTest.java | 75 +++++++++++++++++--
4 files changed, 124 insertions(+), 13 deletions(-)
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index 1e2456cb253..9cc7abef61e 100644
--- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java
+++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
@@ -954,6 +954,12 @@ boolean performOperationComplete() throws IOException {
return true;
}
pinFailure = null;
+ // Each attempt gets its own chain. This field is populated lazily and
+ // survives retries and redirects, so without clearing it the guard would
+ // vet the previous connection's certificates -- accepting an unpinned
+ // certificate on a retried request, or rejecting a redirect to a
+ // differently pinned host.
+ sslCertificates = null;
if (cacheMode == CachingMode.OFFLINE || cacheMode == CachingMode.OFFLINE_FIRST) {
InputStream is = null; //NOPMD CloseResource
try {
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java b/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
index 3060313e787..e1ed7b6368d 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
@@ -101,14 +101,29 @@ public ShieldConfig collectSignals(boolean collect) {
public ShieldConfig protect(String hostPattern, HostPolicy policy) {
if (hostPattern != null && hostPattern.length() > 0) {
hostPolicies.put(hostPattern.toLowerCase(),
- policy == null ? HostPolicy.PROTECTED : policy);
+ policy == null ? implicitPolicy() : policy);
}
return this;
}
- /// Registers a host with [HostPolicy#PROTECTED].
+ /// Registers a host with the default policy, which honours
+ /// [#defaultFailureMode(FailureMode)].
public ShieldConfig protect(String hostPattern) {
- return protect(hostPattern, HostPolicy.PROTECTED);
+ return protect(hostPattern, implicitPolicy());
+ }
+
+ /// The policy used when a host is registered without an explicit one.
+ ///
+ /// Built from [#defaultFailureMode(FailureMode)] rather than returning the
+ /// [HostPolicy#PROTECTED] constant, whose mode is always
+ /// [FailureMode#OPEN] -- otherwise setting a fail-closed default would
+ /// silently do nothing for every host registered the short way, which is
+ /// most of them.
+ private HostPolicy implicitPolicy() {
+ if (defaultFailureMode == FailureMode.OPEN) {
+ return HostPolicy.PROTECTED;
+ }
+ return new HostPolicy(true, true, defaultFailureMode);
}
public String getEndpoint() {
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldToken.java b/CodenameOne/src/com/codename1/security/shield/ShieldToken.java
index 43c9ee6d5f8..e6cdfa9ba45 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldToken.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldToken.java
@@ -44,13 +44,31 @@ public final class ShieldToken {
private final long ttlMillis;
private final String binding;
+ /// Monotonic reference captured at construction. `System.currentTimeMillis()`
+ /// is the wall clock: it can be stepped backwards or forwards, by the user or
+ /// by NTP, which would keep a lapsed token looking valid or expire a good one
+ /// early. Elapsed time is measured from here instead; `fetchedAt` is retained
+ /// only because it is meaningful to a human reading a log.
+ private final long fetchedNanos;
+
public ShieldToken(String value, ShieldStatus status, long fetchedAt,
long ttlMillis, String binding) {
+ this(value, status, fetchedAt, ttlMillis, binding, System.nanoTime());
+ }
+
+ /// Test seam: lets a test place the token at a chosen point in its lifetime.
+ /// Not public, because an app supplying its own reference could only make a
+ /// lapsed token look fresh.
+ ShieldToken(String value, ShieldStatus status, long fetchedAt,
+ long ttlMillis, String binding, long fetchedNanos) {
this.value = value;
- this.status = status == null ? ShieldStatus.OK : status;
+ // A missing status is an engine bug, not a success. Defaulting to OK
+ // would make isValid() true and attach a token nobody vouched for.
+ this.status = status == null ? ShieldStatus.NOT_INITIALIZED : status;
this.fetchedAt = fetchedAt;
this.ttlMillis = ttlMillis;
this.binding = binding;
+ this.fetchedNanos = fetchedNanos;
}
/// The opaque token to place in the request header. May be null when [#getStatus()] is not
@@ -66,7 +84,8 @@ public ShieldStatus getStatus() {
/// Milliseconds until this token stops being worth sending, or 0 once it has lapsed.
public long getMillisUntilExpiry() {
- long remaining = (fetchedAt + ttlMillis) - System.currentTimeMillis();
+ long elapsed = (System.nanoTime() - fetchedNanos) / 1000000L;
+ long remaining = ttlMillis - elapsed;
return remaining > 0 ? remaining : 0;
}
@@ -81,7 +100,7 @@ public boolean shouldRefresh(int thresholdPercent) {
if (ttlMillis <= 0) {
return true;
}
- long used = System.currentTimeMillis() - fetchedAt;
+ long used = (System.nanoTime() - fetchedNanos) / 1000000L;
return used * 100 >= ttlMillis * thresholdPercent;
}
@@ -100,9 +119,19 @@ public boolean isBoundTo(String data) {
return binding.equals(data);
}
+ /// When this token was fetched, in wall-clock time.
+ ///
+ /// For correlating a client log with a server log, and nothing else --
+ /// lifetime decisions use the monotonic reference instead, for the reasons
+ /// in the class documentation.
+ public long getFetchedAt() {
+ return fetchedAt;
+ }
+
/// Never renders the token value -- these strings end up in logs.
public String toString() {
return "ShieldToken[status=" + status.getId()
+ + ", fetchedAt=" + fetchedAt
+ ", validMs=" + getMillisUntilExpiry()
+ ", bound=" + (binding != null) + "]";
}
diff --git a/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
index 2434736e3ad..d31b83fe30d 100644
--- a/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
@@ -106,25 +106,54 @@ void tokenIsValidOnlyWhileItHasValueSuccessAndTime() {
assertTrue(new ShieldToken("t", ShieldStatus.OK, now, 60000, null).isValid());
assertFalse(new ShieldToken(null, ShieldStatus.OK, now, 60000, null).isValid());
assertFalse(new ShieldToken("t", ShieldStatus.REJECTED, now, 60000, null).isValid());
- assertFalse(new ShieldToken("t", ShieldStatus.OK, now - 120000, 60000, null).isValid());
+ assertFalse(new ShieldToken("t", ShieldStatus.OK, now, 60000, null,
+ System.nanoTime() - 120_000_000_000L).isValid());
}
@Test
void expiredTokenReportsZeroRatherThanNegativeRemainingTime() {
- ShieldToken t = new ShieldToken("t", ShieldStatus.OK,
- System.currentTimeMillis() - 120000, 60000, null);
+ ShieldToken t = new ShieldToken("t", ShieldStatus.OK, System.currentTimeMillis(),
+ 60000, null, System.nanoTime() - 120_000_000_000L);
assertEquals(0, t.getMillisUntilExpiry());
}
+ /**
+ * Lifetime is measured monotonically, so moving the wall clock cannot make a
+ * lapsed token look fresh -- which on a rooted device is something the
+ * attacker can simply do.
+ */
+ @Test
+ void lifetimeIgnoresTheWallClock() {
+ // fetchedAt is deliberately far in the future; only the monotonic
+ // reference should matter.
+ ShieldToken t = new ShieldToken("t", ShieldStatus.OK,
+ System.currentTimeMillis() + 3_600_000L, 60000, null, System.nanoTime());
+ assertTrue(t.isValid());
+ assertTrue(t.getMillisUntilExpiry() > 0);
+
+ ShieldToken lapsed = new ShieldToken("t", ShieldStatus.OK,
+ System.currentTimeMillis() + 3_600_000L, 60000, null,
+ System.nanoTime() - 120_000_000_000L);
+ assertFalse(lapsed.isValid());
+ }
+
+ /** A null status is an engine bug; treating it as OK would attach an unvouched token. */
+ @Test
+ void aMissingStatusIsNotTreatedAsSuccess() {
+ ShieldToken t = new ShieldToken("t", null, System.currentTimeMillis(), 60000, null);
+ assertFalse(t.getStatus().isSuccess());
+ assertFalse(t.isValid());
+ }
+
@Test
void refreshTriggersOnceThresholdShareOfLifetimeIsUsed() {
long now = System.currentTimeMillis();
// 10% used, 50% threshold -> no refresh yet.
- assertFalse(new ShieldToken("t", ShieldStatus.OK, now - 1000, 10000, null)
- .shouldRefresh(50));
+ assertFalse(new ShieldToken("t", ShieldStatus.OK, now, 10000, null,
+ System.nanoTime() - 1_000_000_000L).shouldRefresh(50));
// 60% used -> refresh.
- assertTrue(new ShieldToken("t", ShieldStatus.OK, now - 6000, 10000, null)
- .shouldRefresh(50));
+ assertTrue(new ShieldToken("t", ShieldStatus.OK, now, 10000, null,
+ System.nanoTime() - 6_000_000_000L).shouldRefresh(50));
}
@Test
@@ -183,6 +212,38 @@ void hostMatchingIsCaseInsensitive() {
assertSame(HostPolicy.PROTECTED, c.policyFor("api.example.com"));
}
+ /**
+ * defaultFailureMode has to reach hosts registered the short way, which is
+ * most of them -- otherwise setting a fail-closed default silently does
+ * nothing, which is the worst possible outcome for a security setting.
+ */
+ @Test
+ void defaultFailureModeAppliesToHostsRegisteredWithoutAnExplicitPolicy() {
+ ShieldConfig c = new ShieldConfig()
+ .defaultFailureMode(FailureMode.CLOSED)
+ .protect("api.example.com");
+ HostPolicy p = c.policyFor("api.example.com");
+ assertEquals(FailureMode.CLOSED, p.getFailureMode());
+ assertTrue(p.isAttachToken());
+ assertTrue(p.isEnforcePins());
+ }
+
+ @Test
+ void anExplicitPolicyStillWinsOverTheDefaultFailureMode() {
+ ShieldConfig c = new ShieldConfig()
+ .defaultFailureMode(FailureMode.CLOSED)
+ .protect("api.example.com", HostPolicy.PROTECTED);
+ assertEquals(FailureMode.OPEN, c.policyFor("api.example.com").getFailureMode());
+ }
+
+ @Test
+ void aNullPolicyAlsoPicksUpTheDefaultFailureMode() {
+ ShieldConfig c = new ShieldConfig()
+ .defaultFailureMode(FailureMode.CLOSED)
+ .protect("api.example.com", null);
+ assertEquals(FailureMode.CLOSED, c.policyFor("api.example.com").getFailureMode());
+ }
+
@Test
void defaultsAreOpenAndNonBlocking() {
ShieldConfig c = new ShieldConfig();
From b4d167dd92a068db1ecbf6b76bb9c734bd217687 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 11:40:57 +0300
Subject: [PATCH 06/96] Stop the shield header following cross-host redirects
Three more from review.
A redirect reuses the same request object with its headers intact, and attach()
only ever added. A protected endpoint with an open redirect would therefore hand
a replayable attestation token to whatever host it pointed at. The header is now
cleared before the policy for the current host is evaluated, and re-added only
if that host is protected. Adds ConnectionRequest.removeRequestHeader, since
there was no way to remove a header at all.
The earlier bootstrap fix left the STATE_NEW window open: between key generation
persisting the identifier and its attestation completing, the key exists but is
not attested, so a caller arriving then bypassed the bootstrapInFlight check and
attested the same key again. Attestation is rate limited, so that costs real
budget and races its own result. The check now happens before branching on key
state, and a key that exists but was never attested is attested rather than
replaced.
iOS getCompromiseReasons dropped the Cydia probe, which for apps that declare
the scheme was the only signal on a device where the file, dyld, write and
tracing probes come back clean -- a regression against the previous behaviour.
It is consulted again, through a helper rather than isJailbrokenDevice(), which
now delegates the other way.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/io/ConnectionRequest.java | 11 ++++++
.../codename1/security/shield/AppShield.java | 5 +++
.../impl/ios/IOSDeviceIntegrity.java | 26 +++++++++-----
.../codename1/impl/ios/IOSImplementation.java | 36 +++++++++++++------
4 files changed, 59 insertions(+), 19 deletions(-)
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index 9cc7abef61e..d2b009a0a79 100644
--- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java
+++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
@@ -651,6 +651,17 @@ public void addRequestHeader(String key, String value) {
}
}
+ /// Removes a header previously added with [#addRequestHeader(String, String)].
+ ///
+ /// Needed because a redirect reuses the same request object with the same
+ /// headers: anything scoped to the original host has to be removable before
+ /// the retry, or it follows the redirect to wherever it points.
+ public void removeRequestHeader(String key) {
+ if (userHeaders != null && key != null) {
+ userHeaders.remove(key);
+ }
+ }
+
/// Adds the given header to the request that will be sent unless the header
/// is already set to something else
///
diff --git a/CodenameOne/src/com/codename1/security/shield/AppShield.java b/CodenameOne/src/com/codename1/security/shield/AppShield.java
index 9bde5485ff9..0b2beddef86 100644
--- a/CodenameOne/src/com/codename1/security/shield/AppShield.java
+++ b/CodenameOne/src/com/codename1/security/shield/AppShield.java
@@ -201,6 +201,11 @@ public static void attach(ConnectionRequest request) throws ShieldException {
}
String host = hostOf(request.getUrl());
HostPolicy policy = policyFor(host);
+ // Always clear first. A redirect reuses this request object with its
+ // headers intact, so a protected endpoint with an open redirect would
+ // otherwise hand a replayable token to whatever host it points at.
+ // Re-adding below is conditional on the *current* host's policy.
+ request.removeRequestHeader(getConfig().getTokenHeader());
if (!policy.isAttachToken()) {
return;
}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 777a4475dc3..9d7248d8913 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -183,7 +183,14 @@ AsyncResource requestToken(String nonce) {
}
SecureStorage store = SecureStorage.getInstance();
String keyId = store.get(KEY_ID);
- if (keyId == null || keyId.length() == 0) {
+ boolean attested = STATE_ATTESTED.equals(store.get(KEY_STATE));
+ if (keyId == null || keyId.length() == 0 || !attested) {
+ // Checked before branching on the key state, not after: between
+ // key generation persisting the id and its attestation
+ // completing, the key exists but is not yet attested, and a
+ // caller arriving in that window would otherwise attest the same
+ // key a second time. Attestation is rate limited, so that costs
+ // real budget and races its own result.
PendingRequest pending =
new PendingRequest(r, nonce, PendingRequest.OP_GENERATE_KEY, null);
if (bootstrapInFlight) {
@@ -197,15 +204,18 @@ AsyncResource requestToken(String nonce) {
return r;
}
bootstrapInFlight = true;
- int rid = register(pending);
- nativeInstance.appAttestGenerateKey(rid);
+ if (keyId != null && keyId.length() > 0) {
+ // A key exists but was never attested -- a previous attempt
+ // died between the two steps. Attest that key rather than
+ // generating another.
+ attestKey(r, nonce, keyId);
+ } else {
+ int rid = register(pending);
+ nativeInstance.appAttestGenerateKey(rid);
+ }
return r;
}
- if (STATE_ATTESTED.equals(store.get(KEY_STATE))) {
- assertWithKey(r, nonce, keyId);
- } else {
- attestKey(r, nonce, keyId);
- }
+ assertWithKey(r, nonce, keyId);
}
return r;
}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
index b990a7ead15..f96f3a2e6fa 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
@@ -12561,15 +12561,23 @@ public boolean isPolygon() {
@Override
public boolean isJailbrokenDevice() {
- if (getCompromiseReasons().length > 0) {
- return true;
+ return getCompromiseReasons().length > 0;
+ }
+
+ /**
+ * The legacy probe. Only ever returns true when the app also declares cydia
+ * in ios.applicationQueriesSchemes -- on iOS 9 and up canOpenURL returns
+ * false for undeclared schemes regardless of what is installed. That is why
+ * it cannot be the primary signal, but apps that do declare it would
+ * regress if it were dropped.
+ */
+ private boolean cydiaProbe() {
+ try {
+ Boolean b = canExecute("cydia://package/com.example.package");
+ return b != null && b.booleanValue();
+ } catch (Throwable t) {
+ return false;
}
- // Kept as a last resort, but note it only ever returns true when the app
- // also declares cydia in ios.applicationQueriesSchemes -- on iOS 9 and up
- // canOpenURL returns false for undeclared schemes regardless of what is
- // installed. That is why it cannot be the primary signal.
- Boolean b = canExecute("cydia://package/com.example.package");
- return b != null && b.booleanValue();
}
/**
@@ -12580,11 +12588,17 @@ public boolean isJailbrokenDevice() {
@Override
public String[] getCompromiseReasons() {
String[] signals = deviceIntegrity().jailbreakSignals();
- if (signals.length == 0) {
- return signals;
- }
java.util.ArrayList out = new java.util.ArrayList();
boolean jailbreakReported = false;
+ if (signals.length == 0) {
+ // The native probes found nothing, but an app that declares the
+ // cydia scheme can still detect one this way. Dropping it here would
+ // regress isDeviceCompromised() for exactly those apps.
+ if (cydiaProbe()) {
+ out.add("jailbreak");
+ }
+ return out.toArray(new String[out.size()]);
+ }
for (int i = 0; i < signals.length; i++) {
String s = signals[i];
if ("hookLib".equals(s) || "dyldInsert".equals(s)) {
From cf0a137da354666fa8ca3c687595dda0e1875d47 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 11:53:30 +0300
Subject: [PATCH 07/96] Install the network guard, without which protect() did
nothing
AppShield.init() never installed a NetworkGuard, so nothing consulted the host
policies at all: ShieldConfig.protect() configured tokens and pinning that were
only ever applied if an app called AppShield.attach() by hand. That is the
documented core behaviour of the API, and it was dead code.
Adds ShieldNetworkGuard, installed during init. It decorates protected hosts,
asks for the richer certificate details only when a pin set actually covers the
host, enforces pins through the engine, and drops the cached token when a
protected host answers 401 or 403 -- which usually means it refused the token,
so replaying it would fail again.
Installed even with no engine present. The default engine issues no tokens and
enforces no pins, so the guard is inert, and installing unconditionally keeps
behaviour identical whether or not the enterprise engine was injected. An app
that installed its own guard keeps it and gets a log line saying so, since the
consequence is worth knowing.
Also: invalid-key recovery started a replacement key generation without marking
the bootstrap in flight, because resetAttestation() had just cleared the flag. A
request arriving before the recovery callback would see no key and no bootstrap
running, generate a second rate-limited key, and race the recovery.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../codename1/security/shield/AppShield.java | 25 +++++
.../security/shield/ShieldNetworkGuard.java | 102 ++++++++++++++++++
.../impl/ios/IOSDeviceIntegrity.java | 5 +
3 files changed, 132 insertions(+)
create mode 100644 CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
diff --git a/CodenameOne/src/com/codename1/security/shield/AppShield.java b/CodenameOne/src/com/codename1/security/shield/AppShield.java
index 0b2beddef86..e0a9a93419a 100644
--- a/CodenameOne/src/com/codename1/security/shield/AppShield.java
+++ b/CodenameOne/src/com/codename1/security/shield/AppShield.java
@@ -24,6 +24,7 @@
import com.codename1.io.ConnectionRequest;
import com.codename1.io.Log;
+import com.codename1.io.NetworkManager;
import com.codename1.security.shield.spi.ShieldEngine;
import com.codename1.security.shield.spi.ShieldEngineRegistry;
import com.codename1.ui.Display;
@@ -108,6 +109,30 @@ public static void init(ShieldConfig cfg) {
Log.e(t);
setStatus(ShieldStatus.UNPROTECTED);
}
+ installNetworkGuard();
+ }
+
+ /// Hooks the shield into the network stack, which is what makes
+ /// [ShieldConfig#protect(String, HostPolicy)] take effect on ordinary requests. Without it a
+ /// registered host would carry a policy nothing consults.
+ ///
+ /// Installed even when no engine is present: the guard is inert in that case (the default
+ /// engine issues no tokens and enforces no pins) and installing unconditionally keeps the
+ /// behaviour identical whether or not the enterprise engine was injected.
+ private static void installNetworkGuard() {
+ try {
+ NetworkManager.setNetworkGuard(new ShieldNetworkGuard());
+ } catch (IllegalStateException e) {
+ // The slot seals after the first install. An app that installed its
+ // own guard keeps it; say so rather than failing startup, because the
+ // consequence is that protected hosts are not decorated automatically
+ // and that is worth knowing about.
+ Log.p("AppShield: a network guard is already installed, so protected hosts will "
+ + "not be decorated automatically. Call AppShield.attach(request) from "
+ + "your own guard if you need both.");
+ } catch (Throwable t) {
+ Log.e(t);
+ }
}
/// True when a real attestation engine is present and available. False in an open-source or
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java b/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
new file mode 100644
index 00000000000..f382545c004
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
@@ -0,0 +1,102 @@
+/*
+ * 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.shield;
+
+import com.codename1.io.ConnectionRequest;
+import com.codename1.io.Log;
+import com.codename1.io.NetworkGuard;
+import com.codename1.security.shield.spi.ShieldEngineRegistry;
+import java.io.IOException;
+
+/// Connects [AppShield] to the network stack.
+///
+/// This is what makes [ShieldConfig#protect(String, HostPolicy)] mean anything: without a guard
+/// installed, registering a host would configure a policy that nothing ever consults, and tokens
+/// would only ever be attached by an app calling [AppShield#attach(ConnectionRequest)] by hand.
+/// Installed once from [AppShield#init(ShieldConfig)].
+///
+/// Package-private: apps configure behaviour through [ShieldConfig], and an app-supplied guard
+/// could only weaken this one.
+final class ShieldNetworkGuard implements NetworkGuard {
+
+ public void beforeRequest(ConnectionRequest request) throws IOException {
+ // Also clears the header when the host is not protected, which is what
+ // stops a token following a cross-host redirect.
+ AppShield.attach(request);
+ }
+
+ public boolean isCertificateCheckRequired(String url) {
+ String host = AppShield.hostOf(url);
+ if (host == null || !AppShield.policyFor(host).isEnforcePins()) {
+ return false;
+ }
+ // Only ask for the richer certificate details when a pin set actually
+ // covers this host -- collecting them has a per-connection cost, and an
+ // unpinned host must be left completely alone.
+ return AppShield.getPinSet().isEnforcedFor(host);
+ }
+
+ public void checkCertificates(ConnectionRequest request,
+ ConnectionRequest.SSLCertificate[] certificates) throws IOException {
+ String host = AppShield.hostOf(request.getUrl());
+ if (host == null || !AppShield.policyFor(host).isEnforcePins()) {
+ return;
+ }
+ String[] spki = new String[certificates == null ? 0 : certificates.length];
+ String[] certs = new String[spki.length];
+ for (int i = 0; i < spki.length; i++) {
+ spki[i] = certificates[i].getPublicKeyDigest();
+ certs[i] = certificates[i].getFingerprint();
+ }
+ boolean ok;
+ try {
+ // Local and non-blocking by contract: on iOS this runs on the TLS
+ // delegate thread with the handshake held open.
+ ok = ShieldEngineRegistry.getEngine().verifyPins(host, spki, certs);
+ } catch (Throwable t) {
+ // A crash in pin comparison must not fail closed by accident. A real
+ // mismatch is reported as false, not thrown.
+ Log.e(t);
+ return;
+ }
+ if (!ok) {
+ throw new ShieldException(ShieldStatus.PIN_MISMATCH,
+ "The certificate chain presented by " + host
+ + " matched none of its configured pins");
+ }
+ }
+
+ public void afterResponse(ConnectionRequest request, int responseCode) {
+ if (responseCode != 401 && responseCode != 403) {
+ return;
+ }
+ String host = AppShield.hostOf(request.getUrl());
+ if (host == null || !AppShield.policyFor(host).isAttachToken()) {
+ return;
+ }
+ // A protected host refusing the request usually means it refused the
+ // token. Dropping the cached one makes the next attempt re-attest rather
+ // than replay something already rejected.
+ AppShield.invalidateToken();
+ }
+}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 9d7248d8913..291e3d6d4d4 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -352,6 +352,11 @@ public static void nativeAttestError(final int requestId, final int errorCode,
PendingRequest retry = new PendingRequest(pending.result, pending.nonce,
PendingRequest.OP_GENERATE_KEY, null);
retry.retried = true;
+ // resetAttestation() clears the flag, so set it again before
+ // starting the replacement: otherwise a request arriving before
+ // the recovery callback sees no key and no bootstrap running,
+ // generates a second rate-limited key and races this one.
+ instance.bootstrapInFlight = true;
int rid = register(retry);
instance.nativeInstance.appAttestGenerateKey(rid);
}
From de958958502980c724a1fc5ad8cccd95c8f27df6 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 12:11:55 +0300
Subject: [PATCH 08/96] Do not assert before the backend has registered the key
Three more from review.
Queued callers were drained into assertions as soon as Apple returned the
attestation object, but the backend has not seen it at that point -- the first
caller's token is only completed afterwards, and registration happens after
that. Those assertions would reference a key the server does not know, get
rejected, and trigger a pointless invalid-key reset. They are now failed with a
retryable message; by the time the app retries, the key is registered and the
request costs one cheap assertion.
addProtectedHost(host, null) had the same defect just fixed in
ShieldConfig.protect: it stored the always-open HostPolicy.PROTECTED constant
and ignored the configured default failure mode.
The base64-decoded client data hash was allocated and never released on the
non-ARC iOS build, leaking one NSData per integrity request for the life of the
process. Released through the pool, guarded because ARC forbids an explicit
autorelease and this file builds both ways.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../codename1/security/shield/AppShield.java | 19 ++++++++++++++-
Ports/iOSPort/nativeSources/IOSNative.m | 14 +++++++++++
.../impl/ios/IOSDeviceIntegrity.java | 23 ++++++++-----------
3 files changed, 42 insertions(+), 14 deletions(-)
diff --git a/CodenameOne/src/com/codename1/security/shield/AppShield.java b/CodenameOne/src/com/codename1/security/shield/AppShield.java
index e0a9a93419a..3e2f3892571 100644
--- a/CodenameOne/src/com/codename1/security/shield/AppShield.java
+++ b/CodenameOne/src/com/codename1/security/shield/AppShield.java
@@ -297,11 +297,28 @@ public static ShieldToken getCachedToken() {
/// runtime.
public static void addProtectedHost(String host, HostPolicy policy) {
if (host != null && host.length() > 0) {
+ // Same rule as ShieldConfig.protect: an omitted policy has to pick up
+ // the configured default failure mode, or setting a fail-closed
+ // default silently does nothing on this path too.
runtimeHosts.put(host.toLowerCase(),
- policy == null ? HostPolicy.PROTECTED : policy);
+ policy == null ? implicitPolicy() : policy);
}
}
+ /// Registers a host with the default policy, honouring
+ /// [ShieldConfig#defaultFailureMode(FailureMode)].
+ public static void addProtectedHost(String host) {
+ addProtectedHost(host, null);
+ }
+
+ private static HostPolicy implicitPolicy() {
+ FailureMode mode = getConfig().getDefaultFailureMode();
+ if (mode == FailureMode.OPEN) {
+ return HostPolicy.PROTECTED;
+ }
+ return new HostPolicy(true, true, mode);
+ }
+
/// The policy in force for a host. Returns [HostPolicy#UNPROTECTED] for anything not
/// registered, which is the great majority of hosts an app talks to.
public static HostPolicy policyFor(String host) {
diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m
index 5593a06c931..541403ec651 100644
--- a/Ports/iOSPort/nativeSources/IOSNative.m
+++ b/Ports/iOSPort/nativeSources/IOSNative.m
@@ -14641,6 +14641,13 @@ void com_codename1_impl_ios_IOSNative_appAttestAttestKey___int_java_lang_String_
NSString *nsHash = (clientDataHashB64 == JAVA_NULL) ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG clientDataHashB64);
NSData *clientDataHash = nsHash == nil ? nil
: [[NSData alloc] initWithBase64EncodedString:nsHash options:0];
+#ifndef CN1_USE_ARC
+ // initWithBase64EncodedString returns an owned object. The block below
+ // retains it for the duration of the call, so hand ownership to the pool
+ // rather than leaking one decoded hash per request. Guarded because ARC
+ // forbids an explicit autorelease, and this file builds both ways.
+ [clientDataHash autorelease];
+#endif
if (nsKeyId == nil || clientDataHash == nil) {
cn1AppAttestFail(requestId, nil, @"App Attest attestation missing key or hash");
POOL_END();
@@ -14675,6 +14682,13 @@ void com_codename1_impl_ios_IOSNative_appAttestGenerateAssertion___int_java_lang
NSString *nsHash = (clientDataHashB64 == JAVA_NULL) ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG clientDataHashB64);
NSData *clientDataHash = nsHash == nil ? nil
: [[NSData alloc] initWithBase64EncodedString:nsHash options:0];
+#ifndef CN1_USE_ARC
+ // initWithBase64EncodedString returns an owned object. The block below
+ // retains it for the duration of the call, so hand ownership to the pool
+ // rather than leaking one decoded hash per request. Guarded because ARC
+ // forbids an explicit autorelease, and this file builds both ways.
+ [clientDataHash autorelease];
+#endif
if (nsKeyId == nil || clientDataHash == nil) {
cn1AppAttestFail(requestId, nil, @"App Attest assertion missing key or hash");
POOL_END();
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 291e3d6d4d4..d4c0a0fe61f 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -312,10 +312,16 @@ public static void nativeAttestationReady(final int requestId, final String atte
synchronized (instance.flowLock) {
instance.currentBackoff = MIN_BACKOFF_MILLIS;
instance.bootstrapInFlight = false;
- // Callers that arrived mid-bootstrap now assert against the key
- // this attestation established, rather than each attesting one
- // of their own.
- instance.drainBootstrapWaiters(pending.keyId);
+ // Deliberately NOT asserted here. Apple has returned the
+ // attestation object, but the backend has not seen it yet -- the
+ // first caller's token is only completed below, and registration
+ // happens after that. An assertion sent now would reference a key
+ // the server does not know, which it would reject and which would
+ // trigger a pointless invalid-key reset. Queued callers are told
+ // to retry instead; by then the key is registered and their
+ // request costs one cheap assertion.
+ instance.failBootstrapWaiters("App Attest is completing first-run "
+ + "registration for this device; retry shortly");
}
}
succeed(pending, TOKEN_PREFIX + ":attest:" + base64(bytes(pending.keyId))
@@ -382,15 +388,6 @@ public static void nativeAttestError(final int requestId, final int errorCode,
fail(pending, message);
}
- /** Assert for everyone who queued behind the bootstrap. Caller holds flowLock. */
- private void drainBootstrapWaiters(String keyId) {
- while (!waitingForBootstrap.isEmpty()) {
- PendingRequest waiting = (PendingRequest) waitingForBootstrap.elementAt(0);
- waitingForBootstrap.removeElementAt(0);
- assertWithKey(waiting.result, waiting.nonce, keyId);
- }
- }
-
/** Caller holds flowLock. Leaving these unresolved would hang the callers. */
private void failBootstrapWaiters(String message) {
while (!waitingForBootstrap.isEmpty()) {
From 67750ab9376f69d9035408c7998cda60c6ec0de9 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 12:38:42 +0300
Subject: [PATCH 09/96] Keep tokens off plaintext URLs, and stop over-eager
invalidation
Eight more from review.
The token is a bearer credential, so it is no longer attached to a non-https
URL. A downgrade redirect or a mistyped scheme would otherwise hand it to
anyone on the path, and pinning cannot help when there is no certificate.
Invalidation on any 401 or 403 was wrong: a protected API normally carries
ordinary user authorization too, so an expired login would force a re-attest and
push a client toward throttling exactly while it is already failing to log in.
It now requires the backend to say it was the token it rejected, via a documented
X-CN1-Attest-Reject response header. Reading it needed a generic seam, since the
framework retains no response headers and the connection is closed by the time
afterResponse runs -- so NetworkGuard now declares which headers it wants and
they are captured while the connection is open.
A fail-closed host in a build with no engine blocked requests outright, directly
contradicting the degradation contract this API documents. Fail-closed now
applies only when an engine is actually present. Relatedly, an unchecked
exception from a pluggable engine escaped the ShieldException-only handler and
blocked the request regardless of failure mode; it is now routed through the same
path.
headersFor() could hand a request-bound token to an unrelated browser
navigation, which is exactly what binding exists to prevent. It now requires an
unbound token.
PinSet returned its backing Vector despite documenting itself immutable, so any
caller of the public getPinSet() could empty it and silently switch enforcement
off. Deep-copied in and out.
ShieldSignals notified listeners while holding its monitor on the update path.
Display.callSerially runs inline before the EDT is up, so a listener calling
back into snapshot() would deadlock.
iOS: resetAttestation left an outstanding bootstrap registered, whose callback
could repopulate the key the reset had just deleted and race whatever started
afterwards. Callbacks now carry a generation and stale ones are discarded.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/io/ConnectionRequest.java | 31 ++++++++++
.../src/com/codename1/io/NetworkGuard.java | 11 +++-
.../src/com/codename1/io/NetworkManager.java | 2 +-
.../codename1/security/shield/AppShield.java | 57 ++++++++++++++++---
.../com/codename1/security/shield/PinSet.java | 39 ++++++++++++-
.../security/shield/ShieldNetworkGuard.java | 18 ++++--
.../security/shield/ShieldSignals.java | 16 ++++--
.../impl/ios/IOSDeviceIntegrity.java | 32 +++++++++++
.../security/shield/ShieldApiTest.java | 36 ++++++++++++
9 files changed, 221 insertions(+), 21 deletions(-)
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index d2b009a0a79..9a591e69606 100644
--- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java
+++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
@@ -1193,6 +1193,7 @@ boolean performOperationComplete() throws IOException {
Preferences.set("cn1Etag" + createRequestURL(), etag);
}
readHeaders(connection);
+ captureGuardHeaders(connection);
contentLength = impl.getContentLength(connection);
timeSinceLastUpdate = System.currentTimeMillis();
@@ -1336,6 +1337,36 @@ protected boolean shouldConvertPostToGetOnRedirect() {
protected void readHeaders(Object connection) throws IOException {
}
+ /// Values of the headers the installed [NetworkGuard] asked for, captured while the connection
+ /// is still open because it is closed before `afterResponse` runs.
+ private String[] guardHeaders;
+
+ private void captureGuardHeaders(Object connection) {
+ NetworkGuard guard = NetworkManager.getNetworkGuard();
+ guardHeaders = null;
+ if (guard == null) {
+ return;
+ }
+ try {
+ String[] names = guard.interestingResponseHeaders();
+ if (names == null || names.length == 0) {
+ return;
+ }
+ String[] values = new String[names.length];
+ for (int i = 0; i < names.length; i++) {
+ values[i] = getHeader(connection, names[i]);
+ }
+ guardHeaders = values;
+ } catch (Throwable t) {
+ // Diagnostics for the guard must never fail the request.
+ Log.e(t);
+ }
+ }
+
+ String[] getGuardHeaders() {
+ return guardHeaders;
+ }
+
/// Allows reading the headers from the connection by calling the getHeader() method when a response that isn't 200 OK is sent.
///
/// #### Parameters
diff --git a/CodenameOne/src/com/codename1/io/NetworkGuard.java b/CodenameOne/src/com/codename1/io/NetworkGuard.java
index 6150c006875..630f023399d 100644
--- a/CodenameOne/src/com/codename1/io/NetworkGuard.java
+++ b/CodenameOne/src/com/codename1/io/NetworkGuard.java
@@ -68,11 +68,20 @@ public interface NetworkGuard {
void checkCertificates(ConnectionRequest request,
ConnectionRequest.SSLCertificate[] certificates) throws IOException;
+ /// Response header names this guard wants captured, or null for none.
+ ///
+ /// Asked once per response, while the connection is still open. The framework does not retain
+ /// arbitrary response headers, and by the time [#afterResponse] runs the connection has been
+ /// closed -- so anything a guard needs to see has to be named here first.
+ String[] interestingResponseHeaders();
+
/// Called once the response code is known, including for failed requests.
///
/// This is how a token layer learns its token was refused -- a 401 or 403 from a protected host
/// usually means the cached token should be discarded before the next attempt. Must not throw;
/// exceptions here are logged and swallowed so a telemetry problem cannot fail a request that
/// otherwise succeeded.
- void afterResponse(ConnectionRequest request, int responseCode);
+ /// @param headers values for the names returned by [#interestingResponseHeaders()], in the
+ /// same order; an entry is null when the response did not carry that header
+ void afterResponse(ConnectionRequest request, int responseCode, String[] headers);
}
diff --git a/CodenameOne/src/com/codename1/io/NetworkManager.java b/CodenameOne/src/com/codename1/io/NetworkManager.java
index 5e400072bd5..f78d9abc1ce 100644
--- a/CodenameOne/src/com/codename1/io/NetworkManager.java
+++ b/CodenameOne/src/com/codename1/io/NetworkManager.java
@@ -1171,7 +1171,7 @@ private boolean runCurrentRequest(@Async.Execute ConnectionRequest req) {
NetworkGuard guard = networkGuard;
if (guard != null) {
try {
- guard.afterResponse(req, req.getResponseCode());
+ guard.afterResponse(req, req.getResponseCode(), req.getGuardHeaders());
} catch (Throwable t) {
// A guard's bookkeeping must never turn a completed
// request into a failed one.
diff --git a/CodenameOne/src/com/codename1/security/shield/AppShield.java b/CodenameOne/src/com/codename1/security/shield/AppShield.java
index 3e2f3892571..d4e82146d6c 100644
--- a/CodenameOne/src/com/codename1/security/shield/AppShield.java
+++ b/CodenameOne/src/com/codename1/security/shield/AppShield.java
@@ -81,6 +81,15 @@ public final class AppShield {
private static final Vector listeners = new Vector();
private static final Hashtable runtimeHosts = new Hashtable();
+ /// Response header a backend sets to say it rejected the *attestation token*, as opposed to
+ /// the user's own credentials.
+ ///
+ /// Without it a 401 or 403 is ambiguous: protected APIs normally carry ordinary user
+ /// authorization too, and treating every such response as an attestation rejection would make
+ /// a client re-attest through an entire login failure. Emit it only when the token itself was
+ /// the problem; the value is ignored.
+ public static final String REJECT_HEADER = "X-CN1-Attest-Reject";
+
private AppShield() {
}
@@ -224,7 +233,8 @@ public static void attach(ConnectionRequest request) throws ShieldException {
if (request == null || !initialized) {
return;
}
- String host = hostOf(request.getUrl());
+ String url = request.getUrl();
+ String host = hostOf(url);
HostPolicy policy = policyFor(host);
// Always clear first. A redirect reuses this request object with its
// headers intact, so a protected endpoint with an open redirect would
@@ -234,27 +244,54 @@ public static void attach(ConnectionRequest request) throws ShieldException {
if (!policy.isAttachToken()) {
return;
}
+ if (!isSecure(url)) {
+ // The token is a bearer credential. Sending it in plaintext -- after a
+ // downgrade redirect, or a mistyped scheme -- hands it to anyone on the
+ // path, and pinning cannot help because there is no certificate to pin.
+ Log.p("AppShield: refusing to attach a token to a plaintext URL for "
+ + host + ". Use https for protected hosts.");
+ return;
+ }
try {
ShieldToken token = ShieldEngineRegistry.getEngine().fetchToken(null);
- setStatus(token.getStatus());
- if (token.isValid()) {
+ setStatus(token == null ? ShieldStatus.SERVICE_DOWN : token.getStatus());
+ if (token != null && token.isValid()) {
request.addRequestHeader(getConfig().getTokenHeader(), token.getValue());
return;
}
- failOrContinue(policy, new ShieldException(token.getStatus(),
+ failOrContinue(policy, new ShieldException(
+ token == null ? ShieldStatus.SERVICE_DOWN : token.getStatus(),
"No valid attestation token for " + host));
} catch (ShieldException e) {
setStatus(e.getStatus());
failOrContinue(policy, e);
+ } catch (Throwable t) {
+ // An engine is pluggable code that can throw anything. Letting an
+ // unchecked failure escape would block the request regardless of the
+ // host's failure mode, which is the opposite of fail-open.
+ Log.e(t);
+ setStatus(ShieldStatus.SERVICE_DOWN);
+ failOrContinue(policy, new ShieldException(ShieldStatus.SERVICE_DOWN,
+ "Attestation engine failed for " + host));
}
}
+ /// True for an absolute https URL. Anything else -- http, or a relative URL we cannot
+ /// classify -- is not somewhere a bearer token belongs.
+ static boolean isSecure(String url) {
+ return url != null && url.toLowerCase().startsWith("https://");
+ }
+
private static void failOrContinue(HostPolicy policy, ShieldException e) throws ShieldException {
- if (policy.getFailureMode() == FailureMode.CLOSED) {
+ // A build with no engine must never block a request: that is the
+ // degradation contract this API documents, and a fail-closed host in an
+ // open-source or unentitled build would otherwise break outright.
+ if (policy.getFailureMode() == FailureMode.CLOSED && isProtected()) {
throw e;
}
Log.p("AppShield: continuing without a token (" + e.getStatus().getId()
- + "); host policy is fail-open.");
+ + "); " + (isProtected() ? "host policy is fail-open."
+ : "no attestation engine is present, so nothing is enforced."));
}
/// The headers a protected URL should carry, for network paths that do not go through
@@ -273,8 +310,14 @@ public static Hashtable headersFor(String url) {
if (!policyFor(hostOf(url)).isAttachToken()) {
return out;
}
+ if (!isSecure(url)) {
+ return out;
+ }
ShieldToken token = getCachedToken();
- if (token != null && token.isValid()) {
+ // isBoundTo(null) as well as isValid(): a token minted for one specific
+ // request must not be handed to an unrelated navigation, which is the
+ // whole reason binding exists.
+ if (token != null && token.isValid() && token.isBoundTo(null)) {
out.put(getConfig().getTokenHeader(), token.getValue());
}
return out;
diff --git a/CodenameOne/src/com/codename1/security/shield/PinSet.java b/CodenameOne/src/com/codename1/security/shield/PinSet.java
index 7b417799158..7728c75de9e 100644
--- a/CodenameOne/src/com/codename1/security/shield/PinSet.java
+++ b/CodenameOne/src/com/codename1/security/shield/PinSet.java
@@ -61,7 +61,11 @@ public final class PinSet {
/// @param softExpiry local millis after which a refresh should be attempted, 0 for never
/// @param hardExpiry local millis after which the set is discarded entirely, 0 for never
public PinSet(Hashtable hostToPins, int version, long softExpiry, long hardExpiry) {
- this.hostToPins = hostToPins == null ? new Hashtable() : hostToPins;
+ // Deep copy. The set is reachable through the public AppShield.getPinSet(),
+ // and a caller that cleared the backing vectors would leave every host
+ // looking unpinned -- which silently disables enforcement rather than
+ // failing visibly.
+ this.hostToPins = copyOf(hostToPins);
this.version = version;
this.softExpiry = softExpiry;
this.hardExpiry = hardExpiry;
@@ -92,6 +96,11 @@ public boolean isEnforcedFor(String host) {
return pins != null && !pins.isEmpty();
}
+ /// Number of hosts with at least one pin. Used by tests and diagnostics.
+ public int hostCount() {
+ return hostToPins.size();
+ }
+
/// The pins registered for a host, honouring a leading `*.` wildcard, or null when the host is
/// not pinned.
public Vector pinsFor(String host) {
@@ -101,20 +110,44 @@ public Vector pinsFor(String host) {
String h = host.toLowerCase();
Object exact = hostToPins.get(h);
if (exact != null) {
- return (Vector) exact;
+ return copyOf((Vector) exact);
}
// Walk up the labels so a "*.example.com" entry covers "api.example.com".
int dot = h.indexOf('.');
while (dot >= 0 && dot < h.length() - 1) {
Object wild = hostToPins.get("*." + h.substring(dot + 1));
if (wild != null) {
- return (Vector) wild;
+ return copyOf((Vector) wild);
}
dot = h.indexOf('.', dot + 1);
}
return null;
}
+ private static Hashtable copyOf(Hashtable in) {
+ Hashtable out = new Hashtable();
+ if (in == null) {
+ return out;
+ }
+ java.util.Enumeration keys = in.keys();
+ while (keys.hasMoreElements()) {
+ Object key = keys.nextElement();
+ Object value = in.get(key);
+ out.put(key, value instanceof Vector ? copyOf((Vector) value) : value);
+ }
+ return out;
+ }
+
+ private static Vector copyOf(Vector in) {
+ Vector out = new Vector();
+ if (in != null) {
+ for (int i = 0; i < in.size(); i++) {
+ out.addElement(in.elementAt(i));
+ }
+ }
+ return out;
+ }
+
/// True when at least one of the supplied chain digests matches a pin for the host.
///
/// Returns true when the host is not pinned at all -- "no opinion" must never be reported as a
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java b/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
index f382545c004..9f9be57befb 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
@@ -86,7 +86,11 @@ public void checkCertificates(ConnectionRequest request,
}
}
- public void afterResponse(ConnectionRequest request, int responseCode) {
+ public String[] interestingResponseHeaders() {
+ return new String[] {AppShield.REJECT_HEADER};
+ }
+
+ public void afterResponse(ConnectionRequest request, int responseCode, String[] headers) {
if (responseCode != 401 && responseCode != 403) {
return;
}
@@ -94,9 +98,15 @@ public void afterResponse(ConnectionRequest request, int responseCode) {
if (host == null || !AppShield.policyFor(host).isAttachToken()) {
return;
}
- // A protected host refusing the request usually means it refused the
- // token. Dropping the cached one makes the next attempt re-attest rather
- // than replay something already rejected.
+ // Only when the backend says it was the *token* it rejected. A protected
+ // API usually also carries ordinary user authorization, so an expired
+ // login or a plain permission denial is a 401/403 that has nothing to do
+ // with attestation -- re-attesting on every one of those would push a
+ // client into rate limiting precisely while it is already failing to log
+ // in.
+ if (headers == null || headers.length == 0 || headers[0] == null) {
+ return;
+ }
AppShield.invalidateToken();
}
}
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldSignals.java b/CodenameOne/src/com/codename1/security/shield/ShieldSignals.java
index 6a08e395890..7a4efd76898 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldSignals.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldSignals.java
@@ -52,18 +52,24 @@ public static void add(ShieldSignal signal) {
return;
}
synchronized (signals) {
+ boolean replaced = false;
for (int i = 0; i < signals.size(); i++) {
if (((ShieldSignal) signals.elementAt(i)).getId().equals(signal.getId())) {
signals.setElementAt(signal, i);
- notifyListeners(signal);
- return;
+ replaced = true;
+ break;
}
}
- if (signals.size() >= MAX_SIGNALS) {
- signals.removeElementAt(0);
+ if (!replaced) {
+ if (signals.size() >= MAX_SIGNALS) {
+ signals.removeElementAt(0);
+ }
+ signals.addElement(signal);
}
- signals.addElement(signal);
}
+ // Outside the lock on every path. Display.callSerially runs the task
+ // inline when the EDT is not up yet, so a listener that calls back into
+ // snapshot() would deadlock on the monitor we were still holding.
notifyListeners(signal);
}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index d4c0a0fe61f..1a0ff1e9004 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -119,6 +119,13 @@ final class IOSDeviceIntegrity {
private long currentBackoff = MIN_BACKOFF_MILLIS;
/** True while a generate-then-attest bootstrap is running. */
private boolean bootstrapInFlight;
+ /**
+ * Bumped by every reset. A callback carrying an older generation belongs to a
+ * bootstrap that was abandoned, and acting on it would repopulate the key a
+ * reset just deleted -- racing whatever flow started afterwards to persist a
+ * different key.
+ */
+ private int generation;
/** Callers that arrived mid-bootstrap and will assert once it completes. */
private final java.util.Vector waitingForBootstrap = new java.util.Vector();
@@ -144,6 +151,8 @@ void resetAttestation() {
synchronized (flowLock) {
currentBackoff = MIN_BACKOFF_MILLIS;
bootstrapInFlight = false;
+ // Any callback still outstanding now belongs to an abandoned flow.
+ generation++;
failBootstrapWaiters("App Attest state was reset while a bootstrap was in flight");
}
}
@@ -278,6 +287,10 @@ public static void nativeKeyGenerated(final int requestId, final String keyId) {
if (pending == null || instance == null) {
return;
}
+ if (isStale(pending)) {
+ fail(pending, "App Attest state was reset while this request was in flight");
+ return;
+ }
if (keyId == null || keyId.length() == 0) {
fail(pending, "App Attest key generation returned no identifier");
return;
@@ -299,6 +312,10 @@ public static void nativeAttestationReady(final int requestId, final String atte
if (pending == null) {
return;
}
+ if (isStale(pending)) {
+ fail(pending, "App Attest state was reset while this request was in flight");
+ return;
+ }
if (attestationB64 == null) {
fail(pending, "App Attest attestation returned no data");
return;
@@ -409,6 +426,9 @@ private static long readRetryAfter() {
}
private static int register(PendingRequest pending) {
+ if (instance != null) {
+ pending.generation = instance.generation;
+ }
synchronized (REQUESTS) {
int rid = nextRequestId++;
REQUESTS.put(Integer.valueOf(rid), pending);
@@ -416,6 +436,15 @@ private static int register(PendingRequest pending) {
}
}
+ /**
+ * True when this callback belongs to a flow a reset has since abandoned. Its
+ * results must be discarded rather than written back, or it would resurrect
+ * the key the reset deleted.
+ */
+ private static boolean isStale(PendingRequest pending) {
+ return instance != null && pending.generation != instance.generation;
+ }
+
private static PendingRequest take(int requestId) {
synchronized (REQUESTS) {
return REQUESTS.remove(Integer.valueOf(requestId));
@@ -470,6 +499,9 @@ private static final class PendingRequest {
final String keyId;
String clientData;
boolean retried;
+ /// Which reset generation this request was registered under. A callback
+ /// carrying an older one belongs to an abandoned flow.
+ int generation;
PendingRequest(AsyncResource result, String nonce, int op, String keyId) {
this.result = result;
diff --git a/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
index d31b83fe30d..607ba007c75 100644
--- a/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
@@ -254,6 +254,16 @@ void defaultsAreOpenAndNonBlocking() {
assertEquals(FailureMode.OPEN, HostPolicy.PROTECTED.getFailureMode());
}
+ /** A bearer token has no business travelling in plaintext. */
+ @Test
+ void onlyHttpsUrlsAreConsideredSecure() {
+ assertTrue(AppShield.isSecure("https://api.example.com/x"));
+ assertTrue(AppShield.isSecure("HTTPS://api.example.com/x"));
+ assertFalse(AppShield.isSecure("http://api.example.com/x"));
+ assertFalse(AppShield.isSecure("/relative/path"));
+ assertFalse(AppShield.isSecure(null));
+ }
+
// --- PinSet ---------------------------------------------------------
private static PinSet pinSet(String host, String pin, long soft, long hard) {
@@ -322,6 +332,32 @@ void pinWildcardsFollowTheSameRulesAsHostPolicies() {
assertNull(set.pinsFor("other.test"));
}
+ /**
+ * The set is reachable through the public AppShield.getPinSet(), so a caller
+ * that mutated it could silently switch enforcement off rather than fail
+ * visibly.
+ */
+ @Test
+ void pinSetCannotBeDisabledByMutatingWhatItReturns() {
+ Hashtable t = new Hashtable();
+ Vector v = new Vector();
+ v.addElement("AAA");
+ t.put("api.example.com", v);
+ PinSet set = new PinSet(t, 1, 0, 0);
+
+ // Mutating the table passed to the constructor must not reach inside.
+ v.removeAllElements();
+ t.clear();
+ assertTrue(set.isEnforcedFor("api.example.com"));
+ assertEquals(1, set.hostCount());
+
+ // Nor must mutating what pinsFor() hands back.
+ set.pinsFor("api.example.com").removeAllElements();
+ assertTrue(set.isEnforcedFor("api.example.com"));
+ assertFalse(set.matches("api.example.com", new String[] {"WRONG"}));
+ assertTrue(set.matches("api.example.com", new String[] {"AAA"}));
+ }
+
// --- ShieldSignals --------------------------------------------------
@Test
From 735483c34e88eea3d54ab0deb203d8a160d86f15 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 13:46:54 +0300
Subject: [PATCH 10/96] Fix an MCP transport leak, SPKI collection and
attestation registration
CI surfaced a pre-existing race in the MCP server that my added tests
shifted the ordering enough to expose. The reader thread reads liveness
off the server's shared fields, so a stopped-then-restarted server left
the old thread serving the transport it had been given: it blocked in
readMessage forever and never released the process-wide registration,
and every later server in the JVM was refused with "already open on
port". The thread now tracks the transport it was handed and closes it
once the server has moved on. isRunning() joins the other synchronized
accesses, which SpotBugs asked for.
Review findings, all confirmed against the code:
- Collect SPKI digests even when the request already enabled certificate
checks. The early return meant a request that called
setCheckSSLCertificates(true) handed the guard a chain with no public
key digests, so a guard pinning the SPKI rejected a valid chain.
- Capture guard headers before returning from an error response.
setReadResponseForErrors(false) returns before the capture ran, so a
401/403 -- the response a token layer most needs to see -- never
reached afterResponse and the rejected token stayed cached.
- Hold iOS assertions until registration is acknowledged. Apple's
callback persisted "attested", so a caller arriving in the window
before any backend had recorded the key sailed past the waiting queue
and asserted against a key the server could not resolve. The key is
now "pending" until DeviceIntegrity.confirmAttestation(), with a grace
window so a consumer that never acknowledges still works.
The JavaSE smoke test's Maven Central retry now backs off progressively;
a flat 30s is inside the window a 429 is still rate limiting in, so all
three attempts failed for the same reason.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/CodenameOneImplementation.java | 7 +
.../com/codename1/io/ConnectionRequest.java | 18 ++-
.../src/com/codename1/mcp/MCPServer.java | 57 ++++++--
.../codename1/security/DeviceIntegrity.java | 17 +++
.../shield/spi/DefaultEngineContext.java | 8 ++
.../security/shield/spi/EngineContext.java | 5 +
CodenameOne/src/com/codename1/ui/Display.java | 6 +
.../com/codename1/impl/javase/JavaSEPort.java | 5 +
.../impl/ios/IOSDeviceIntegrity.java | 112 +++++++++++++--
.../codename1/impl/ios/IOSImplementation.java | 5 +
.../mcp/MCPLoopbackTransportOpenTest.java | 136 ++++++++++++++++++
scripts/run-javase-cef-ffmpeg-smoke.py | 24 ++--
12 files changed, 363 insertions(+), 37 deletions(-)
diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
index d2cb55765fb..361ed4d7104 100644
--- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
+++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
@@ -11272,6 +11272,13 @@ public String[] getEnabledAccessibilityServices() {
public void resetAttestation() {
}
+ /// Acknowledges that a verifying backend has recorded the attested key, so subsequent requests can
+ /// take the cheap assertion path. See
+ /// [com.codename1.security.DeviceIntegrity#confirmAttestation()]. No-op where attestation holds no
+ /// client-side key.
+ public void confirmAttestation() {
+ }
+
/// Returns digests of the certificates the running application is actually signed with, so a build
/// can be compared against the identity it was built under and repackaging can be reported.
///
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index 9a591e69606..b2555b0f587 100644
--- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java
+++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
@@ -929,25 +929,26 @@ boolean checkCertificatesNativeCallback() {
/// True when the certificate chain for this request should be fetched and vetted, either
/// because the request opted in itself or because the installed [NetworkGuard] pins this host.
private boolean shouldInspectCertificates() {
- if (checkSSLCertificates) {
- return true;
- }
NetworkGuard guard = NetworkManager.getNetworkGuard();
if (guard == null) {
- return false;
+ return checkSSLCertificates;
}
try {
if (guard.isCertificateCheckRequired(url)) {
// Ask the platform for the richer chain details (public key
// digests, per-certificate grouping). Off by default so every
- // existing caller keeps seeing byte-identical data.
+ // existing caller keeps seeing byte-identical data. Asked even
+ // when the request already opted in on its own: otherwise a
+ // request that calls setCheckSSLCertificates(true) hands the
+ // guard a chain with no public key digests at all, and a guard
+ // pinning the SPKI would reject a perfectly valid chain.
collectPublicKeyDigests = true;
return true;
}
} catch (Throwable t) {
Log.e(t);
}
- return false;
+ return checkSSLCertificates;
}
/// Performs the actual network request on behalf of the network manager
@@ -1180,6 +1181,11 @@ boolean performOperationComplete() throws IOException {
responseErrorMessge = impl.getResponseMessage(connection);
handleErrorResponseCode(responseCode, responseErrorMessge);
if (!isReadResponseForErrors()) {
+ // Capture before the early return. A 401/403 is exactly the
+ // response a guard needs to see -- it is how a token layer
+ // learns its token was refused -- and this branch is the
+ // common configuration for the requests that carry one.
+ captureGuardHeaders(connection);
return true;
}
}
diff --git a/CodenameOne/src/com/codename1/mcp/MCPServer.java b/CodenameOne/src/com/codename1/mcp/MCPServer.java
index 386568ba327..51acdea382e 100644
--- a/CodenameOne/src/com/codename1/mcp/MCPServer.java
+++ b/CodenameOne/src/com/codename1/mcp/MCPServer.java
@@ -128,7 +128,7 @@ public void setScreenshotEnabled(boolean screenshotEnabled) {
this.screenshotEnabled = screenshotEnabled;
}
- public boolean isRunning() {
+ public synchronized boolean isRunning() {
return running;
}
@@ -139,10 +139,15 @@ public synchronized void start(MCPTransport transport) {
}
this.transport = transport;
running = true;
+ // Captured for the thread rather than read back off the field. A later start()
+ // replaces the field, and a reader thread that followed it would end up serving a
+ // transport the server no longer considers its own -- while the transport it was
+ // actually given was never closed.
+ final MCPTransport mine = transport;
Thread readerThread = new Thread(new Runnable() {
@Override
public void run() {
- runLoop();
+ runLoop(mine);
}
}, "cn1-mcp-server");
readerThread.start();
@@ -156,9 +161,34 @@ public synchronized void stop() {
}
}
- private void runLoop() {
+ /// True while `t` is still the transport this server is serving over. False once stop()
+ /// has run, or once a restart handed the server a different transport -- in both cases
+ /// the thread holding `t` owns it alone and has to close it itself.
+ private synchronized boolean isCurrent(MCPTransport t) {
+ return running && transport == t; // NOPMD identity is the question, not equality
+ }
+
+ /// Clears the running flag, but only on behalf of the transport that is still current.
+ /// A thread unwinding from a superseded transport must not stop a server that has since
+ /// been restarted over a new one.
+ private synchronized void stopIfCurrent(MCPTransport t) {
+ if (transport == t) { // NOPMD identity: is this still our transport?
+ running = false;
+ }
+ }
+
+ private void runLoop(MCPTransport t) {
+ // Opening is deferred to this thread, so by the time it happens the server may
+ // already have been stopped or restarted. Either way stop()'s close() ran against a
+ // transport that had not opened yet and therefore released nothing, so opening now
+ // would leave a transport registered with nobody left to close it. That registration
+ // is process-wide: every later open() is refused on the grounds that an agent is
+ // already being served.
+ if (!isCurrent(t)) {
+ return;
+ }
try {
- transport.open();
+ t.open();
} catch (IOException ex) {
// The transport failed to start listening. Log defensively: the CN1 Log
// routes through the platform implementation, which may not be registered
@@ -169,13 +199,20 @@ private void runLoop() {
} catch (Throwable logErr) {
System.err.println("[cn1.mcp] transport open failed: " + ex);
}
- running = false;
+ stopIfCurrent(t);
+ t.close();
return;
}
- while (running) {
+ if (!isCurrent(t)) {
+ // Same window, the far side of it: the server moved on while open() was in
+ // flight. Undo the registration this thread just took.
+ t.close();
+ return;
+ }
+ while (isCurrent(t)) {
String line;
try {
- line = transport.readMessage();
+ line = t.readMessage();
} catch (IOException ex) {
break;
}
@@ -188,14 +225,14 @@ private void runLoop() {
String response = handleMessage(line);
if (response != null) {
try {
- transport.writeMessage(response);
+ t.writeMessage(response);
} catch (IOException ex) {
break;
}
}
}
- running = false;
- transport.close();
+ stopIfCurrent(t);
+ t.close();
}
/// Handles one inbound JSON-RPC message and returns the response line, or null
diff --git a/CodenameOne/src/com/codename1/security/DeviceIntegrity.java b/CodenameOne/src/com/codename1/security/DeviceIntegrity.java
index 371372902d5..bee358e871b 100644
--- a/CodenameOne/src/com/codename1/security/DeviceIntegrity.java
+++ b/CodenameOne/src/com/codename1/security/DeviceIntegrity.java
@@ -127,6 +127,23 @@ public static void resetAttestation() {
Display.getInstance().resetAttestation();
}
+ /// Tells the attestation layer that your backend has recorded the attested key, so later requests
+ /// can use cheap assertions instead of attesting again.
+ ///
+ /// This matters on iOS. The first token of a device's life is an attestation, which carries the
+ /// public key; every token after it is an assertion, which carries only the key's identifier. An
+ /// assertion sent before the backend has stored that public key is unresolvable, and the natural
+ /// reading of that rejection -- the key is invalid -- would throw away a key that was perfectly
+ /// good and burn one of Apple's rate limited attestations replacing it. So requests made between
+ /// the attestation and this acknowledgement are refused with a retry hint rather than asserted.
+ ///
+ /// Call it once, after the response accepting the attestation token. Not calling it is safe but
+ /// slower: the client assumes registration succeeded after a short grace period. No-op on Android
+ /// and where attestation is unsupported.
+ public static void confirmAttestation() {
+ Display.getInstance().confirmAttestation();
+ }
+
/// Non-exiting RASP check. Returns true when the device shows signs of being rooted, jailbroken,
/// running under dynamic instrumentation (e.g. Frida) or otherwise tampered. Unlike the
/// `android.rootCheck` / `ios.detectJailbreak` launch gates this never terminates the app, so it is
diff --git a/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java b/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java
index 45c8067e222..35c1c8a3756 100644
--- a/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java
+++ b/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java
@@ -66,6 +66,14 @@ public void resetPlatformAttestation() {
}
}
+ public void confirmPlatformAttestation() {
+ try {
+ DeviceIntegrity.confirmAttestation();
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
public String[] getPlatformCompromiseReasons() {
try {
String[] r = DeviceIntegrity.getCompromiseReasons();
diff --git a/CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java b/CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java
index 824217b8c2a..a5e7f27757d 100644
--- a/CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java
+++ b/CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java
@@ -51,6 +51,11 @@ public interface EngineContext {
/// Used when the service reports that the device's attestation key is unknown to it.
void resetPlatformAttestation();
+ /// Acknowledges that the verifying service recorded the attested key, releasing the client to use
+ /// cheap assertions from here on. Call it once the service has accepted an attestation token; until
+ /// then the platform refuses to assert against a key the service cannot yet resolve.
+ void confirmPlatformAttestation();
+
/// Platform-detected compromise reasons, such as `root` or `frida`.
String[] getPlatformCompromiseReasons();
diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java
index 26c6232b16f..f7543cfc97f 100644
--- a/CodenameOne/src/com/codename1/ui/Display.java
+++ b/CodenameOne/src/com/codename1/ui/Display.java
@@ -6865,6 +6865,12 @@ public void resetAttestation() {
impl.resetAttestation();
}
+ /// Acknowledges that a backend recorded the attested key. See
+ /// `com.codename1.security.DeviceIntegrity#confirmAttestation()`.
+ public void confirmAttestation() {
+ impl.confirmAttestation();
+ }
+
/// Returns digests of the certificates the running app is signed with. Low level hook for the
/// attestation layer, which reports them to a verifying service; an on-device comparison proves
/// nothing on its own. Empty where the platform has no such concept.
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index 33fb9215a19..99f766d3268 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -19050,6 +19050,11 @@ public void resetAttestation() {
// Nothing is cached in the simulator; the menu is the state.
}
+ @Override
+ public void confirmAttestation() {
+ // No client-side key here either, so there is nothing to acknowledge.
+ }
+
@Override
public boolean isDeviceCompromised() {
return JavaSEShield.simReasons().length > 0;
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 1a0ff1e9004..720b2e2a39e 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -90,8 +90,28 @@ final class IOSDeviceIntegrity {
private static final String KEY_STATE = "cn1.appattest.state";
private static final String KEY_RETRY_AFTER = "cn1.appattest.retryAfter";
+ private static final String KEY_PENDING_SINCE = "cn1.appattest.pendingSince";
+
private static final String STATE_NEW = "new";
private static final String STATE_ATTESTED = "attested";
+ /**
+ * Apple returned the attestation object, but no backend has acknowledged
+ * recording the key yet. See {@link #confirmAttestation()}.
+ */
+ private static final String STATE_PENDING = "pending";
+
+ /**
+ * How long a key may sit unacknowledged before it is treated as registered
+ * anyway.
+ *
+ * Needed because acknowledgement is optional: a caller using
+ * {@code DeviceIntegrity.requestIntegrityToken} directly, without the shield
+ * engine, never calls {@link #confirmAttestation()}. Without an expiry such
+ * an app would sit in the pending state forever and re-attest on every
+ * request, which is exactly the rate-limit burn this class exists to avoid.
+ * A minute comfortably covers a registration round trip on a slow link.
+ */
+ private static final long REGISTRATION_GRACE_MILLIS = 60L * 1000L;
// Ordinals from DeviceCheck's DCError enum, which declares no explicit
// values: unknownSystemFailure, featureUnsupported, invalidInput,
@@ -148,6 +168,7 @@ void resetAttestation() {
store.remove(KEY_ID);
store.remove(KEY_STATE);
store.remove(KEY_RETRY_AFTER);
+ store.remove(KEY_PENDING_SINCE);
synchronized (flowLock) {
currentBackoff = MIN_BACKOFF_MILLIS;
bootstrapInFlight = false;
@@ -157,6 +178,29 @@ void resetAttestation() {
}
}
+ /**
+ * Acknowledges that a backend has recorded this device's attested public key,
+ * moving it from {@code pending} to {@code attested} so subsequent requests
+ * take the cheap assertion path.
+ *
+ * Until this is called -- or the grace window expires -- requests are
+ * refused with a retry hint rather than asserting. An assertion references a
+ * key by identifier only, so one sent before the server has the public key is
+ * simply unknown to it: the server rejects it, the app reads that as an
+ * invalid key, and resets a key that was in fact perfectly good. The whole
+ * point of attest-once is not to burn keys that way.
+ */
+ void confirmAttestation() {
+ SecureStorage store = SecureStorage.getInstance();
+ if (!STATE_PENDING.equals(store.get(KEY_STATE))) {
+ return;
+ }
+ synchronized (flowLock) {
+ store.set(KEY_STATE, STATE_ATTESTED);
+ store.remove(KEY_PENDING_SINCE);
+ }
+ }
+
String[] jailbreakSignals() {
try {
String signals = nativeInstance.iosJailbreakSignals();
@@ -192,7 +236,26 @@ AsyncResource requestToken(String nonce) {
}
SecureStorage store = SecureStorage.getInstance();
String keyId = store.get(KEY_ID);
- boolean attested = STATE_ATTESTED.equals(store.get(KEY_STATE));
+ String state = store.get(KEY_STATE);
+ if (STATE_PENDING.equals(state) && keyId != null && keyId.length() > 0) {
+ if (registrationGraceRemaining() > 0) {
+ // The key is attested with Apple but no backend has confirmed
+ // recording it. Asserting now would present a key identifier
+ // the server cannot resolve, which reads as an invalid key and
+ // costs a needless reset -- so refuse briefly instead.
+ r.error(new RuntimeException("App Attest is completing first-run "
+ + "registration for this device; retry shortly"));
+ return r;
+ }
+ // Nobody acknowledged within the window. Either the consumer does
+ // not participate in acknowledgement at all, or its registration
+ // call was lost. Assume registered rather than re-attesting on
+ // every request forever.
+ store.set(KEY_STATE, STATE_ATTESTED);
+ store.remove(KEY_PENDING_SINCE);
+ state = STATE_ATTESTED;
+ }
+ boolean attested = STATE_ATTESTED.equals(state);
if (keyId == null || keyId.length() == 0 || !attested) {
// Checked before branching on the key state, not after: between
// key generation persisting the id and its attestation
@@ -229,6 +292,29 @@ AsyncResource requestToken(String nonce) {
return r;
}
+ /**
+ * Milliseconds left in the registration grace window, or 0 once it has run
+ * out. A pending timestamp from the future -- a clock the user moved back --
+ * is treated as expired rather than as an unbounded wait.
+ */
+ private static long registrationGraceRemaining() {
+ String since = SecureStorage.getInstance().get(KEY_PENDING_SINCE);
+ if (since == null || since.length() == 0) {
+ return 0;
+ }
+ long started;
+ try {
+ started = Long.parseLong(since);
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ long elapsed = System.currentTimeMillis() - started;
+ if (elapsed < 0 || elapsed >= REGISTRATION_GRACE_MILLIS) {
+ return 0;
+ }
+ return REGISTRATION_GRACE_MILLIS - elapsed;
+ }
+
// --- flow steps ------------------------------------------------------
private void attestKey(AsyncResource r, String nonce, String keyId) {
@@ -320,23 +406,23 @@ public static void nativeAttestationReady(final int requestId, final String atte
fail(pending, "App Attest attestation returned no data");
return;
}
- // Optimistic: Apple accepted the attestation, but only the backend can
- // confirm it recorded the key. If it later rejects, the app calls
- // DeviceIntegrity.resetAttestation() and we start over.
+ // Apple accepted the attestation, but only the backend can confirm it
+ // recorded the key, so the key becomes pending rather than attested.
+ // Every caller -- queued or newly arriving -- is told to retry until
+ // confirmAttestation() lands or the grace window expires; otherwise a
+ // caller arriving right after this callback would sail past the queue and
+ // assert against a key the server has never seen. If the backend later
+ // rejects it, the app calls DeviceIntegrity.resetAttestation() instead.
SecureStorage store = SecureStorage.getInstance();
- store.set(KEY_STATE, STATE_ATTESTED);
+ store.set(KEY_STATE, STATE_PENDING);
+ store.set(KEY_PENDING_SINCE, Long.toString(System.currentTimeMillis()));
if (instance != null) {
synchronized (instance.flowLock) {
instance.currentBackoff = MIN_BACKOFF_MILLIS;
instance.bootstrapInFlight = false;
- // Deliberately NOT asserted here. Apple has returned the
- // attestation object, but the backend has not seen it yet -- the
- // first caller's token is only completed below, and registration
- // happens after that. An assertion sent now would reference a key
- // the server does not know, which it would reject and which would
- // trigger a pointless invalid-key reset. Queued callers are told
- // to retry instead; by then the key is registered and their
- // request costs one cheap assertion.
+ // Deliberately NOT asserted here, for the reason above. Queued
+ // callers are told to retry; by then the key is registered and
+ // their request costs one cheap assertion.
instance.failBootstrapWaiters("App Attest is completing first-run "
+ "registration for this device; retry shortly");
}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
index f96f3a2e6fa..9c480bc7a51 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
@@ -4580,6 +4580,11 @@ public void resetAttestation() {
deviceIntegrity().resetAttestation();
}
+ @Override
+ public void confirmAttestation() {
+ deviceIntegrity().confirmAttestation();
+ }
+
private IOSDeviceIntegrity deviceIntegrity() {
if (deviceIntegrity == null) {
deviceIntegrity = new IOSDeviceIntegrity(nativeInstance);
diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java
index c9985e747b4..6872b64af19 100644
--- a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java
@@ -29,6 +29,7 @@
import java.io.IOException;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -98,4 +99,139 @@ void closingTheTransportStopsTheListenerRatherThanOnlyFlaggingIt() throws Except
"close must stop the loopback listener on its own port, got: "
+ implementation.getStoppedListeners());
}
+
+ /// A transport that parks inside open() until the test lets it through, so the test can
+ /// stop the server at a known point in the reader thread's progress.
+ private static final class SlowOpenTransport implements MCPTransport {
+ private final Object gate = new Object();
+ private boolean entered;
+ private boolean released;
+ /// When set, readMessage parks until close(), so the server stays up as a real one
+ /// would instead of ending its loop the instant it starts.
+ private boolean holdReads;
+ volatile boolean opened;
+ volatile int closeCount;
+
+ SlowOpenTransport holdingReads() {
+ holdReads = true;
+ return this;
+ }
+
+ void awaitEntered() throws InterruptedException {
+ synchronized (gate) {
+ while (!entered) {
+ gate.wait(5000);
+ }
+ }
+ }
+
+ void release() {
+ synchronized (gate) {
+ released = true;
+ gate.notifyAll();
+ }
+ }
+
+ public void open() throws IOException {
+ synchronized (gate) {
+ entered = true;
+ gate.notifyAll();
+ while (!released) {
+ try {
+ gate.wait(5000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("interrupted");
+ }
+ }
+ }
+ opened = true;
+ }
+
+ public String readMessage() throws IOException {
+ synchronized (gate) {
+ while (holdReads && closeCount == 0) {
+ try {
+ gate.wait(5000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("interrupted");
+ }
+ }
+ }
+ return null;
+ }
+
+ public void writeMessage(String message) throws IOException {
+ }
+
+ public void close() {
+ synchronized (gate) {
+ closeCount++;
+ gate.notifyAll();
+ }
+ }
+ }
+
+ @Test
+ void stoppingWhileTheReaderThreadIsStillOpeningStillClosesTheTransport() throws Exception {
+ // start() hands the transport to a reader thread, so stop() can run before that
+ // thread finishes opening it. stop()'s close() then has nothing to release, and the
+ // transport goes on to finish opening with nobody left to close it. For the real
+ // socket transport that strands the process-wide registration, and every later
+ // server in the same JVM is refused with "already open on port".
+ MCPServer server = new MCPServer();
+ SlowOpenTransport transport = new SlowOpenTransport();
+ server.start(transport);
+ transport.awaitEntered();
+ server.stop();
+ transport.release();
+
+ // stop() already closed once, finding nothing open. The point of the fix is the
+ // second close, issued by the reader thread once it notices the server went away
+ // while it was opening -- that is what releases the registration it just took.
+ for (int i = 0; i < 500 && (!transport.opened || transport.closeCount < 2); i++) {
+ Thread.sleep(10);
+ }
+ assertTrue(transport.opened, "the reader thread should have completed open()");
+ assertTrue(transport.closeCount >= 2,
+ "the reader thread must close the transport it opened after stop() had "
+ + "already run, got closeCount=" + transport.closeCount);
+ assertFalse(server.isRunning(), "the server should not be running after stop()");
+ }
+
+ @Test
+ void restartingWhileTheOldReaderIsStillOpeningDoesNotOrphanTheOldTransport() throws Exception {
+ // The shape that actually stranded the registration in CI. The reader thread for a
+ // stopped server was still parked in open(); by the time it got through, a restart
+ // had replaced the server's transport. Reading liveness off the server's shared
+ // fields, that thread saw "running" -- so it served the transport it had been given,
+ // blocked in readMessage forever, and never closed it. Its process-wide registration
+ // then outlived the test class, and the next server on that port was refused.
+ MCPServer server = new MCPServer();
+ SlowOpenTransport orphan = new SlowOpenTransport();
+ server.start(orphan);
+ orphan.awaitEntered();
+ server.stop();
+
+ SlowOpenTransport replacement = new SlowOpenTransport().holdingReads();
+ server.start(replacement);
+ replacement.awaitEntered();
+ replacement.release();
+
+ // Only now does the first thread get out of open(), into a server that is running
+ // again -- over a different transport.
+ orphan.release();
+
+ for (int i = 0; i < 500 && (!orphan.opened || orphan.closeCount < 2); i++) {
+ Thread.sleep(10);
+ }
+ assertTrue(orphan.closeCount >= 2,
+ "the superseded transport must be closed by its own reader thread, got "
+ + "closeCount=" + orphan.closeCount);
+ assertTrue(server.isRunning(),
+ "the superseded thread must not stop the server that replaced it");
+
+ server.stop();
+ }
}
diff --git a/scripts/run-javase-cef-ffmpeg-smoke.py b/scripts/run-javase-cef-ffmpeg-smoke.py
index 8f21f2ab9a9..7909643c2e9 100644
--- a/scripts/run-javase-cef-ffmpeg-smoke.py
+++ b/scripts/run-javase-cef-ffmpeg-smoke.py
@@ -133,12 +133,18 @@ def install_runtime():
"-Plocal-dev-javase",
"install",
]
- # Maven Central intermittently serves 403/5xx from the runner's CDN edge and
- # Maven treats those as PERMANENT resolution failures (observed: junit-bom
- # 403 killed the whole smoke 53s in, before any project code built). Retry
- # the install a couple of times with a pause -- a genuine build failure
- # fails identically on every attempt.
- attempts = 3
+ # Maven Central intermittently serves 403/429/5xx from the runner's CDN edge
+ # and Maven treats those as PERMANENT resolution failures (observed: junit-bom
+ # 403 killed the whole smoke 53s in, before any project code built, and later
+ # a 429 killed it three times in a row while reading the root POM). Retry the
+ # install with a growing pause -- a genuine build failure fails identically on
+ # every attempt.
+ #
+ # The backoff has to grow. A flat 30s wait is inside the window Central is
+ # still rate limiting in, so all three attempts fail for the same reason and
+ # the retry buys nothing; a 429 in particular wants minutes, not seconds.
+ backoffs = [30, 120, 300]
+ attempts = len(backoffs) + 1
for attempt in range(1, attempts + 1):
try:
run(cmd, log_name="build-runtime.log", timeout=1800)
@@ -146,9 +152,11 @@ def install_runtime():
except RuntimeError:
if attempt == attempts:
raise
+ delay = backoffs[attempt - 1]
log(f"runtime install failed (attempt {attempt}/{attempts}); "
- "retrying in 30s in case Maven Central was throwing transient 403/5xx")
- time.sleep(30)
+ f"retrying in {delay}s in case Maven Central was throwing "
+ "transient 403/429/5xx")
+ time.sleep(delay)
def run_smoke_app(video_file: Path, screenshot_path: Path, status_path: Path):
From 907297eedb10f26e087c41ae43e2ef0cc827ae3f Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 14:32:57 +0300
Subject: [PATCH 11/96] Guard the failure path, and stop asserting for a
discarded key
SpotBugs on the iOS port flagged the UTF-8 fallback in bytes(): falling
back to the platform default would be worse than failing, since those
bytes are hashed and the server recomputes the hash over UTF-8. UTF-8 is
mandatory on every JVM, so the branch is unreachable and now says so.
Two review findings:
- nativeAssertionReady had no staleness check, unlike the key and
attestation callbacks. A reset landing while an assertion was in
flight -- typically because a concurrent request learned the backend
does not recognise the key -- would still hand back an assertion for
the key that was just discarded.
- isProtected() called the pluggable engine's isAvailable() unguarded,
and failOrContinue() consults it. An engine that throws there after
partial initialization would turn a fail-open host into a blocked
request, inverting the degradation contract. Unanswerable now means
unprotected.
setup-workspace.sh raises Maven's local-repository lock timeout. Both of
its builds run with -T 1C, and the default 30s wait is not always enough
on a cold cache under a loaded runner -- the Android script job died
with "Could not acquire lock(s)" having compiled nothing wrong. Waiting
longer costs nothing when there is no contention.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../codename1/security/shield/AppShield.java | 11 ++++++++++-
.../codename1/impl/ios/IOSDeviceIntegrity.java | 17 ++++++++++++++---
scripts/setup-workspace.sh | 11 +++++++++--
3 files changed, 33 insertions(+), 6 deletions(-)
diff --git a/CodenameOne/src/com/codename1/security/shield/AppShield.java b/CodenameOne/src/com/codename1/security/shield/AppShield.java
index d4e82146d6c..c7e15fa1cf6 100644
--- a/CodenameOne/src/com/codename1/security/shield/AppShield.java
+++ b/CodenameOne/src/com/codename1/security/shield/AppShield.java
@@ -147,7 +147,16 @@ private static void installNetworkGuard() {
/// True when a real attestation engine is present and available. False in an open-source or
/// unentitled build, and in the simulator unless simulation is switched on.
public static boolean isProtected() {
- return ShieldEngineRegistry.getEngine().isAvailable();
+ // Guarded because the engine is pluggable and this is consulted on the failure
+ // path: a partially initialized engine whose isAvailable() throws would turn a
+ // fail-open host into a blocked request, which is the exact inversion the
+ // degradation contract promises will not happen. Unanswerable means unprotected.
+ try {
+ return ShieldEngineRegistry.getEngine().isAvailable();
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
}
/// The active engine's name, for diagnostics and support logs.
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 720b2e2a39e..4257760e189 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -22,7 +22,6 @@
*/
package com.codename1.impl.ios;
-import com.codename1.io.Log;
import com.codename1.security.Hash;
import com.codename1.security.SecureStorage;
import com.codename1.ui.Display;
@@ -437,6 +436,14 @@ public static void nativeAssertionReady(final int requestId, final String assert
if (pending == null) {
return;
}
+ if (isStale(pending)) {
+ // A reset landed while this assertion was in flight -- typically because a
+ // concurrent request learned the backend does not recognise the key. Handing
+ // back an assertion for the key that was just discarded would send the
+ // server the very thing it already rejected.
+ fail(pending, "App Attest state was reset while this request was in flight");
+ return;
+ }
if (assertionB64 == null) {
fail(pending, "App Attest assertion returned no data");
return;
@@ -564,8 +571,12 @@ private static byte[] bytes(String s) {
try {
return s.getBytes("UTF-8");
} catch (java.io.UnsupportedEncodingException e) {
- Log.e(e);
- return s.getBytes();
+ // Every JVM is required to support UTF-8, so this cannot happen. Falling
+ // back to the platform default would be worse than failing: these bytes are
+ // hashed and the server recomputes the same hash over UTF-8, so a silent
+ // re-encode would produce an attestation that never verifies and no
+ // indication of why.
+ throw new IllegalStateException("UTF-8 is unavailable on this VM", e);
}
}
diff --git a/scripts/setup-workspace.sh b/scripts/setup-workspace.sh
index 0e51853980b..1171f7dcc83 100755
--- a/scripts/setup-workspace.sh
+++ b/scripts/setup-workspace.sh
@@ -225,13 +225,20 @@ if [ ! -d "$CN1_BINARIES/.git" ]; then
git clone --depth=1 --filter=blob:none https://github.com/codenameone/cn1-binaries "$CN1_BINARIES"
fi
+# Both builds below run with -T 1C, so several modules install into the local
+# repository at once and contend for its lock. Maven's default wait is 30 seconds,
+# and on a cold cache under a loaded runner that is not always enough -- the build
+# then dies with "Could not acquire lock(s)" having compiled nothing wrong. Waiting
+# longer costs nothing when there is no contention.
+MVN_LOCK_ARGS="-Daether.syncContext.named.time=300 -Daether.syncContext.named.timeUnit=SECONDS"
+
log "Building Codename One core modules"
-"$MAVEN_HOME/bin/mvn" -f maven/pom.xml -T 1C -Dmaven.javadoc.skip=true -Dmaven.source.skip=true -DskipTests -Djava.awt.headless=true -Dcn1.binaries="$CN1_BINARIES" -Dcodename1.platform=javase -P local-dev-javase,compile-android,!download-cn1-binaries install "$@"
+"$MAVEN_HOME/bin/mvn" -f maven/pom.xml $MVN_LOCK_ARGS -T 1C -Dmaven.javadoc.skip=true -Dmaven.source.skip=true -DskipTests -Djava.awt.headless=true -Dcn1.binaries="$CN1_BINARIES" -Dcodename1.platform=javase -P local-dev-javase,compile-android,!download-cn1-binaries install "$@"
log "Building Codename One Maven plugin"
"$MAVEN_HOME/bin/mvn" -f maven/pom.xml \
-pl codenameone-maven-plugin -am \
- -T 1C -Dmaven.javadoc.skip=true -Dmaven.source.skip=true \
+ $MVN_LOCK_ARGS -T 1C -Dmaven.javadoc.skip=true -Dmaven.source.skip=true \
-DskipTests -Djava.awt.headless=true \
-Dcn1.binaries="$CN1_BINARIES" \
-P !download-cn1-binaries \
From cba30f1a6a070f48ef2ee242d0273e1caa570798 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 15:05:01 +0300
Subject: [PATCH 12/96] Satisfy the PMD gate for the new shield code
The SpotBugs failure had been masking these: the quality gate prints and
exits on SpotBugs first, so the PMD wall only appeared once SpotBugs was
clean. All of it is in code this branch added.
AvoidUsingVolatile is the one with substance. NetworkManager's guard
field was volatile; the gate bans the modifier outright, and the core is
built for Java 5 semantics anyway. getNetworkGuard() is now synchronized
on the same monitor the two writers take, so the lock is what publishes
the write to the network thread.
The rest is mechanical: @Override on the interface implementations
across the shield package, foreach for the indexed array loops,
literals-first in the WebSocket reserved-header check, and the
unnecessary java.util.Vector qualification in parseGroupedCertificates.
PreserveStackTrace is annotated rather than restructured, on the throw
line where PMD anchors it. The exception being thrown is not a new one:
it is the original pin failure, recorded by the certificate callback
because that callback can only answer with a boolean. Rethrowing it is
what preserves its stack trace instead of losing it behind a generic
connection error.
Verified by running .github/scripts/generate-quality-report.py locally
rather than through another CI round trip.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../src/com/codename1/impl/WebSocketImpl.java | 6 +++---
.../src/com/codename1/io/ConnectionRequest.java | 11 +++++++----
.../src/com/codename1/io/NetworkManager.java | 10 +++++++---
.../com/codename1/security/shield/AppShield.java | 10 ++++++----
.../com/codename1/security/shield/HostPolicy.java | 1 +
.../src/com/codename1/security/shield/PinSet.java | 5 +++--
.../security/shield/ShieldNetworkGuard.java | 5 +++++
.../codename1/security/shield/ShieldSignal.java | 1 +
.../codename1/security/shield/ShieldSignals.java | 5 +++--
.../codename1/security/shield/ShieldStatus.java | 9 ++++++---
.../com/codename1/security/shield/ShieldToken.java | 1 +
.../security/shield/spi/DefaultEngineContext.java | 11 +++++++++++
.../security/shield/spi/UnprotectedEngine.java | 14 ++++++++++++--
13 files changed, 66 insertions(+), 23 deletions(-)
diff --git a/CodenameOne/src/com/codename1/impl/WebSocketImpl.java b/CodenameOne/src/com/codename1/impl/WebSocketImpl.java
index 2c2267b2d7f..29b204300bc 100644
--- a/CodenameOne/src/com/codename1/impl/WebSocketImpl.java
+++ b/CodenameOne/src/com/codename1/impl/WebSocketImpl.java
@@ -120,9 +120,9 @@ protected final void appendRequestHeaders(StringBuilder req) {
private static boolean isReservedHandshakeHeader(String name) {
String n = name.toLowerCase();
- return n.equals("host") || n.equals("upgrade") || n.equals("connection")
- || n.equals("sec-websocket-key") || n.equals("sec-websocket-version")
- || n.equals("sec-websocket-protocol") || n.equals("content-length");
+ return "host".equals(n) || "upgrade".equals(n) || "connection".equals(n)
+ || "sec-websocket-key".equals(n) || "sec-websocket-version".equals(n)
+ || "sec-websocket-protocol".equals(n) || "content-length".equals(n);
}
private static boolean containsCrLf(String s) {
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index b2555b0f587..c68d4bd44ae 100644
--- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java
+++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
@@ -1243,7 +1243,11 @@ boolean performOperationComplete() throws IOException {
if (pinFailure != null) {
IOException cause = pinFailure;
pinFailure = null;
- throw cause;
+ // Not a new exception: this is the original pin failure, recorded by the
+ // certificate callback because that callback can only answer with a
+ // boolean. Rethrowing it here is what preserves its stack trace, rather
+ // than losing it behind the generic connection error.
+ throw cause; //NOPMD rethrow of the recorded cause, see above
}
throw ioe;
} finally {
@@ -1698,11 +1702,10 @@ static SSLCertificate[] parseGroupedCertificates(String[] entries) {
// the network path, i.e. as an unrelated connection failure.
return new SSLCertificate[0];
}
- java.util.Vector out = new java.util.Vector();
+ Vector out = new Vector();
SSLCertificate current = null;
int index = 0;
- for (int i = 0; i < entries.length; i++) {
- String entry = entries[i];
+ for (String entry : entries) {
if (entry == null) {
continue;
}
diff --git a/CodenameOne/src/com/codename1/io/NetworkManager.java b/CodenameOne/src/com/codename1/io/NetworkManager.java
index f78d9abc1ce..b3d9302f481 100644
--- a/CodenameOne/src/com/codename1/io/NetworkManager.java
+++ b/CodenameOne/src/com/codename1/io/NetworkManager.java
@@ -207,7 +207,11 @@ public static NetworkManager getInstance() {
return INSTANCE;
}
- private static volatile NetworkGuard networkGuard;
+ /// Read through [#getNetworkGuard()], which synchronizes on the same monitor the two
+ /// writers below take. Not `volatile`: the core is built for Java 5 semantics and the
+ /// repo's PMD gate forbids the modifier outright, so the lock is what publishes the
+ /// write to the network thread.
+ private static NetworkGuard networkGuard;
private static boolean networkGuardSealed;
/// Installs the app-wide [NetworkGuard].
@@ -231,7 +235,7 @@ public static void setNetworkGuard(NetworkGuard guard) {
}
/// The installed guard, or null when none was installed.
- public static NetworkGuard getNetworkGuard() {
+ public static synchronized NetworkGuard getNetworkGuard() {
return networkGuard;
}
@@ -1168,7 +1172,7 @@ private boolean runCurrentRequest(@Async.Execute ConnectionRequest req) {
if (requestWasCompleted) {
req.complete = true;
}
- NetworkGuard guard = networkGuard;
+ NetworkGuard guard = getNetworkGuard();
if (guard != null) {
try {
guard.afterResponse(req, req.getResponseCode(), req.getGuardHeaders());
diff --git a/CodenameOne/src/com/codename1/security/shield/AppShield.java b/CodenameOne/src/com/codename1/security/shield/AppShield.java
index c7e15fa1cf6..30dedf8d0fd 100644
--- a/CodenameOne/src/com/codename1/security/shield/AppShield.java
+++ b/CodenameOne/src/com/codename1/security/shield/AppShield.java
@@ -199,6 +199,7 @@ public static AsyncResource fetchToken(final String bindingData) {
return result;
}
Display.getInstance().scheduleBackgroundTask(new Runnable() {
+ @Override
public void run() {
try {
ShieldToken token = ShieldEngineRegistry.getEngine().fetchToken(bindingData);
@@ -416,8 +417,8 @@ public static ShieldSignal[] getSignals() {
fromEngine = new ShieldSignal[0];
}
if (fromEngine != null) {
- for (int i = 0; i < fromEngine.length; i++) {
- ShieldSignals.add(fromEngine[i]);
+ for (ShieldSignal s : fromEngine) {
+ ShieldSignals.add(s);
}
}
return ShieldSignals.snapshot();
@@ -488,9 +489,10 @@ private static final class StatusDispatch implements Runnable {
this.status = status;
}
+ @Override
public void run() {
- for (int i = 0; i < targets.length; i++) {
- targets[i].statusChanged(status);
+ for (ShieldListener target : targets) {
+ target.statusChanged(status);
}
}
}
diff --git a/CodenameOne/src/com/codename1/security/shield/HostPolicy.java b/CodenameOne/src/com/codename1/security/shield/HostPolicy.java
index f76ddbf5db4..4c4f0cfde83 100644
--- a/CodenameOne/src/com/codename1/security/shield/HostPolicy.java
+++ b/CodenameOne/src/com/codename1/security/shield/HostPolicy.java
@@ -74,6 +74,7 @@ public boolean isNoOp() {
return !attachToken && !enforcePins;
}
+ @Override
public String toString() {
return "HostPolicy[token=" + attachToken + ", pins=" + enforcePins
+ ", onFailure=" + failureMode + "]";
diff --git a/CodenameOne/src/com/codename1/security/shield/PinSet.java b/CodenameOne/src/com/codename1/security/shield/PinSet.java
index 7728c75de9e..ba112b1d4db 100644
--- a/CodenameOne/src/com/codename1/security/shield/PinSet.java
+++ b/CodenameOne/src/com/codename1/security/shield/PinSet.java
@@ -160,8 +160,8 @@ public boolean matches(String host, String[] chainSpkiDigests) {
return false;
}
Vector pins = pinsFor(host);
- for (int i = 0; i < chainSpkiDigests.length; i++) {
- if (chainSpkiDigests[i] != null && pins.contains(chainSpkiDigests[i])) {
+ for (String digest : chainSpkiDigests) {
+ if (digest != null && pins.contains(digest)) {
return true;
}
}
@@ -173,6 +173,7 @@ public boolean isEmpty() {
return hostToPins.isEmpty();
}
+ @Override
public String toString() {
return "PinSet[version=" + version + ", hosts=" + hostToPins.size()
+ ", stale=" + isStale() + ", expired=" + isExpired() + "]";
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java b/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
index 9f9be57befb..61283554621 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
@@ -39,12 +39,14 @@
/// could only weaken this one.
final class ShieldNetworkGuard implements NetworkGuard {
+ @Override
public void beforeRequest(ConnectionRequest request) throws IOException {
// Also clears the header when the host is not protected, which is what
// stops a token following a cross-host redirect.
AppShield.attach(request);
}
+ @Override
public boolean isCertificateCheckRequired(String url) {
String host = AppShield.hostOf(url);
if (host == null || !AppShield.policyFor(host).isEnforcePins()) {
@@ -56,6 +58,7 @@ public boolean isCertificateCheckRequired(String url) {
return AppShield.getPinSet().isEnforcedFor(host);
}
+ @Override
public void checkCertificates(ConnectionRequest request,
ConnectionRequest.SSLCertificate[] certificates) throws IOException {
String host = AppShield.hostOf(request.getUrl());
@@ -86,10 +89,12 @@ public void checkCertificates(ConnectionRequest request,
}
}
+ @Override
public String[] interestingResponseHeaders() {
return new String[] {AppShield.REJECT_HEADER};
}
+ @Override
public void afterResponse(ConnectionRequest request, int responseCode, String[] headers) {
if (responseCode != 401 && responseCode != 403) {
return;
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldSignal.java b/CodenameOne/src/com/codename1/security/shield/ShieldSignal.java
index 13c6c6f4a15..d8d0cdc7b2a 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldSignal.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldSignal.java
@@ -79,6 +79,7 @@ public long getTimestamp() {
return timestamp;
}
+ @Override
public String toString() {
return id + "(" + severity + (detail == null ? "" : ", " + detail) + ")";
}
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldSignals.java b/CodenameOne/src/com/codename1/security/shield/ShieldSignals.java
index 7a4efd76898..51116188873 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldSignals.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldSignals.java
@@ -145,9 +145,10 @@ private static final class SignalDispatch implements Runnable {
this.signal = signal;
}
+ @Override
public void run() {
- for (int i = 0; i < targets.length; i++) {
- targets[i].signalRaised(signal);
+ for (ShieldListener target : targets) {
+ target.signalRaised(signal);
}
}
}
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldStatus.java b/CodenameOne/src/com/codename1/security/shield/ShieldStatus.java
index b2e2c891d90..bf43f91eec1 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldStatus.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldStatus.java
@@ -105,18 +105,20 @@ public static ShieldStatus forId(String id) {
if (id == null) {
return NOT_INITIALIZED;
}
- for (int i = 0; i < KNOWN.length; i++) {
- if (KNOWN[i].id.equals(id)) {
- return KNOWN[i];
+ for (ShieldStatus known : KNOWN) {
+ if (known.id.equals(id)) {
+ return known;
}
}
return new ShieldStatus(id, false);
}
+ @Override
public String toString() {
return id;
}
+ @Override
public boolean equals(Object o) {
if (this == o) {
return true;
@@ -127,6 +129,7 @@ public boolean equals(Object o) {
return id.equals(((ShieldStatus) o).id);
}
+ @Override
public int hashCode() {
return id.hashCode();
}
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldToken.java b/CodenameOne/src/com/codename1/security/shield/ShieldToken.java
index e6cdfa9ba45..d8fc4f250d9 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldToken.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldToken.java
@@ -129,6 +129,7 @@ public long getFetchedAt() {
}
/// Never renders the token value -- these strings end up in logs.
+ @Override
public String toString() {
return "ShieldToken[status=" + status.getId()
+ ", fetchedAt=" + fetchedAt
diff --git a/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java b/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java
index 35c1c8a3756..ad05f995161 100644
--- a/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java
+++ b/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java
@@ -42,14 +42,17 @@ final class DefaultEngineContext implements EngineContext {
private DefaultEngineContext() {
}
+ @Override
public SecureStorage getSecureStorage() {
return SecureStorage.getInstance();
}
+ @Override
public AsyncResource requestPlatformAttestation(String nonce) {
return DeviceIntegrity.requestIntegrityToken(nonce);
}
+ @Override
public boolean isPlatformAttestationSupported() {
try {
return DeviceIntegrity.isAttestationSupported();
@@ -58,6 +61,7 @@ public boolean isPlatformAttestationSupported() {
}
}
+ @Override
public void resetPlatformAttestation() {
try {
DeviceIntegrity.resetAttestation();
@@ -66,6 +70,7 @@ public void resetPlatformAttestation() {
}
}
+ @Override
public void confirmPlatformAttestation() {
try {
DeviceIntegrity.confirmAttestation();
@@ -74,6 +79,7 @@ public void confirmPlatformAttestation() {
}
}
+ @Override
public String[] getPlatformCompromiseReasons() {
try {
String[] r = DeviceIntegrity.getCompromiseReasons();
@@ -83,6 +89,7 @@ public String[] getPlatformCompromiseReasons() {
}
}
+ @Override
public String[] getEnabledAccessibilityServices() {
try {
String[] r = DeviceIntegrity.getEnabledAccessibilityServices();
@@ -92,6 +99,7 @@ public String[] getEnabledAccessibilityServices() {
}
}
+ @Override
public String[] getAppSignerDigests() {
try {
String[] r = Display.getInstance().getAppSignerDigests();
@@ -101,6 +109,7 @@ public String[] getAppSignerDigests() {
}
}
+ @Override
public String getProperty(String key, String defaultValue) {
try {
return Display.getInstance().getProperty(key, defaultValue);
@@ -109,10 +118,12 @@ public String getProperty(String key, String defaultValue) {
}
}
+ @Override
public void log(String message) {
Log.p(message);
}
+ @Override
public void publishSignal(ShieldSignal signal) {
ShieldSignals.add(signal);
}
diff --git a/CodenameOne/src/com/codename1/security/shield/spi/UnprotectedEngine.java b/CodenameOne/src/com/codename1/security/shield/spi/UnprotectedEngine.java
index d29b2b8aed7..bf0f546c140 100644
--- a/CodenameOne/src/com/codename1/security/shield/spi/UnprotectedEngine.java
+++ b/CodenameOne/src/com/codename1/security/shield/spi/UnprotectedEngine.java
@@ -54,14 +54,17 @@ final class UnprotectedEngine implements ShieldEngine {
private UnprotectedEngine() {
}
+ @Override
public String getName() {
return "unprotected";
}
+ @Override
public boolean isAvailable() {
return false;
}
+ @Override
public void initialize(EngineContext ctx, ShieldConfig config) {
if (ctx != null) {
ctx.log("AppShield: no attestation engine registered; running unprotected. "
@@ -69,23 +72,28 @@ public void initialize(EngineContext ctx, ShieldConfig config) {
}
}
+ @Override
public ShieldToken fetchToken(String bindingData) throws ShieldException {
throw new ShieldException(ShieldStatus.UNPROTECTED,
"This build has no attestation engine, so no token can be issued.");
}
+ @Override
public ShieldToken getCachedToken() {
return null;
}
+ @Override
public boolean verifyPins(String host, String[] spkiDigests, String[] certDigests) {
return true;
}
+ @Override
public PinSet getPinSet() {
return PinSet.EMPTY;
}
+ @Override
public ShieldSignal[] collectSignals() {
String[] reasons;
try {
@@ -99,8 +107,8 @@ public ShieldSignal[] collectSignals() {
return new ShieldSignal[0];
}
Vector out = new Vector();
- for (int i = 0; i < reasons.length; i++) {
- ShieldSignal s = toSignal(reasons[i]);
+ for (String reason : reasons) {
+ ShieldSignal s = toSignal(reason);
if (s != null) {
out.addElement(s);
}
@@ -131,9 +139,11 @@ private static ShieldSignal toSignal(String reason) {
return new ShieldSignal(reason, 50, null);
}
+ @Override
public void invalidate() {
}
+ @Override
public void shutdown() {
}
}
From 2a9246ccc5b30e473ed44ee77e4c216460e35638 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 15:59:10 +0300
Subject: [PATCH 13/96] Close five races and bypasses in the attestation client
iOS App Attest callbacks:
- The staleness check and the state writes it guards now happen inside
flowLock, the lock resetAttestation() takes. Split apart, a reset
landing between them deleted the key and bumped the generation, these
writes put the deleted key back, and attestKey() then registered it
under the new generation -- so every later staleness check accepted
the bootstrap that was supposed to have been abandoned. fail() only
schedules onto the EDT, so calling it under the lock is safe.
- SecureStorage.set() results are checked. A refused keychain write left
the key attested and delivered for registration while the next request
could not find it, so it generated another rate-limited hardware key,
and another, until Apple throttled the device -- the exact failure
attest-once exists to avoid. A failed write now aborts the bootstrap
and fails the queued waiters with it.
Pinning on iOS: setInsecure(true) returned from the authentication
challenge before the chain was ever offered to Java, so a host with
enforced pins accepted any certificate at all as long as the request
happened to be insecure. The chain is now collected and vetoed first;
only after Java approves does the insecure path substitute useCredential
for default handling. That keeps setInsecure meaning "override OS trust
evaluation for a self-signed server" rather than "stop looking".
Unaffected for ordinary insecure requests, which return true from the
callback when no inspection is required.
Android SecureStorage:
- plainPrefs() resolves through getContext() instead of requiring an
Activity. A port initialized from a background service has no Activity
but does have a context, and this tier exists precisely so a
background caller can read a cached secret -- get() called it outside
its try/catch, so Secrets.get() crashed rather than returning the
value.
- The whole load-check-generate on CN1PlainKey is serialized, not just
the generation. Two first writers could each generate under the alias,
and the second generation replaces the key the first already encrypted
with, leaving that ciphertext permanently undecryptable.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/android/AndroidSecureStorage.java | 108 +++++++++++++-----
.../nativeSources/NetworkConnectionImpl.m | 25 ++--
.../impl/ios/IOSDeviceIntegrity.java | 92 ++++++++++-----
3 files changed, 160 insertions(+), 65 deletions(-)
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
index 556273f1664..3089c805405 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
@@ -99,6 +99,13 @@ public final class AndroidSecureStorage extends SecureStorage {
*/
private static final String PLAIN_KEY_ID = "CN1PlainKey";
private static final String PLAIN_PREFS = "CN1PlainSecureStorage";
+
+ /**
+ * Serializes load-check-generate on the non-prompting key, and the shared
+ * AndroidKeyStore handle with it. Static because the keystore alias is
+ * process-wide, so two instances would race just as two threads would.
+ */
+ private static final Object PLAIN_KEY_LOCK = new Object();
private static final int GCM_TAG_BITS = 128;
private KeyStore keyStore;
@@ -220,7 +227,11 @@ public boolean set(String account, String value) {
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, key);
byte[] enc = c.doFinal(value.getBytes("UTF-8"));
- plainPrefs().edit()
+ SharedPreferences prefs = plainPrefs();
+ if (prefs == null) {
+ return false;
+ }
+ prefs.edit()
.putString(account, Base64.encodeToString(c.getIV(), Base64.NO_WRAP)
+ ":" + Base64.encodeToString(enc, Base64.NO_WRAP))
.apply();
@@ -243,7 +254,11 @@ public String get(String account) {
if (Build.VERSION.SDK_INT < 23) {
return legacyPlainGet(account);
}
- String stored = plainPrefs().getString(account, null);
+ SharedPreferences prefs = plainPrefs();
+ if (prefs == null) {
+ return null;
+ }
+ String stored = prefs.getString(account, null);
if (stored == null) {
return null;
}
@@ -282,13 +297,30 @@ public boolean remove(String account) {
if (account == null) {
return false;
}
- plainPrefs().edit().remove(account).apply();
+ SharedPreferences prefs = plainPrefs();
+ if (prefs == null) {
+ return false;
+ }
+ prefs.edit().remove(account).apply();
return true;
}
+ /**
+ * The preferences file, resolved from the application context rather than an
+ * Activity.
+ *
+ * A port initialized from a background service has no Activity but does
+ * have a context, and this tier exists precisely so a background caller can
+ * read a cached secret without prompting. Requiring an Activity would make
+ * {@code get()} throw there -- outside its try/catch, so the caller crashes
+ * rather than reading the value it asked for.
+ */
private SharedPreferences plainPrefs() {
- return AndroidNativeUtil.getActivity()
- .getApplicationContext()
+ Context ctx = AndroidNativeUtil.getContext();
+ if (ctx == null) {
+ return null;
+ }
+ return ctx.getApplicationContext()
.getSharedPreferences(PLAIN_PREFS, Context.MODE_PRIVATE);
}
@@ -298,31 +330,43 @@ private SharedPreferences plainPrefs() {
* generation fails.
*/
private SecretKey plainKey(boolean create) throws Exception {
- keyStore().load(null);
- SecretKey existing = (SecretKey) keyStore.getKey(PLAIN_KEY_ID, null);
- if (existing != null || !create) {
- return existing;
- }
- KeyGenerator gen = KeyGenerator.getInstance(
- KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE);
- gen.init(new KeyGenParameterSpec.Builder(PLAIN_KEY_ID,
- KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
- .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
- .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
- .setKeySize(256)
- .setRandomizedEncryptionRequired(true)
- .build());
- gen.generateKey();
- return (SecretKey) keyStore.getKey(PLAIN_KEY_ID, null);
+ // The whole load-check-generate sequence is serialized, not just the
+ // generation. Two first writers that each saw the alias absent would each
+ // generate under it, and the second generation replaces the key the first
+ // one had already encrypted with -- leaving that ciphertext permanently
+ // undecryptable. The shared KeyStore is not thread safe either.
+ synchronized (PLAIN_KEY_LOCK) {
+ keyStore().load(null);
+ SecretKey existing = (SecretKey) keyStore.getKey(PLAIN_KEY_ID, null);
+ if (existing != null || !create) {
+ return existing;
+ }
+ KeyGenerator gen = KeyGenerator.getInstance(
+ KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE);
+ gen.init(new KeyGenParameterSpec.Builder(PLAIN_KEY_ID,
+ KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(256)
+ .setRandomizedEncryptionRequired(true)
+ .build());
+ gen.generateKey();
+ return (SecretKey) keyStore.getKey(PLAIN_KEY_ID, null);
+ }
}
private void resetPlainKey() {
- try {
- keyStore().deleteEntry(PLAIN_KEY_ID);
- } catch (KeyStoreException e) {
- Log.e(e);
+ synchronized (PLAIN_KEY_LOCK) {
+ try {
+ keyStore().deleteEntry(PLAIN_KEY_ID);
+ } catch (KeyStoreException e) {
+ Log.e(e);
+ }
+ }
+ SharedPreferences prefs = plainPrefs();
+ if (prefs != null) {
+ prefs.edit().clear().apply();
}
- plainPrefs().edit().clear().apply();
}
// API 22 and below have no KeyGenParameterSpec. The preferences file is
@@ -331,7 +375,11 @@ private void resetPlainKey() {
private boolean legacyPlainSet(String account, String value) {
warnLegacyPlainStorage();
try {
- plainPrefs().edit()
+ SharedPreferences prefs = plainPrefs();
+ if (prefs == null) {
+ return false;
+ }
+ prefs.edit()
.putString(account, Base64.encodeToString(
value.getBytes("UTF-8"), Base64.NO_WRAP))
.apply();
@@ -344,7 +392,11 @@ private boolean legacyPlainSet(String account, String value) {
private String legacyPlainGet(String account) {
warnLegacyPlainStorage();
- String stored = plainPrefs().getString(account, null);
+ SharedPreferences prefs = plainPrefs();
+ if (prefs == null) {
+ return null;
+ }
+ String stored = prefs.getString(account, null);
if (stored == null) {
return null;
}
diff --git a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
index 83d1d79e9a6..412a99070f4 100644
--- a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
+++ b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
@@ -166,11 +166,12 @@ -(void) connection: (NSURLConnection*)connection willSendRequestForAuthenticatio
SecTrustRef trustRef = [[challenge protectionSpace] serverTrust];
SecTrustEvaluate(trustRef, NULL);
NSMutableString* certs = [NSMutableString string];
- if (insecure) {
- [[challenge sender] useCredential:[NSURLCredential credentialForTrust:[[challenge protectionSpace] serverTrust]] forAuthenticationChallenge:challenge];
- return;
- }
- //[connection cancel];
+ // The chain is collected and offered to Java even for an insecure request. An
+ // insecure request asks us to accept a certificate the OS would reject -- a
+ // self-signed development server -- and that is a decision about OS trust
+ // evaluation, not a decision to stop looking. Returning here meant a host with
+ // enforced pins accepted any certificate at all as long as the request happened
+ // to be insecure, which is the opposite of what pinning is for.
CFIndex count = SecTrustGetCertificateCount(trustRef);
for (int i=0; i
Date: Wed, 29 Jul 2026 17:09:01 +0300
Subject: [PATCH 14/96] Fold hostnames by ASCII, and close two more reset races
Host matching used String.toLowerCase(), which folds by device locale.
Under the Turkish locale an uppercase ASCII I becomes the dotless i, so
a request to API.example.com stops matching a policy registered as
api.example.com -- and the failure is silent: the host simply looks
unprotected, so no token is attached and no pin is enforced, on exactly
the devices the developer never tests with. All seven sites in the
shield package now go through ShieldHosts, which folds ASCII by hand.
That removes the locale from the question rather than passing
Locale.ENGLISH everywhere, and keeps working on the ports with a reduced
java.util.Locale.
iOS App Attest, two more of the same family as the last round:
- resetAttestation() removed the stored state before taking flowLock,
leaving a window where a callback could take the lock, pass its
staleness check -- the generation had not been bumped yet -- and write
the discarded key straight back. The reset then marked only that
callback stale and left the resurrected key for the next bootstrap.
The lock is now held across the removals and the generation bump.
- A failed KEY_PENDING_SINCE write was ignored, and
registrationGraceRemaining() reads a missing timestamp as an expired
window -- so the next request promoted the key to attested and
asserted against a key no backend had acknowledged. It now rolls the
state back to new and fails the bootstrap.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../codename1/security/shield/AppShield.java | 8 +-
.../com/codename1/security/shield/PinSet.java | 2 +-
.../security/shield/ShieldConfig.java | 4 +-
.../security/shield/ShieldHosts.java | 81 +++++++++++++++++++
.../impl/ios/IOSDeviceIntegrity.java | 31 +++++--
5 files changed, 113 insertions(+), 13 deletions(-)
create mode 100644 CodenameOne/src/com/codename1/security/shield/ShieldHosts.java
diff --git a/CodenameOne/src/com/codename1/security/shield/AppShield.java b/CodenameOne/src/com/codename1/security/shield/AppShield.java
index 30dedf8d0fd..9504bdbedfd 100644
--- a/CodenameOne/src/com/codename1/security/shield/AppShield.java
+++ b/CodenameOne/src/com/codename1/security/shield/AppShield.java
@@ -289,7 +289,7 @@ public static void attach(ConnectionRequest request) throws ShieldException {
/// True for an absolute https URL. Anything else -- http, or a relative URL we cannot
/// classify -- is not somewhere a bearer token belongs.
static boolean isSecure(String url) {
- return url != null && url.toLowerCase().startsWith("https://");
+ return ShieldHosts.startsWithIgnoreCase(url, "https://");
}
private static void failOrContinue(HostPolicy policy, ShieldException e) throws ShieldException {
@@ -353,7 +353,7 @@ public static void addProtectedHost(String host, HostPolicy policy) {
// Same rule as ShieldConfig.protect: an omitted policy has to pick up
// the configured default failure mode, or setting a fail-closed
// default silently does nothing on this path too.
- runtimeHosts.put(host.toLowerCase(),
+ runtimeHosts.put(ShieldHosts.normalize(host),
policy == null ? implicitPolicy() : policy);
}
}
@@ -378,7 +378,7 @@ public static HostPolicy policyFor(String host) {
if (host == null) {
return HostPolicy.UNPROTECTED;
}
- Object runtime = runtimeHosts.get(host.toLowerCase());
+ Object runtime = runtimeHosts.get(ShieldHosts.normalize(host));
if (runtime != null) {
return (HostPolicy) runtime;
}
@@ -526,7 +526,7 @@ static String hostOf(String url) {
if (colon >= 0 && authority.indexOf(']') < colon) {
authority = authority.substring(0, colon);
}
- return authority.length() == 0 ? null : authority.toLowerCase();
+ return authority.length() == 0 ? null : ShieldHosts.normalize(authority);
}
private static com.codename1.security.shield.spi.EngineContext contextForEngine() {
diff --git a/CodenameOne/src/com/codename1/security/shield/PinSet.java b/CodenameOne/src/com/codename1/security/shield/PinSet.java
index ba112b1d4db..f28b8c8d650 100644
--- a/CodenameOne/src/com/codename1/security/shield/PinSet.java
+++ b/CodenameOne/src/com/codename1/security/shield/PinSet.java
@@ -107,7 +107,7 @@ public Vector pinsFor(String host) {
if (host == null) {
return null;
}
- String h = host.toLowerCase();
+ String h = ShieldHosts.normalize(host);
Object exact = hostToPins.get(h);
if (exact != null) {
return copyOf((Vector) exact);
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java b/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
index e1ed7b6368d..6fc70b2cb28 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
@@ -100,7 +100,7 @@ public ShieldConfig collectSignals(boolean collect) {
/// subdomains. Hosts not registered here are never touched.
public ShieldConfig protect(String hostPattern, HostPolicy policy) {
if (hostPattern != null && hostPattern.length() > 0) {
- hostPolicies.put(hostPattern.toLowerCase(),
+ hostPolicies.put(ShieldHosts.normalize(hostPattern),
policy == null ? implicitPolicy() : policy);
}
return this;
@@ -152,7 +152,7 @@ public HostPolicy policyFor(String host) {
if (host == null) {
return HostPolicy.UNPROTECTED;
}
- String h = host.toLowerCase();
+ String h = ShieldHosts.normalize(host);
Object exact = hostPolicies.get(h);
if (exact != null) {
return (HostPolicy) exact;
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldHosts.java b/CodenameOne/src/com/codename1/security/shield/ShieldHosts.java
new file mode 100644
index 00000000000..2cdb7f6a69b
--- /dev/null
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldHosts.java
@@ -0,0 +1,81 @@
+/*
+ * 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.shield;
+
+/// Case folding for hostnames and URL schemes, fixed to ASCII.
+///
+/// #### Why not `String.toLowerCase()`
+///
+/// It folds using the device's locale. Under the Turkish locale an uppercase ASCII `I` becomes the
+/// dotless `ı`, so a request to `API.example.com` stops matching a policy registered as
+/// `api.example.com`. The consequence is not a display glitch: the host silently looks unprotected,
+/// so no token is attached and no pin is enforced -- on precisely the devices whose users the
+/// developer never tests with.
+///
+/// `toLowerCase(Locale.ENGLISH)` would fix it too, but hostnames and URL schemes are ASCII by
+/// definition, so folding them by hand removes the locale from the question entirely and keeps this
+/// working on the ports with a reduced `java.util.Locale`.
+final class ShieldHosts {
+
+ private ShieldHosts() {
+ }
+
+ /// Lowercases the ASCII letters and leaves every other character alone. Null in, null out.
+ static String normalize(String s) {
+ if (s == null) {
+ return null;
+ }
+ int len = s.length();
+ StringBuilder sb = null;
+ for (int i = 0; i < len; i++) {
+ char c = s.charAt(i);
+ if (c >= 'A' && c <= 'Z') {
+ if (sb == null) {
+ sb = new StringBuilder(len);
+ sb.append(s, 0, i);
+ }
+ sb.append((char) (c + 32));
+ } else if (sb != null) {
+ sb.append(c);
+ }
+ }
+ return sb == null ? s : sb.toString();
+ }
+
+ /// True when the URL carries the given lowercase ASCII scheme prefix, whatever case it is in.
+ static boolean startsWithIgnoreCase(String value, String lowerPrefix) {
+ if (value == null || lowerPrefix == null || value.length() < lowerPrefix.length()) {
+ return false;
+ }
+ for (int i = 0; i < lowerPrefix.length(); i++) {
+ char c = value.charAt(i);
+ if (c >= 'A' && c <= 'Z') {
+ c = (char) (c + 32);
+ }
+ if (c != lowerPrefix.charAt(i)) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 83825c4a1f0..29399abd3ed 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -163,12 +163,18 @@ boolean isSupported() {
* restore to a new device, or an OS-side invalidation.
*/
void resetAttestation() {
- SecureStorage store = SecureStorage.getInstance();
- store.remove(KEY_ID);
- store.remove(KEY_STATE);
- store.remove(KEY_RETRY_AFTER);
- store.remove(KEY_PENDING_SINCE);
+ // The lock is taken BEFORE the removals, so deleting the state and invalidating
+ // the generation are one step. Removing first left a window in which a callback
+ // could take the lock, pass its staleness check -- the generation had not been
+ // bumped yet -- and write the discarded key straight back. The reset would then
+ // mark only that callback stale and leave the resurrected key behind for the
+ // next bootstrap to find.
synchronized (flowLock) {
+ SecureStorage store = SecureStorage.getInstance();
+ store.remove(KEY_ID);
+ store.remove(KEY_STATE);
+ store.remove(KEY_RETRY_AFTER);
+ store.remove(KEY_PENDING_SINCE);
currentBackoff = MIN_BACKOFF_MILLIS;
bootstrapInFlight = false;
// Any callback still outstanding now belongs to an abandoned flow.
@@ -446,7 +452,20 @@ public static void nativeAttestationReady(final int requestId, final String atte
"App Attest could not record its attestation state");
return;
}
- store.set(KEY_PENDING_SINCE, Long.toString(System.currentTimeMillis()));
+ if (!store.set(KEY_PENDING_SINCE, Long.toString(System.currentTimeMillis()))) {
+ // Part of the same state transition, not a nicety: with no timestamp,
+ // registrationGraceRemaining() reads the window as already expired, so
+ // the very next request promotes the key to attested and asserts against
+ // a key no backend has acknowledged -- the first-use rejection and
+ // pointless key reset this state exists to prevent. Roll back to new so
+ // the key is attested again rather than used prematurely.
+ store.set(KEY_STATE, STATE_NEW);
+ instance.bootstrapInFlight = false;
+ fail(pending, "App Attest could not record its registration deadline");
+ instance.failBootstrapWaiters(
+ "App Attest could not record its registration deadline");
+ return;
+ }
instance.currentBackoff = MIN_BACKOFF_MILLIS;
instance.bootstrapInFlight = false;
// Deliberately NOT asserted here, for the reason above. Queued callers
From 598df78867ddba66a97fe954a5a8f262912c8d89 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 17:24:49 +0300
Subject: [PATCH 15/96] Close the recovery race, stale guard state, and a
legacy read
Invalid-key recovery discarded the identity and claimed the replacement
bootstrap in two separate lock acquisitions. In the gap a concurrent
requestToken() saw no key and no bootstrap running, started its own
generation, and then the recovery path started another -- two
rate-limited hardware keys racing to become the persisted identity.
resetLocked() lets both happen under one acquisition.
A reused ConnectionRequest retained its response code and guard headers,
so an attempt that failed before reaching a response, or completed from
the offline cache, replayed the earlier 401 and its rejection header to
afterResponse() -- invalidating a token that was never refused. Both are
cleared at the top of each attempt, and afterResponse only runs when the
attempt actually observed a response.
A value first written on API 22 or below has no IV separator, so after an
OS upgrade to 23+ the AES path read it and reported the secret missing
forever. It is now decoded and re-stored encrypted, so the upgrade costs
one migration rather than a cached credential.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/io/ConnectionRequest.java | 18 ++++++++
.../src/com/codename1/io/NetworkManager.java | 2 +-
.../impl/android/AndroidSecureStorage.java | 21 ++++++++-
.../impl/ios/IOSDeviceIntegrity.java | 45 ++++++++++++-------
4 files changed, 67 insertions(+), 19 deletions(-)
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index c68d4bd44ae..3c5ba958130 100644
--- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java
+++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
@@ -972,6 +972,12 @@ boolean performOperationComplete() throws IOException {
// certificate on a retried request, or rejecting a redirect to a
// differently pinned host.
sslCertificates = null;
+ // Same reasoning for the guard's view of the response. A ConnectionRequest can
+ // be reused, and a retained 401 plus its rejection header would be replayed to
+ // afterResponse() by an attempt that failed before reaching a response or was
+ // served from the cache -- invalidating a token that was never refused.
+ guardHeaders = null;
+ guardResponseCaptured = false;
if (cacheMode == CachingMode.OFFLINE || cacheMode == CachingMode.OFFLINE_FIRST) {
InputStream is = null; //NOPMD CloseResource
try {
@@ -1351,9 +1357,15 @@ protected void readHeaders(Object connection) throws IOException {
/// is still open because it is closed before `afterResponse` runs.
private String[] guardHeaders;
+ /// Whether the current attempt got far enough to have a response of its own. Guards the
+ /// reuse case: without it a retained response code from an earlier attempt is reported
+ /// as though it belonged to this one.
+ private boolean guardResponseCaptured;
+
private void captureGuardHeaders(Object connection) {
NetworkGuard guard = NetworkManager.getNetworkGuard();
guardHeaders = null;
+ guardResponseCaptured = true;
if (guard == null) {
return;
}
@@ -1377,6 +1389,12 @@ String[] getGuardHeaders() {
return guardHeaders;
}
+ /// True when this attempt actually observed a response, so its code and headers describe
+ /// this request rather than a previous use of the same object.
+ boolean hasGuardResponse() {
+ return guardResponseCaptured;
+ }
+
/// Allows reading the headers from the connection by calling the getHeader() method when a response that isn't 200 OK is sent.
///
/// #### Parameters
diff --git a/CodenameOne/src/com/codename1/io/NetworkManager.java b/CodenameOne/src/com/codename1/io/NetworkManager.java
index b3d9302f481..1bf744f59a7 100644
--- a/CodenameOne/src/com/codename1/io/NetworkManager.java
+++ b/CodenameOne/src/com/codename1/io/NetworkManager.java
@@ -1173,7 +1173,7 @@ private boolean runCurrentRequest(@Async.Execute ConnectionRequest req) {
req.complete = true;
}
NetworkGuard guard = getNetworkGuard();
- if (guard != null) {
+ if (guard != null && req.hasGuardResponse()) {
try {
guard.afterResponse(req, req.getResponseCode(), req.getGuardHeaders());
} catch (Throwable t) {
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
index 3089c805405..7d7b20b375f 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
@@ -264,7 +264,16 @@ public String get(String account) {
}
int sep = stored.indexOf(':');
if (sep < 0) {
- return null;
+ // No IV separator, so this was written by legacyPlainSet on API 22 or below
+ // and the device has since been upgraded to 23+. Reporting it missing would
+ // silently discard a cached credential across an OS upgrade the user did not
+ // choose to lose anything by. Decode it and re-store it encrypted, so this
+ // only happens once.
+ String legacy = decodeLegacyPlain(stored);
+ if (legacy != null) {
+ set(account, legacy);
+ }
+ return legacy;
}
try {
SecretKey key = plainKey(false);
@@ -390,6 +399,16 @@ private boolean legacyPlainSet(String account, String value) {
}
}
+ /** The obfuscated-only form written on API 22 and below, or null if unreadable. */
+ private String decodeLegacyPlain(String stored) {
+ try {
+ return new String(Base64.decode(stored, Base64.NO_WRAP), "UTF-8");
+ } catch (Throwable t) {
+ Log.e(t);
+ return null;
+ }
+ }
+
private String legacyPlainGet(String account) {
warnLegacyPlainStorage();
SharedPreferences prefs = plainPrefs();
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 29399abd3ed..f205ed5d8b5 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -170,19 +170,25 @@ void resetAttestation() {
// mark only that callback stale and leave the resurrected key behind for the
// next bootstrap to find.
synchronized (flowLock) {
- SecureStorage store = SecureStorage.getInstance();
- store.remove(KEY_ID);
- store.remove(KEY_STATE);
- store.remove(KEY_RETRY_AFTER);
- store.remove(KEY_PENDING_SINCE);
- currentBackoff = MIN_BACKOFF_MILLIS;
- bootstrapInFlight = false;
- // Any callback still outstanding now belongs to an abandoned flow.
- generation++;
- failBootstrapWaiters("App Attest state was reset while a bootstrap was in flight");
+ resetLocked();
}
}
+ /// The reset itself, for callers that must not release the lock between discarding
+ /// the old identity and starting the replacement. Caller holds `flowLock`.
+ private void resetLocked() {
+ SecureStorage store = SecureStorage.getInstance();
+ store.remove(KEY_ID);
+ store.remove(KEY_STATE);
+ store.remove(KEY_RETRY_AFTER);
+ store.remove(KEY_PENDING_SINCE);
+ currentBackoff = MIN_BACKOFF_MILLIS;
+ bootstrapInFlight = false;
+ // Any callback still outstanding now belongs to an abandoned flow.
+ generation++;
+ failBootstrapWaiters("App Attest state was reset while a bootstrap was in flight");
+ }
+
/**
* Acknowledges that a backend has recorded this device's attested public key,
* moving it from {@code pending} to {@code attested} so subsequent requests
@@ -514,17 +520,22 @@ public static void nativeAttestError(final int requestId, final int errorCode,
return;
}
if (errorCode == DC_ERROR_INVALID_KEY && instance != null && !pending.retried) {
- // The key is gone or was never valid. Wipe it and try once from
- // scratch; a second failure is reported rather than looped.
- instance.resetAttestation();
+ // The key is gone or was never valid. Wipe it and try once from scratch; a
+ // second failure is reported rather than looped.
+ //
+ // Discarding the identity and claiming the replacement bootstrap happen in
+ // ONE lock acquisition. Calling resetAttestation() and then reacquiring left
+ // a gap in which a concurrent requestToken() saw no key and no bootstrap
+ // running, started its own generation, and then this path started another --
+ // two rate-limited hardware keys racing to become the persisted identity.
synchronized (instance.flowLock) {
+ instance.resetLocked();
PendingRequest retry = new PendingRequest(pending.result, pending.nonce,
PendingRequest.OP_GENERATE_KEY, null);
retry.retried = true;
- // resetAttestation() clears the flag, so set it again before
- // starting the replacement: otherwise a request arriving before
- // the recovery callback sees no key and no bootstrap running,
- // generates a second rate-limited key and races this one.
+ // resetLocked() clears the flag, so set it again before starting the
+ // replacement -- still under the same lock, so nothing observes the
+ // cleared state.
instance.bootstrapInFlight = true;
int rid = register(retry);
instance.nativeInstance.appAttestGenerateKey(rid);
From 29c5da884350a4c93be2518fc75f042b9c223adf Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 18:31:41 +0300
Subject: [PATCH 16/96] Restore ASCII-only sources, and close three more
locale/lock gaps
The Android port build failed on "unmappable character for encoding
ASCII": my ShieldHosts doc comment contained a literal dotless i while
explaining the very bug it exists to prevent. javac compiles this tree
as ASCII, so the character is now described rather than shown.
Three review findings:
- nativeAttestError never checked the generation, so a stale callback
could reset an identity a newer bootstrap had just established and burn
another rate-limited key replacing it -- and a stale non-invalid-key
error could clear bootstrapInFlight for a generation it knew nothing
about, letting a concurrent request start a second key alongside the
first. Stale errors are now rejected before touching any shared state.
- confirmAttestation read the pending state outside the lock and
transitioned inside it, so a reset and replacement landing in between
would be stamped attested while Apple had not attested the new key.
The check moved inside the same block.
- ShieldHosts now drops a terminal DNS root dot. "api.example.com." is a
valid absolute name resolving to the same host, so keeping the dot left
a normally registered policy unmatched -- silently, as ever: no token,
no pin.
WebSocketImpl's reserved-header check still used toLowerCase(), which is
the same locale bug: under Turkish, CONNECTION does not fold to
connection, so a reserved header would be emitted alongside the
handshake's own and produce a conflicting or rejected handshake.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../src/com/codename1/impl/WebSocketImpl.java | 17 +++++++++++-
.../security/shield/ShieldHosts.java | 18 ++++++++++---
.../impl/ios/IOSDeviceIntegrity.java | 27 ++++++++++++++++---
3 files changed, 53 insertions(+), 9 deletions(-)
diff --git a/CodenameOne/src/com/codename1/impl/WebSocketImpl.java b/CodenameOne/src/com/codename1/impl/WebSocketImpl.java
index 29b204300bc..82ede32afca 100644
--- a/CodenameOne/src/com/codename1/impl/WebSocketImpl.java
+++ b/CodenameOne/src/com/codename1/impl/WebSocketImpl.java
@@ -119,12 +119,27 @@ protected final void appendRequestHeaders(StringBuilder req) {
}
private static boolean isReservedHandshakeHeader(String name) {
- String n = name.toLowerCase();
+ // ASCII folding, not toLowerCase(): under the Turkish and Azerbaijani locales an
+ // uppercase I folds to a dotless letter outside ASCII, so a header spelled
+ // CONNECTION would not be recognised as reserved and would be emitted alongside
+ // the handshake's own -- producing a conflicting or rejected opening handshake
+ // while the API promises reserved names are ignored.
+ String n = asciiLower(name);
return "host".equals(n) || "upgrade".equals(n) || "connection".equals(n)
|| "sec-websocket-key".equals(n) || "sec-websocket-version".equals(n)
|| "sec-websocket-protocol".equals(n) || "content-length".equals(n);
}
+ /// Lowercases ASCII letters only, so the result never depends on the device locale.
+ private static String asciiLower(String s) {
+ StringBuilder sb = new StringBuilder(s.length());
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ sb.append(c >= 'A' && c <= 'Z' ? (char) (c + 32) : c);
+ }
+ return sb.toString();
+ }
+
private static boolean containsCrLf(String s) {
return s.indexOf('\r') >= 0 || s.indexOf('\n') >= 0;
}
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldHosts.java b/CodenameOne/src/com/codename1/security/shield/ShieldHosts.java
index 2cdb7f6a69b..e6c62045c5e 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldHosts.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldHosts.java
@@ -26,9 +26,10 @@
///
/// #### Why not `String.toLowerCase()`
///
-/// It folds using the device's locale. Under the Turkish locale an uppercase ASCII `I` becomes the
-/// dotless `ı`, so a request to `API.example.com` stops matching a policy registered as
-/// `api.example.com`. The consequence is not a display glitch: the host silently looks unprotected,
+/// It folds using the device's locale. Under the Turkish locale an uppercase ASCII `I` becomes a
+/// dotless lowercase letter outside ASCII, so a request to `API.example.com` stops matching a
+/// policy registered as `api.example.com`. The consequence is not a display glitch: the host
+/// silently looks unprotected,
/// so no token is attached and no pin is enforced -- on precisely the devices whose users the
/// developer never tests with.
///
@@ -40,11 +41,20 @@ final class ShieldHosts {
private ShieldHosts() {
}
- /// Lowercases the ASCII letters and leaves every other character alone. Null in, null out.
+ /// Lowercases the ASCII letters and drops a terminal DNS root dot. Null in, null out.
+ ///
+ /// `https://api.example.com./` is a valid absolute name that resolves identically to
+ /// `api.example.com`, so keeping the dot would leave a normally registered policy unmatched --
+ /// and the failure is the silent kind: no token attached, no pin enforced. Applied to
+ /// configured, runtime, request and pin hosts alike, since they all pass through here.
static String normalize(String s) {
if (s == null) {
return null;
}
+ // Only one, and never on a bare "." -- an empty host is not an improvement.
+ if (s.length() > 1 && s.charAt(s.length() - 1) == '.') {
+ s = s.substring(0, s.length() - 1);
+ }
int len = s.length();
StringBuilder sb = null;
for (int i = 0; i < len; i++) {
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index f205ed5d8b5..a4ea6c44411 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -202,11 +202,15 @@ private void resetLocked() {
* point of attest-once is not to burn keys that way.
*/
void confirmAttestation() {
- SecureStorage store = SecureStorage.getInstance();
- if (!STATE_PENDING.equals(store.get(KEY_STATE))) {
- return;
- }
synchronized (flowLock) {
+ // Re-read inside the lock. Checking first and transitioning afterwards let a
+ // reset and a replacement key land in between, so this would stamp
+ // STATE_ATTESTED over a STATE_NEW key Apple has not attested yet -- and the
+ // next request would assert against it.
+ SecureStorage store = SecureStorage.getInstance();
+ if (!STATE_PENDING.equals(store.get(KEY_STATE))) {
+ return;
+ }
store.set(KEY_STATE, STATE_ATTESTED);
store.remove(KEY_PENDING_SINCE);
}
@@ -519,6 +523,21 @@ public static void nativeAttestError(final int requestId, final int errorCode,
if (pending == null) {
return;
}
+ if (instance != null) {
+ boolean stale;
+ synchronized (instance.flowLock) {
+ stale = isStale(pending);
+ }
+ if (stale) {
+ // Belongs to a flow that was already abandoned. Acting on it would reset
+ // the identity a newer bootstrap just established and burn another
+ // rate-limited key replacing it -- and for a non-invalid-key error it
+ // would clear bootstrapInFlight for a generation it knows nothing about,
+ // letting a concurrent request start a second key alongside the first.
+ fail(pending, "App Attest state was reset while this request was in flight");
+ return;
+ }
+ }
if (errorCode == DC_ERROR_INVALID_KEY && instance != null && !pending.retried) {
// The key is gone or was never valid. Wipe it and try once from scratch; a
// second failure is reported rather than looped.
From 693bab71ae00993ead5fb4f638e14aa3c78c241e Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 19:47:20 +0300
Subject: [PATCH 17/96] Give the request hook back its legacy certificate view
The comment claimed the request's own checkSSLCertificates hook ran
unchanged, and it did not: once a guard pinned the host, the hook was
handed the grouped view, which yields one object per certificate and
keeps only the first fingerprint. An app pinning a SHA-1 value, or simply
counting entries, would have started rejecting chains it has always
accepted. The hook now always sees the flat legacy view and the enriched
one goes only to the guard, on both the direct and the iOS native
callback paths.
A fail-closed host that received a plaintext URL returned without a
token, which protected the token but broke the policy's own promise to
refuse requests lacking one -- and sent the body over plaintext instead.
It now goes through failOrContinue, so a closed policy aborts.
Two more iOS atomicity gaps of the same family as the last round:
- nativeAttestError checked the generation under the lock and then
released it before acting, so a reset landing in between would have its
replacement bootstrap discarded by the older callback. The check and
every mutation the callback performs now sit in one acquisition,
including the trailing in-flight clear that was still outside it.
- succeed() only schedules completion onto the EDT, so a reset between
the check and the runnable still delivered an assertion for the
discarded key. The generation is rechecked at publication.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/io/ConnectionRequest.java | 36 ++++--
.../codename1/security/shield/AppShield.java | 8 ++
.../impl/ios/IOSDeviceIntegrity.java | 116 ++++++++++--------
3 files changed, 101 insertions(+), 59 deletions(-)
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index 3c5ba958130..8c1639de83d 100644
--- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java
+++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
@@ -912,7 +912,12 @@ boolean checkCertificatesNativeCallback() {
checkSSLCertificates(certs);
NetworkGuard guard = NetworkManager.getNetworkGuard();
if (guard != null) {
- guard.checkCertificates(this, certs);
+ // Same split as the non-callback path: the hook above sees the flat view
+ // it has always seen, the guard gets the enriched one when the platform
+ // can produce it.
+ SSLCertificate[] forGuard = _connection == null
+ ? null : guardSSLCertificates(_connection, url);
+ guard.checkCertificates(this, forGuard == null ? certs : forGuard);
}
return !shouldStop();
} catch (IOException ex) {
@@ -1068,12 +1073,15 @@ boolean performOperationComplete() throws IOException {
// empty POST bodies.
!Util.getImplementation().checkSSLCertificatesRequiresCallbackFromNative()) {
sslCertificates = getSSLCertificatesImpl(connection, url);
- // The request's own hook runs first and unchanged, so an app that
- // already pins by overriding it keeps working; the guard layers on.
+ // The request's own hook runs first and on the legacy flat view, so an
+ // app that already pins by overriding it keeps working; the guard layers
+ // on, and only the guard sees the enriched per-certificate form.
checkSSLCertificates(sslCertificates);
NetworkGuard certGuard = NetworkManager.getNetworkGuard();
if (certGuard != null) {
- certGuard.checkCertificates(this, sslCertificates);
+ SSLCertificate[] forGuard = guardSSLCertificates(connection, url);
+ certGuard.checkCertificates(this,
+ forGuard == null ? sslCertificates : forGuard);
}
if (shouldStop()) {
return true;
@@ -1762,11 +1770,25 @@ static SSLCertificate[] parseGroupedCertificates(String[] entries) {
return arr;
}
- private SSLCertificate[] getSSLCertificatesImpl(Object connection, String url) throws IOException {
+ /// The enriched per-certificate view, or null when this platform or request cannot produce
+ /// one. Only the [NetworkGuard] sees this; see [#getSSLCertificatesImpl] for why.
+ private SSLCertificate[] guardSSLCertificates(Object connection, String url)
+ throws IOException {
CodenameOneImplementation impl = Util.getImplementation();
- if (collectPublicKeyDigests && impl.canGetPublicKeyDigests()) {
- return parseGroupedCertificates(impl.getSSLCertificatesEx(connection, url));
+ if (!collectPublicKeyDigests || !impl.canGetPublicKeyDigests()) {
+ return null;
}
+ return parseGroupedCertificates(impl.getSSLCertificatesEx(connection, url));
+ }
+
+ /// The flat, one-entry-per-fingerprint view every existing caller has always seen.
+ ///
+ /// Deliberately not the grouped form even when the guard asked for it: grouping yields one
+ /// object per certificate and keeps only the first fingerprint, so a hook that pins a SHA-1
+ /// value -- or simply counts entries -- would start rejecting a chain it has always accepted.
+ /// The enriched view exists for the guard and goes only to the guard.
+ private SSLCertificate[] getSSLCertificatesImpl(Object connection, String url) throws IOException {
+ CodenameOneImplementation impl = Util.getImplementation();
String[] sslCerts = impl.getSSLCertificates(connection, url);
SSLCertificate[] out = new SSLCertificate[sslCerts.length];
int i = 0;
diff --git a/CodenameOne/src/com/codename1/security/shield/AppShield.java b/CodenameOne/src/com/codename1/security/shield/AppShield.java
index 9504bdbedfd..99fc5757587 100644
--- a/CodenameOne/src/com/codename1/security/shield/AppShield.java
+++ b/CodenameOne/src/com/codename1/security/shield/AppShield.java
@@ -258,8 +258,16 @@ public static void attach(ConnectionRequest request) throws ShieldException {
// The token is a bearer credential. Sending it in plaintext -- after a
// downgrade redirect, or a mistyped scheme -- hands it to anyone on the
// path, and pinning cannot help because there is no certificate to pin.
+ //
+ // Routed through the failure mode rather than simply returning: a
+ // fail-closed host promises to refuse requests that carry no valid token,
+ // and silently sending the body over plaintext instead is the one outcome
+ // that policy exists to rule out.
Log.p("AppShield: refusing to attach a token to a plaintext URL for "
+ host + ". Use https for protected hosts.");
+ failOrContinue(policy, new ShieldException(ShieldStatus.REJECTED,
+ "AppShield: " + host + " is a protected host but the request is "
+ + "plaintext, so no token can be attached safely. Use https."));
return;
}
try {
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index a4ea6c44411..ac3f1fbb335 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -524,60 +524,51 @@ public static void nativeAttestError(final int requestId, final int errorCode,
return;
}
if (instance != null) {
- boolean stale;
+ // The staleness check and every mutation this callback performs sit inside
+ // ONE acquisition. Checking under the lock and then releasing it let a reset
+ // bump the generation and start a replacement in between, and this callback
+ // would then discard that replacement -- or, for a non-invalid-key error,
+ // clear the in-flight flag and waiters belonging to it.
synchronized (instance.flowLock) {
- stale = isStale(pending);
- }
- if (stale) {
- // Belongs to a flow that was already abandoned. Acting on it would reset
- // the identity a newer bootstrap just established and burn another
- // rate-limited key replacing it -- and for a non-invalid-key error it
- // would clear bootstrapInFlight for a generation it knows nothing about,
- // letting a concurrent request start a second key alongside the first.
- fail(pending, "App Attest state was reset while this request was in flight");
- return;
- }
- }
- if (errorCode == DC_ERROR_INVALID_KEY && instance != null && !pending.retried) {
- // The key is gone or was never valid. Wipe it and try once from scratch; a
- // second failure is reported rather than looped.
- //
- // Discarding the identity and claiming the replacement bootstrap happen in
- // ONE lock acquisition. Calling resetAttestation() and then reacquiring left
- // a gap in which a concurrent requestToken() saw no key and no bootstrap
- // running, started its own generation, and then this path started another --
- // two rate-limited hardware keys racing to become the persisted identity.
- synchronized (instance.flowLock) {
- instance.resetLocked();
- PendingRequest retry = new PendingRequest(pending.result, pending.nonce,
- PendingRequest.OP_GENERATE_KEY, null);
- retry.retried = true;
- // resetLocked() clears the flag, so set it again before starting the
- // replacement -- still under the same lock, so nothing observes the
- // cleared state.
- instance.bootstrapInFlight = true;
- int rid = register(retry);
- instance.nativeInstance.appAttestGenerateKey(rid);
- }
- return;
- }
- if (errorCode == DC_ERROR_SERVER_UNAVAILABLE && instance != null) {
- // Never retry a throttle in a loop -- that is what gets an app's
- // whole attestation budget suspended.
- synchronized (instance.flowLock) {
- long backoff = instance.currentBackoff;
- SecureStorage.getInstance().set(KEY_RETRY_AFTER,
- Long.toString(System.currentTimeMillis() + backoff));
- instance.currentBackoff = Math.min(backoff * 2, MAX_BACKOFF_MILLIS);
+ if (isStale(pending)) {
+ fail(pending,
+ "App Attest state was reset while this request was in flight");
+ return;
+ }
+ if (errorCode == DC_ERROR_INVALID_KEY && !pending.retried) {
+ // The key is gone or was never valid. Wipe it and try once from
+ // scratch; a second failure is reported rather than looped.
+ instance.resetLocked();
+ PendingRequest retry = new PendingRequest(pending.result, pending.nonce,
+ PendingRequest.OP_GENERATE_KEY, null);
+ retry.retried = true;
+ // resetLocked() clears the flag, so set it again before starting the
+ // replacement -- still under the same lock, so nothing observes the
+ // cleared state.
+ instance.bootstrapInFlight = true;
+ int rid = register(retry);
+ instance.nativeInstance.appAttestGenerateKey(rid);
+ return;
+ }
+ if (errorCode == DC_ERROR_SERVER_UNAVAILABLE) {
+ // Never retry a throttle in a loop -- that is what gets an app's
+ // whole attestation budget suspended.
+ long backoff = instance.currentBackoff;
+ SecureStorage.getInstance().set(KEY_RETRY_AFTER,
+ Long.toString(System.currentTimeMillis() + backoff));
+ instance.currentBackoff = Math.min(backoff * 2, MAX_BACKOFF_MILLIS);
+ }
+ if (pending.op != PendingRequest.OP_ASSERT) {
+ // Still the same acquisition: releasing here and reacquiring would
+ // reopen the window on this mutation alone, clearing the in-flight
+ // flag of whatever bootstrap had started meanwhile.
+ instance.bootstrapInFlight = false;
+ instance.failBootstrapWaiters(
+ msg == null ? "App Attest failed (code " + errorCode + ")" : msg);
+ }
}
}
String message = msg == null ? "App Attest failed (code " + errorCode + ")" : msg;
- if (instance != null && pending.op != PendingRequest.OP_ASSERT) {
- synchronized (instance.flowLock) {
- instance.bootstrapInFlight = false;
- instance.failBootstrapWaiters(message);
- }
- }
fail(pending, message);
}
@@ -627,12 +618,33 @@ private static PendingRequest take(int requestId) {
}
}
+ /**
+ * Completes a caller with a token, unless a reset overtook it.
+ *
+ * Completion is scheduled onto the EDT, so between the callback deciding the
+ * token is good and the caller receiving it, another request can reset attestation
+ * -- and the caller would then be handed an assertion for the key that was just
+ * discarded, which is exactly what the staleness checks exist to suppress. The
+ * generation is therefore checked again at the moment of publication.
+ */
private static void succeed(final PendingRequest pending, final String token) {
Display.getInstance().callSerially(new Runnable() {
public void run() {
- if (!pending.result.isDone()) {
- pending.result.complete(token);
+ if (pending.result.isDone()) {
+ return;
+ }
+ if (instance != null) {
+ boolean stale;
+ synchronized (instance.flowLock) {
+ stale = isStale(pending);
+ }
+ if (stale) {
+ pending.result.error(new RuntimeException("App Attest state was "
+ + "reset while this request was in flight"));
+ return;
+ }
}
+ pending.result.complete(token);
}
});
}
From 3c7e541dbdae44d3a3bb2abd9989a011a1122b21 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 20:03:50 +0300
Subject: [PATCH 18/96] Key WebSocket headers case-insensitively, fail a
refused reset
HTTP header names are case-insensitive but the handshake map was keyed by
whatever spelling the caller used, so header("Authorization", v) followed
by header("authorization", null) left the credential in place, and
setting both emitted the field twice and left the server to choose. Not a
thing to leave to chance for an Authorization header. Keyed by the folded
name now, storing the caller's spelling alongside the value so emission
is unchanged.
resetAttestation ignored what the keychain returned. A refused deletion
still advanced the generation and reported the reset complete, so the
next request could reload the very key the backend asked the client to
discard and carry on asserting with it. The two entries carrying the
identity are now checked: if they survive, the reset does not happen at
all -- state left visibly unchanged so the caller can retry -- and the
invalid-key recovery path fails rather than starting a replacement
alongside an identity it could not remove.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../src/com/codename1/impl/WebSocketImpl.java | 26 ++++++++++----
.../impl/ios/IOSDeviceIntegrity.java | 35 ++++++++++++++++---
2 files changed, 50 insertions(+), 11 deletions(-)
diff --git a/CodenameOne/src/com/codename1/impl/WebSocketImpl.java b/CodenameOne/src/com/codename1/impl/WebSocketImpl.java
index 82ede32afca..f1c16f3573e 100644
--- a/CodenameOne/src/com/codename1/impl/WebSocketImpl.java
+++ b/CodenameOne/src/com/codename1/impl/WebSocketImpl.java
@@ -83,16 +83,26 @@ public final void setRequestHeader(String name, String value) {
if (name == null || name.length() == 0) {
return;
}
+ // Keyed by the folded name, because HTTP header names are case-insensitive.
+ // Keying by the spelling the caller happened to use meant
+ // header("Authorization", v) followed by header("authorization", null) left the
+ // credential in place, and setting both emitted the field twice -- leaving the
+ // server to pick one, which is not a thing to leave to chance for an
+ // Authorization header. The caller's spelling is kept for emission.
+ String key = asciiLower(name);
if (value == null) {
- requestHeaders.remove(name);
+ requestHeaders.remove(key);
} else {
- requestHeaders.put(name, value);
+ requestHeaders.put(key, new String[] {name, value});
}
}
- /// The extra handshake headers, keyed by name. Never null; ports read this
- /// while building the handshake. Ports must not emit an entry whose name
- /// collides with a header the handshake sets itself.
+ /// The extra handshake headers, keyed by the ASCII-folded name. Each value is a
+ /// two-element `String[]` of `{ name as the caller spelled it, value }`, so lookups
+ /// and removals are case-insensitive the way HTTP is while emission preserves the
+ /// caller's capitalization. Never null; ports read this while building the
+ /// handshake. Ports must not emit an entry whose name collides with a header the
+ /// handshake sets itself.
protected final java.util.Hashtable requestHeaders() {
return requestHeaders;
}
@@ -106,8 +116,10 @@ protected final java.util.Hashtable requestHeaders() {
protected final void appendRequestHeaders(StringBuilder req) {
java.util.Enumeration keys = requestHeaders.keys();
while (keys.hasMoreElements()) {
- String name = (String) keys.nextElement();
- String value = (String) requestHeaders.get(name);
+ String key = (String) keys.nextElement();
+ String[] pair = (String[]) requestHeaders.get(key);
+ String name = pair[0];
+ String value = pair[1];
if (isReservedHandshakeHeader(name)) {
continue;
}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index ac3f1fbb335..39573fc85dd 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -22,6 +22,7 @@
*/
package com.codename1.impl.ios;
+import com.codename1.io.Log;
import com.codename1.security.Hash;
import com.codename1.security.SecureStorage;
import com.codename1.ui.Display;
@@ -170,7 +171,14 @@ void resetAttestation() {
// mark only that callback stale and leave the resurrected key behind for the
// next bootstrap to find.
synchronized (flowLock) {
- resetLocked();
+ try {
+ resetLocked();
+ } catch (IllegalStateException e) {
+ // Public API entry point, so the failure is logged rather than thrown at
+ // an app that asked us to forget a key. The state is untouched, so the
+ // next attempt can retry it.
+ Log.e(e);
+ }
}
}
@@ -178,10 +186,19 @@ void resetAttestation() {
/// the old identity and starting the replacement. Caller holds `flowLock`.
private void resetLocked() {
SecureStorage store = SecureStorage.getInstance();
- store.remove(KEY_ID);
- store.remove(KEY_STATE);
+ // The two that carry the identity are checked. If the keychain refuses to
+ // delete them the reset has not happened, and advancing the generation anyway
+ // would report success while the next request reloads the very key the backend
+ // asked us to discard -- and keeps asserting with it. Better to leave the state
+ // visibly unchanged so the caller fails and can retry.
+ boolean cleared = store.remove(KEY_ID);
+ cleared &= store.remove(KEY_STATE);
store.remove(KEY_RETRY_AFTER);
store.remove(KEY_PENDING_SINCE);
+ if (!cleared) {
+ throw new IllegalStateException("App Attest could not discard its stored key; "
+ + "the keychain refused the deletion");
+ }
currentBackoff = MIN_BACKOFF_MILLIS;
bootstrapInFlight = false;
// Any callback still outstanding now belongs to an abandoned flow.
@@ -538,7 +555,17 @@ public static void nativeAttestError(final int requestId, final int errorCode,
if (errorCode == DC_ERROR_INVALID_KEY && !pending.retried) {
// The key is gone or was never valid. Wipe it and try once from
// scratch; a second failure is reported rather than looped.
- instance.resetLocked();
+ try {
+ instance.resetLocked();
+ } catch (IllegalStateException e) {
+ // Nothing was discarded, so starting a replacement would leave
+ // two identities and the old one still usable. Fail the caller.
+ instance.bootstrapInFlight = false;
+ fail(pending, "App Attest could not discard the rejected key");
+ instance.failBootstrapWaiters(
+ "App Attest could not discard the rejected key");
+ return;
+ }
PendingRequest retry = new PendingRequest(pending.result, pending.nonce,
PendingRequest.OP_GENERATE_KEY, null);
retry.retried = true;
From 226624094ffcbce9d5c867a9b715f98d14ab4a1f Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 20:21:05 +0300
Subject: [PATCH 19/96] Invalidate on partial reset, and retry a flaky Central
resolution
The two keychain deletions in resetLocked are not atomic, so a partial
failure left half the identity gone while the method reported the state
untouched -- and a callback from the rejected identity could still pass
its staleness check and act on it. The generation is now advanced before
the failure is raised: whatever the keychain managed, no outstanding
callback belongs to the current flow any more.
The pending-state rollback was itself unchecked. If storage accepted
pending, rejected the timestamp, and then rejected the rollback, the key
sat pending with no deadline -- which reads as an expired grace window,
so the next request would promote it and assert against a key no backend
had seen. That case now discards the identity outright rather than
leaving state that reads as ready.
succeed() checks staleness under the lock and completes outside it. The
completion is deliberately not held under the lock: complete() runs the
app's own listeners synchronously, and holding the attestation lock
across arbitrary application code is how the deadlock earlier in this
class happened. The residual window yields an assertion the backend
rejects, which the existing reset-and-retry path already recovers from;
a deadlocked EDT has no recovery at all.
The Windows cross-compile job had no retry around its Maven build and
died on a Central connection reset while resolving a build extension,
before compiling anything. Same growing backoff as the JavaSE smoke
script, for the same reason.
Co-Authored-By: Claude Opus 5 (1M context)
---
.github/workflows/windows-cross-compile.yml | 19 ++++++-
.../impl/ios/IOSDeviceIntegrity.java | 50 +++++++++++++++----
2 files changed, 57 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/windows-cross-compile.yml b/.github/workflows/windows-cross-compile.yml
index 71f15dd1474..040c1f2e13c 100644
--- a/.github/workflows/windows-cross-compile.yml
+++ b/.github/workflows/windows-cross-compile.yml
@@ -106,8 +106,23 @@ jobs:
- name: Build codename1-core + the Windows port (JDK 8)
run: |
cd maven
- JAVA_HOME="$JDK_8_HOME" mvn -B -pl windows -am -DskipTests \
- '-Dmaven.javadoc.skip=true' '-Plocal-dev-javase' install
+ # Maven Central intermittently resets the connection or throttles the runner,
+ # and Maven treats that as a permanent resolution failure -- observed killing
+ # this job while resolving a build extension, before any project code compiled.
+ # The backoff grows because a flat retry lands inside the same window a 429 is
+ # still rate limiting in. A genuine build failure fails identically every time.
+ for delay in 30 120 300 0; do
+ if JAVA_HOME="$JDK_8_HOME" mvn -B -pl windows -am -DskipTests \
+ '-Dmaven.javadoc.skip=true' '-Plocal-dev-javase' install; then
+ break
+ fi
+ if [ "$delay" = "0" ]; then
+ echo "core + Windows port build failed after all retries"
+ exit 1
+ fi
+ echo "build failed; retrying in ${delay}s in case Maven Central was flaky"
+ sleep "$delay"
+ done
test -f core/target/classes/com/codename1/ui/Form.class
test -f windows/target/classes/com/codename1/impl/windows/WindowsImplementation.class
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 39573fc85dd..f720993541d 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -191,11 +191,19 @@ private void resetLocked() {
// would report success while the next request reloads the very key the backend
// asked us to discard -- and keeps asserting with it. Better to leave the state
// visibly unchanged so the caller fails and can retry.
- boolean cleared = store.remove(KEY_ID);
- cleared &= store.remove(KEY_STATE);
+ boolean idGone = store.remove(KEY_ID);
+ boolean stateGone = store.remove(KEY_STATE);
store.remove(KEY_RETRY_AFTER);
store.remove(KEY_PENDING_SINCE);
- if (!cleared) {
+ if (!idGone || !stateGone) {
+ // The two deletions are not atomic, so a partial failure leaves half the
+ // identity gone. Treating that as "untouched" would let a callback from the
+ // rejected identity still pass its staleness check and act on inconsistent
+ // state, so the generation is advanced first: whatever the keychain did, no
+ // outstanding callback belongs to the current flow any more.
+ generation++;
+ bootstrapInFlight = false;
+ failBootstrapWaiters("App Attest could not fully discard its stored key");
throw new IllegalStateException("App Attest could not discard its stored key; "
+ "the keychain refused the deletion");
}
@@ -486,7 +494,19 @@ public static void nativeAttestationReady(final int requestId, final String atte
// a key no backend has acknowledged -- the first-use rejection and
// pointless key reset this state exists to prevent. Roll back to new so
// the key is attested again rather than used prematurely.
- store.set(KEY_STATE, STATE_NEW);
+ if (!store.set(KEY_STATE, STATE_NEW)) {
+ // The rollback failed too, so the key would sit pending with no
+ // deadline and be promoted on the next request. Discard the identity
+ // outright rather than leave a state that reads as ready.
+ try {
+ instance.resetLocked();
+ } catch (IllegalStateException ignored) {
+ // resetLocked already advanced the generation and failed the
+ // waiters; nothing further to do but report to this caller.
+ }
+ fail(pending, "App Attest could not record its registration deadline");
+ return;
+ }
instance.bootstrapInFlight = false;
fail(pending, "App Attest could not record its registration deadline");
instance.failBootstrapWaiters(
@@ -660,12 +680,22 @@ public void run() {
if (pending.result.isDone()) {
return;
}
- if (instance != null) {
- boolean stale;
- synchronized (instance.flowLock) {
- stale = isStale(pending);
- }
- if (stale) {
+ if (instance == null) {
+ pending.result.complete(token);
+ return;
+ }
+ // The staleness check happens under the lock; the completion itself does
+ // not, and that is deliberate. complete() runs the app's own listeners
+ // synchronously, and holding the attestation lock across arbitrary
+ // application code is how the deadlock earlier in this class happened.
+ //
+ // The residual window is a reset landing between the check and the
+ // handover. What the caller then holds is an assertion for a key the
+ // backend no longer knows, which it rejects; the app resets and retries,
+ // which is the recovery path that already exists. A deadlocked EDT has
+ // no such recovery, so the trade runs this way round.
+ synchronized (instance.flowLock) {
+ if (isStale(pending)) {
pending.result.error(new RuntimeException("App Attest state was "
+ "reset while this request was in flight"));
return;
From 38323ed3828c9c12087e07a18bf9b024cc2254f8 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 20:39:12 +0300
Subject: [PATCH 20/96] Keep the throttle deadline when the keychain refuses
the write
requestToken reads the backoff deadline from storage only, so a refused
write left it existing purely as a duration nobody consulted -- and the
next caller went straight back to a throttled App Attest service, which
is how a suspension gets extended rather than waited out. The deadline is
now held in memory alongside the keychain, whichever is later wins, and a
reset clears both.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/ios/IOSDeviceIntegrity.java | 21 ++++++++++++++++---
1 file changed, 18 insertions(+), 3 deletions(-)
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index f720993541d..7c911c1ef35 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -137,6 +137,16 @@ final class IOSDeviceIntegrity {
/** Guards the whole attest flow so concurrent callers cannot each burn a key. */
private final Object flowLock = new Object();
private long currentBackoff = MIN_BACKOFF_MILLIS;
+ /**
+ * The throttle deadline, held in memory as well as in the keychain.
+ *
+ * {@code requestToken} reads the deadline from storage, so a refused write left
+ * the backoff existing only as a duration nobody consulted -- and the next caller
+ * went straight back to a throttled App Attest service, which is how a suspension
+ * gets extended rather than waited out. This survives at least until the process
+ * dies, which is the case the keychain was covering.
+ */
+ private long retryAfterFallback;
/** True while a generate-then-attest bootstrap is running. */
private boolean bootstrapInFlight;
/**
@@ -208,6 +218,7 @@ private void resetLocked() {
+ "the keychain refused the deletion");
}
currentBackoff = MIN_BACKOFF_MILLIS;
+ retryAfterFallback = 0L;
bootstrapInFlight = false;
// Any callback still outstanding now belongs to an abandoned flow.
generation++;
@@ -267,7 +278,7 @@ AsyncResource requestToken(String nonce) {
return r;
}
synchronized (flowLock) {
- long retryAfter = readRetryAfter();
+ long retryAfter = Math.max(readRetryAfter(), retryAfterFallback);
if (retryAfter > System.currentTimeMillis()) {
r.error(new RuntimeException("App Attest is backing off after a throttle; "
+ "retry in " + ((retryAfter - System.currentTimeMillis()) / 1000)
@@ -599,10 +610,14 @@ public static void nativeAttestError(final int requestId, final int errorCode,
}
if (errorCode == DC_ERROR_SERVER_UNAVAILABLE) {
// Never retry a throttle in a loop -- that is what gets an app's
- // whole attestation budget suspended.
+ // whole attestation budget suspended. Recorded in memory as well as
+ // in the keychain, because a refused write must not mean no backoff.
long backoff = instance.currentBackoff;
+ long deadline = System.currentTimeMillis() + backoff;
+ instance.retryAfterFallback = Math.max(instance.retryAfterFallback,
+ deadline);
SecureStorage.getInstance().set(KEY_RETRY_AFTER,
- Long.toString(System.currentTimeMillis() + backoff));
+ Long.toString(deadline));
instance.currentBackoff = Math.min(backoff * 2, MAX_BACKOFF_MILLIS);
}
if (pending.op != PendingRequest.OP_ASSERT) {
From da97f4fd217febb777384800ab1b5ff94edec70a Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 20:55:04 +0300
Subject: [PATCH 21/96] Ask the guard only about URLs it selected, and reset
the backoff
shouldInspectCertificates returns true when the request opted in on its
own, and both certificate paths then called the guard regardless -- so a
guard that had explicitly declined a URL was still handed its chain and
could reject a host it wanted nothing to do with. The answer is now
remembered and the guard is only consulted for URLs it asked about.
A successful assertion resets the App Attest backoff. Only the failure
path touched it, so the doubling accumulated across the process lifetime:
outages separated by long stretches of successful requests compounded
until a single transient failure imposed the full hour. Attestation
already did this; assertion is the steady-state path and needs it more.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../src/com/codename1/io/ConnectionRequest.java | 15 ++++++++++++---
.../codename1/impl/ios/IOSDeviceIntegrity.java | 11 +++++++++++
2 files changed, 23 insertions(+), 3 deletions(-)
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index 8c1639de83d..08f9183428c 100644
--- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java
+++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
@@ -911,10 +911,11 @@ boolean checkCertificatesNativeCallback() {
SSLCertificate[] certs = getSSLCertificates();
checkSSLCertificates(certs);
NetworkGuard guard = NetworkManager.getNetworkGuard();
- if (guard != null) {
+ if (guard != null && guardWantsCertificates) {
// Same split as the non-callback path: the hook above sees the flat view
// it has always seen, the guard gets the enriched one when the platform
- // can produce it.
+ // can produce it. And only for URLs the guard asked about -- the request
+ // may have opted into inspection by itself.
SSLCertificate[] forGuard = _connection == null
? null : guardSSLCertificates(_connection, url);
guard.checkCertificates(this, forGuard == null ? certs : forGuard);
@@ -933,13 +934,21 @@ boolean checkCertificatesNativeCallback() {
/// True when the certificate chain for this request should be fetched and vetted, either
/// because the request opted in itself or because the installed [NetworkGuard] pins this host.
+ /// Whether the guard asked to see this URL's chain, as answered by
+ /// [#shouldInspectCertificates()]. Kept so the guard is only handed a chain for hosts
+ /// it selected: a request can opt into inspection on its own, and a guard that
+ /// declined this URL must not then be asked to judge it.
+ private boolean guardWantsCertificates;
+
private boolean shouldInspectCertificates() {
+ guardWantsCertificates = false;
NetworkGuard guard = NetworkManager.getNetworkGuard();
if (guard == null) {
return checkSSLCertificates;
}
try {
if (guard.isCertificateCheckRequired(url)) {
+ guardWantsCertificates = true;
// Ask the platform for the richer chain details (public key
// digests, per-certificate grouping). Off by default so every
// existing caller keeps seeing byte-identical data. Asked even
@@ -1078,7 +1087,7 @@ boolean performOperationComplete() throws IOException {
// on, and only the guard sees the enriched per-certificate form.
checkSSLCertificates(sslCertificates);
NetworkGuard certGuard = NetworkManager.getNetworkGuard();
- if (certGuard != null) {
+ if (certGuard != null && guardWantsCertificates) {
SSLCertificate[] forGuard = guardSSLCertificates(connection, url);
certGuard.checkCertificates(this,
forGuard == null ? sslCertificates : forGuard);
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 7c911c1ef35..2505d402600 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -559,6 +559,17 @@ public static void nativeAssertionReady(final int requestId, final String assert
fail(pending, "App Attest assertion returned no data");
return;
}
+ if (instance != null) {
+ synchronized (instance.flowLock) {
+ // A working assertion means Apple is answering again, so the throttle
+ // sequence starts over. Without this the doubling only ever accumulated:
+ // outages separated by months of successful requests would compound
+ // until one transient failure imposed the full hour.
+ instance.currentBackoff = MIN_BACKOFF_MILLIS;
+ instance.retryAfterFallback = 0L;
+ SecureStorage.getInstance().remove(KEY_RETRY_AFTER);
+ }
+ }
succeed(pending, TOKEN_PREFIX + ":assert:" + base64(bytes(pending.keyId))
+ ":" + assertionB64
+ ":" + base64(bytes(pending.clientData)));
From 782103af560fd9f726fbaab8902678db69ad9998 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 21:12:56 +0300
Subject: [PATCH 22/96] Fail closed for a registered engine, and serialize the
iOS coordinator
failOrContinue keyed on isProtected(), which asks whether attestation can
run right now. An engine may legitimately report itself unavailable -- an
unsupported device, a failed initialization -- and that is exactly when a
fail-closed host must refuse, not the moment to stop enforcing. It now
keys on whether an engine was registered at all, so only a build with no
engine is exempt, which is the documented degradation contract.
deviceIntegrity() was an unsynchronized lazy init. Two concurrent first
requests each built a coordinator with its own flowLock and
bootstrapInFlight, so neither saw the other's bootstrap -- two
rate-limited hardware keys, and two sets of callbacks racing to persist
an identity through a shared static the constructor had just overwritten.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../src/com/codename1/security/shield/AppShield.java | 10 ++++++++--
.../security/shield/spi/ShieldEngineRegistry.java | 8 +++++++-
.../src/com/codename1/impl/ios/IOSImplementation.java | 11 ++++++++++-
3 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/CodenameOne/src/com/codename1/security/shield/AppShield.java b/CodenameOne/src/com/codename1/security/shield/AppShield.java
index 99fc5757587..1df5c8d64a6 100644
--- a/CodenameOne/src/com/codename1/security/shield/AppShield.java
+++ b/CodenameOne/src/com/codename1/security/shield/AppShield.java
@@ -304,11 +304,17 @@ private static void failOrContinue(HostPolicy policy, ShieldException e) throws
// A build with no engine must never block a request: that is the
// degradation contract this API documents, and a fail-closed host in an
// open-source or unentitled build would otherwise break outright.
- if (policy.getFailureMode() == FailureMode.CLOSED && isProtected()) {
+ // Registered, not available. An engine may legitimately report itself
+ // unavailable -- an unsupported device, a failed initialization -- and that is
+ // exactly when a fail-closed host must refuse, not the moment to stop
+ // enforcing. Only a build with no engine at all is exempt, which is the
+ // degradation contract the open-source path documents.
+ boolean enginePresent = ShieldEngineRegistry.isEngineRegistered();
+ if (policy.getFailureMode() == FailureMode.CLOSED && enginePresent) {
throw e;
}
Log.p("AppShield: continuing without a token (" + e.getStatus().getId()
- + "); " + (isProtected() ? "host policy is fail-open."
+ + "); " + (enginePresent ? "host policy is fail-open."
: "no attestation engine is present, so nothing is enforced."));
}
diff --git a/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngineRegistry.java b/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngineRegistry.java
index cb4c8419a10..05d09810d13 100644
--- a/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngineRegistry.java
+++ b/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngineRegistry.java
@@ -82,7 +82,13 @@ public static EngineContext getDefaultContext() {
return DefaultEngineContext.INSTANCE;
}
- /// True when a real engine was registered.
+ /// True when a real engine was registered, whatever it currently reports about its
+ /// availability.
+ ///
+ /// Distinct from [com.codename1.security.shield.AppShield#isProtected()], which asks
+ /// whether attestation can run *right now*. A fail-closed host must refuse when a
+ /// registered engine cannot attest -- that is the case it exists for -- and relax only
+ /// for a build that has no engine at all.
public static boolean isEngineRegistered() {
synchronized (ShieldEngineRegistry.class) {
return engine != null;
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
index 9c480bc7a51..1b51df43b0c 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
@@ -4585,7 +4585,16 @@ public void confirmAttestation() {
deviceIntegrity().confirmAttestation();
}
- private IOSDeviceIntegrity deviceIntegrity() {
+ /**
+ * The App Attest coordinator, created once.
+ *
+ * Synchronized because two concurrent first requests would otherwise each build
+ * one. Each carries its own {@code flowLock} and {@code bootstrapInFlight}, so
+ * neither would see the other's bootstrap -- two rate-limited hardware keys, and
+ * two sets of callbacks racing to persist an identity -- while the constructor
+ * overwrites the shared static the native callbacks dispatch through.
+ */
+ private synchronized IOSDeviceIntegrity deviceIntegrity() {
if (deviceIntegrity == null) {
deviceIntegrity = new IOSDeviceIntegrity(nativeInstance);
}
From 08aea2dc5a49df2b69e271e0af42cf048dbaa2cb Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 21:29:02 +0300
Subject: [PATCH 23/96] Acknowledge a specific attested key, not whatever is
pending
confirmAttestation() promoted whichever key happened to be pending. A
backend response for an earlier attestation can arrive after a reset has
already replaced the identity, and acknowledging it marked a key attested
that the backend has never seen -- so the next assertion is rejected and
costs another reset, which is the loop the pending state exists to break.
It now takes the key identifier the response acknowledged and transitions
only when that is still the stored key. The signature change runs through
DeviceIntegrity, Display, CodenameOneImplementation, EngineContext and
the ports; the API is new in this branch, so nothing shipped depends on
the old shape.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/CodenameOneImplementation.java | 2 +-
.../com/codename1/security/DeviceIntegrity.java | 16 +++++++++++-----
.../shield/spi/DefaultEngineContext.java | 4 ++--
.../security/shield/spi/EngineContext.java | 2 +-
CodenameOne/src/com/codename1/ui/Display.java | 4 ++--
.../com/codename1/impl/javase/JavaSEPort.java | 2 +-
.../codename1/impl/ios/IOSDeviceIntegrity.java | 13 ++++++++++++-
.../codename1/impl/ios/IOSImplementation.java | 4 ++--
8 files changed, 32 insertions(+), 15 deletions(-)
diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
index 361ed4d7104..d8b71a7cd7d 100644
--- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
+++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
@@ -11276,7 +11276,7 @@ public void resetAttestation() {
/// take the cheap assertion path. See
/// [com.codename1.security.DeviceIntegrity#confirmAttestation()]. No-op where attestation holds no
/// client-side key.
- public void confirmAttestation() {
+ public void confirmAttestation(String keyId) {
}
/// Returns digests of the certificates the running application is actually signed with, so a build
diff --git a/CodenameOne/src/com/codename1/security/DeviceIntegrity.java b/CodenameOne/src/com/codename1/security/DeviceIntegrity.java
index bee358e871b..2fd64e50128 100644
--- a/CodenameOne/src/com/codename1/security/DeviceIntegrity.java
+++ b/CodenameOne/src/com/codename1/security/DeviceIntegrity.java
@@ -137,11 +137,17 @@ public static void resetAttestation() {
/// good and burn one of Apple's rate limited attestations replacing it. So requests made between
/// the attestation and this acknowledgement are refused with a retry hint rather than asserted.
///
- /// Call it once, after the response accepting the attestation token. Not calling it is safe but
- /// slower: the client assumes registration succeeded after a short grace period. No-op on Android
- /// and where attestation is unsupported.
- public static void confirmAttestation() {
- Display.getInstance().confirmAttestation();
+ /// Call it once, after the response accepting the attestation token, passing the key that
+ /// response acknowledged. Not calling it is safe but slower: the client assumes registration
+ /// succeeded after a short grace period. No-op on Android and where attestation is
+ /// unsupported.
+ /// @param keyId the key identifier your backend recorded -- the middle field of the
+ /// `cn1aa1:attest::` token it accepted, base64-decoded.
+ /// Naming it matters: a response for an earlier attestation can arrive after
+ /// the key has already been replaced, and acknowledging that would mark a key
+ /// attested which the backend has never seen.
+ public static void confirmAttestation(String keyId) {
+ Display.getInstance().confirmAttestation(keyId);
}
/// Non-exiting RASP check. Returns true when the device shows signs of being rooted, jailbroken,
diff --git a/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java b/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java
index ad05f995161..0a64d704a60 100644
--- a/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java
+++ b/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java
@@ -71,9 +71,9 @@ public void resetPlatformAttestation() {
}
@Override
- public void confirmPlatformAttestation() {
+ public void confirmPlatformAttestation(String keyId) {
try {
- DeviceIntegrity.confirmAttestation();
+ DeviceIntegrity.confirmAttestation(keyId);
} catch (Throwable t) {
Log.e(t);
}
diff --git a/CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java b/CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java
index a5e7f27757d..2ec982522b5 100644
--- a/CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java
+++ b/CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java
@@ -54,7 +54,7 @@ public interface EngineContext {
/// Acknowledges that the verifying service recorded the attested key, releasing the client to use
/// cheap assertions from here on. Call it once the service has accepted an attestation token; until
/// then the platform refuses to assert against a key the service cannot yet resolve.
- void confirmPlatformAttestation();
+ void confirmPlatformAttestation(String keyId);
/// Platform-detected compromise reasons, such as `root` or `frida`.
String[] getPlatformCompromiseReasons();
diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java
index f7543cfc97f..4941cf7eb7e 100644
--- a/CodenameOne/src/com/codename1/ui/Display.java
+++ b/CodenameOne/src/com/codename1/ui/Display.java
@@ -6867,8 +6867,8 @@ public void resetAttestation() {
/// Acknowledges that a backend recorded the attested key. See
/// `com.codename1.security.DeviceIntegrity#confirmAttestation()`.
- public void confirmAttestation() {
- impl.confirmAttestation();
+ public void confirmAttestation(String keyId) {
+ impl.confirmAttestation(keyId);
}
/// Returns digests of the certificates the running app is signed with. Low level hook for the
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index 99f766d3268..7647e1dbdbe 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -19051,7 +19051,7 @@ public void resetAttestation() {
}
@Override
- public void confirmAttestation() {
+ public void confirmAttestation(String keyId) {
// No client-side key here either, so there is nothing to acknowledge.
}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 2505d402600..50c1f457a21 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -237,7 +237,10 @@ private void resetLocked() {
* invalid key, and resets a key that was in fact perfectly good. The whole
* point of attest-once is not to burn keys that way.
*/
- void confirmAttestation() {
+ void confirmAttestation(String keyId) {
+ if (keyId == null || keyId.length() == 0) {
+ return;
+ }
synchronized (flowLock) {
// Re-read inside the lock. Checking first and transitioning afterwards let a
// reset and a replacement key land in between, so this would stamp
@@ -247,6 +250,14 @@ void confirmAttestation() {
if (!STATE_PENDING.equals(store.get(KEY_STATE))) {
return;
}
+ // And it must acknowledge THIS key. A response for an earlier attestation
+ // can arrive after a reset has already replaced the identity, and promoting
+ // on that would mark a key attested that the backend has never seen -- so
+ // the next assertion is rejected and costs another reset, which is the loop
+ // this state exists to break.
+ if (!keyId.equals(store.get(KEY_ID))) {
+ return;
+ }
store.set(KEY_STATE, STATE_ATTESTED);
store.remove(KEY_PENDING_SINCE);
}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
index 1b51df43b0c..3dc1f0979ed 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
@@ -4581,8 +4581,8 @@ public void resetAttestation() {
}
@Override
- public void confirmAttestation() {
- deviceIntegrity().confirmAttestation();
+ public void confirmAttestation(String keyId) {
+ deviceIntegrity().confirmAttestation(keyId);
}
/**
From 4bddbdf4bcb4b2a00b4896a548305a87a9b0ae6d Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 21:46:10 +0300
Subject: [PATCH 24/96] Treat an absent chain as unavailable, and hold the key
across the write
The iOS contract lets the native TLS callback hand back an empty
certificate array when the chain is not in the TLS cache. The guard
converted that into empty digest arrays and asked the engine to verify
them, and an enforced host has no pin matching nothing -- so a perfectly
good cached connection failed with PIN_MISMATCH naming a certificate
nobody had seen. Pinning fails open on unavailability everywhere else in
this design; an absent chain now follows the same rule.
AndroidSecureStorage held PLAIN_KEY_LOCK for the key lookup and released
it before encrypting and storing. A concurrent resetPlainKey() could then
delete the alias and clear the preferences in between, so the writer
reported success while its ciphertext was encrypted under a key that no
longer existed -- unreadable forever, silently. The lock now spans the
whole use-and-persist on both set and get, and the reset's own two steps.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../security/shield/ShieldNetworkGuard.java | 11 ++-
.../impl/android/AndroidSecureStorage.java | 69 +++++++++++--------
2 files changed, 52 insertions(+), 28 deletions(-)
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java b/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
index 61283554621..650b378a1cf 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
@@ -65,7 +65,16 @@ public void checkCertificates(ConnectionRequest request,
if (host == null || !AppShield.policyFor(host).isEnforcePins()) {
return;
}
- String[] spki = new String[certificates == null ? 0 : certificates.length];
+ if (certificates == null || certificates.length == 0) {
+ // The iOS contract explicitly allows an empty array when the chain is not
+ // available from the TLS cache. That is "we did not see a certificate", not
+ // "we saw a wrong one" -- and reporting a mismatch would fail a perfectly
+ // good cached connection with a PIN_MISMATCH naming a certificate nobody
+ // observed. Pinning fails open on unavailability everywhere else in this
+ // design; this is the same rule.
+ return;
+ }
+ String[] spki = new String[certificates.length];
String[] certs = new String[spki.length];
for (int i = 0; i < spki.length; i++) {
spki[i] = certificates[i].getPublicKeyDigest();
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
index 7d7b20b375f..6077aaee3c7 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
@@ -220,22 +220,29 @@ public boolean set(String account, String value) {
return legacyPlainSet(account, value);
}
try {
- SecretKey key = plainKey(true);
- if (key == null) {
- return false;
- }
- Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
- c.init(Cipher.ENCRYPT_MODE, key);
- byte[] enc = c.doFinal(value.getBytes("UTF-8"));
- SharedPreferences prefs = plainPrefs();
- if (prefs == null) {
- return false;
+ // The whole use-and-persist runs under the same lock a reset takes.
+ // Releasing it after the lookup let a concurrent resetPlainKey() delete the
+ // alias and clear the preferences between here and the write, so this
+ // reported success while storing ciphertext under a key that no longer
+ // exists -- unreadable forever, and silently so.
+ synchronized (PLAIN_KEY_LOCK) {
+ SecretKey key = plainKey(true);
+ if (key == null) {
+ return false;
+ }
+ Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
+ c.init(Cipher.ENCRYPT_MODE, key);
+ byte[] enc = c.doFinal(value.getBytes("UTF-8"));
+ SharedPreferences prefs = plainPrefs();
+ if (prefs == null) {
+ return false;
+ }
+ prefs.edit()
+ .putString(account, Base64.encodeToString(c.getIV(), Base64.NO_WRAP)
+ + ":" + Base64.encodeToString(enc, Base64.NO_WRAP))
+ .apply();
+ return true;
}
- prefs.edit()
- .putString(account, Base64.encodeToString(c.getIV(), Base64.NO_WRAP)
- + ":" + Base64.encodeToString(enc, Base64.NO_WRAP))
- .apply();
- return true;
} catch (InvalidKeyException e) {
// Includes KeyPermanentlyInvalidatedException.
resetPlainKey();
@@ -276,15 +283,19 @@ public String get(String account) {
return legacy;
}
try {
- SecretKey key = plainKey(false);
- if (key == null) {
- return null;
+ // Same reasoning as set(): a reset landing mid-read would otherwise
+ // invalidate the key between the lookup and the decrypt.
+ synchronized (PLAIN_KEY_LOCK) {
+ SecretKey key = plainKey(false);
+ if (key == null) {
+ return null;
+ }
+ byte[] iv = Base64.decode(stored.substring(0, sep), Base64.NO_WRAP);
+ byte[] enc = Base64.decode(stored.substring(sep + 1), Base64.NO_WRAP);
+ Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
+ c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
+ return new String(c.doFinal(enc), "UTF-8");
}
- byte[] iv = Base64.decode(stored.substring(0, sep), Base64.NO_WRAP);
- byte[] enc = Base64.decode(stored.substring(sep + 1), Base64.NO_WRAP);
- Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
- c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
- return new String(c.doFinal(enc), "UTF-8");
} catch (InvalidKeyException e) {
// The key was invalidated out from under us (device-wide credential
// change, or the Samsung 8.0.0 quirk documented on the biometric
@@ -365,16 +376,20 @@ private SecretKey plainKey(boolean create) throws Exception {
}
private void resetPlainKey() {
+ // Deleting the key and dropping the ciphertexts it protected are one step, under
+ // the same lock readers and writers hold. Clearing outside it left a window
+ // where a writer had already encrypted under the old key and was about to store
+ // a value this was about to wipe -- or worse, stored it just after.
synchronized (PLAIN_KEY_LOCK) {
try {
keyStore().deleteEntry(PLAIN_KEY_ID);
} catch (KeyStoreException e) {
Log.e(e);
}
- }
- SharedPreferences prefs = plainPrefs();
- if (prefs != null) {
- prefs.edit().clear().apply();
+ SharedPreferences prefs = plainPrefs();
+ if (prefs != null) {
+ prefs.edit().clear().apply();
+ }
}
}
From 7c5cefbebe9b6fb9e8f7136055b974229b93cd97 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 22:07:31 +0300
Subject: [PATCH 25/96] Fall back to the flat chain instead of skipping
enforcement
getSSLCertificatesEx returns an empty array on any failure, and the guard
now reads empty as "no chain available" and fails open -- so a hiccup in
the enriched path silently disabled pinning while the legacy fingerprints
were still perfectly obtainable. An empty enriched view is reported as
null, which makes the caller fall back to the flat list and enforce on
certificate digests rather than on nothing.
Co-Authored-By: Claude Opus 5 (1M context)
---
CodenameOne/src/com/codename1/io/ConnectionRequest.java | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index 08f9183428c..e289ac81b99 100644
--- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java
+++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
@@ -1787,7 +1787,14 @@ private SSLCertificate[] guardSSLCertificates(Object connection, String url)
if (!collectPublicKeyDigests || !impl.canGetPublicKeyDigests()) {
return null;
}
- return parseGroupedCertificates(impl.getSSLCertificatesEx(connection, url));
+ SSLCertificate[] enriched =
+ parseGroupedCertificates(impl.getSSLCertificatesEx(connection, url));
+ // Null, not an empty array, when the richer form produced nothing. Ports return
+ // empty on any failure, and the guard reads empty as "no chain available" and
+ // fails open -- so a hiccup in the enriched path would silently disable pinning
+ // while the flat fingerprints were still perfectly obtainable. Null makes the
+ // caller fall back to those instead of to no enforcement.
+ return enriched.length == 0 ? null : enriched;
}
/// The flat, one-entry-per-fingerprint view every existing caller has always seen.
From 7706da86cf43117819e6f13b4fc9b6da1992a3ed Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 22:17:33 +0300
Subject: [PATCH 26/96] Remove headers case-insensitively, and do not resubmit
a spent key
removeRequestHeader matched the exact key only. HTTP header names are
case-insensitive, so a token added as "X-CN1-Attest" and removed as
"x-cn1-attest" survived -- and initConnection emits whatever is left, so
on a redirect from a protected host to an unprotected one the bearer
token went to the redirect target. That is precisely what the removal
exists to prevent. Folding is ASCII-only, so it does not depend on the
device locale.
App Attest attestation is one-time and rate limited, but a key whose
attestation was interrupted still looked identical to one that had never
been submitted -- so the next launch resubmitted it, Apple rejected the
spent key, and the failure repeated on every request until something
reset. A marker is now written before the call and cleared when the
outcome is recorded, so a resumed attempt discards the key and starts
clean instead. One rate-limited key is spent either way; this way the
device recovers by itself.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/io/ConnectionRequest.java | 46 ++++++++++++++++++-
.../impl/ios/IOSDeviceIntegrity.java | 36 +++++++++++++--
2 files changed, 76 insertions(+), 6 deletions(-)
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index e289ac81b99..78204c21867 100644
--- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java
+++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
@@ -657,9 +657,51 @@ public void addRequestHeader(String key, String value) {
/// headers: anything scoped to the original host has to be removable before
/// the retry, or it follows the redirect to wherever it points.
public void removeRequestHeader(String key) {
- if (userHeaders != null && key != null) {
- userHeaders.remove(key);
+ if (userHeaders == null || key == null) {
+ return;
+ }
+ userHeaders.remove(key);
+ // And any other spelling of it. HTTP header names are case-insensitive, so a
+ // token added as "X-CN1-Attest" and removed as "x-cn1-attest" would survive an
+ // exact-key removal -- and initConnection emits everything left, which on a
+ // redirect from a protected host to an unprotected one means handing the bearer
+ // token to the redirect target. That is the case this method exists to prevent.
+ Vector matches = null;
+ Enumeration keys = userHeaders.keys();
+ while (keys.hasMoreElements()) {
+ String existing = (String) keys.nextElement();
+ if (existing != null && existing.length() == key.length()
+ && equalsIgnoreAsciiCase(existing, key)) {
+ if (matches == null) {
+ matches = new Vector();
+ }
+ matches.addElement(existing);
+ }
}
+ if (matches != null) {
+ for (int i = 0; i < matches.size(); i++) {
+ userHeaders.remove(matches.elementAt(i));
+ }
+ }
+ }
+
+ /// ASCII-only case-insensitive comparison, so the result never depends on the device locale --
+ /// under the Turkish locale an uppercase `I` does not fold to `i`.
+ private static boolean equalsIgnoreAsciiCase(String a, String b) {
+ for (int i = 0; i < a.length(); i++) {
+ char x = a.charAt(i);
+ char y = b.charAt(i);
+ if (x >= 'A' && x <= 'Z') {
+ x = (char) (x + 32);
+ }
+ if (y >= 'A' && y <= 'Z') {
+ y = (char) (y + 32);
+ }
+ if (x != y) {
+ return false;
+ }
+ }
+ return true;
}
/// Adds the given header to the request that will be sent unless the header
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 50c1f457a21..e8a15a96c89 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -91,6 +91,15 @@ final class IOSDeviceIntegrity {
private static final String KEY_RETRY_AFTER = "cn1.appattest.retryAfter";
private static final String KEY_PENDING_SINCE = "cn1.appattest.pendingSince";
+ /**
+ * Set immediately before {@code attestKey}, cleared once the result is recorded.
+ *
+ * Attestation is one-time and rate limited, so a key that has already been
+ * submitted must not be submitted again. Finding this marker on a later launch means
+ * the app died between Apple accepting the attestation and us persisting that fact
+ * -- and the key may well be spent, so the only safe move is a fresh one.
+ */
+ private static final String KEY_ATTEST_STARTED = "cn1.appattest.attestStarted";
private static final String STATE_NEW = "new";
private static final String STATE_ATTESTED = "attested";
@@ -205,6 +214,7 @@ private void resetLocked() {
boolean stateGone = store.remove(KEY_STATE);
store.remove(KEY_RETRY_AFTER);
store.remove(KEY_PENDING_SINCE);
+ store.remove(KEY_ATTEST_STARTED);
if (!idGone || !stateGone) {
// The two deletions are not atomic, so a partial failure leaves half the
// identity gone. Treating that as "untouched" would let a callback from the
@@ -338,11 +348,25 @@ AsyncResource requestToken(String nonce) {
return r;
}
bootstrapInFlight = true;
- if (keyId != null && keyId.length() > 0) {
- // A key exists but was never attested -- a previous attempt
- // died between the two steps. Attest that key rather than
- // generating another.
+ if (keyId != null && keyId.length() > 0
+ && store.get(KEY_ATTEST_STARTED) == null) {
+ // A key exists but attestation was never started for it -- the
+ // previous attempt died between generating and submitting. Attest
+ // that key rather than burning another.
attestKey(r, nonce, keyId);
+ } else if (keyId != null && keyId.length() > 0) {
+ // Attestation WAS started for this key and we never recorded the
+ // outcome, so Apple may already have consumed it. Re-submitting a
+ // spent key is rejected and the failure repeats on every request
+ // until something resets, so discard it and start clean. One
+ // rate-limited key is spent either way; this way the device recovers.
+ store.remove(KEY_ID);
+ store.remove(KEY_STATE);
+ store.remove(KEY_ATTEST_STARTED);
+ PendingRequest fresh = new PendingRequest(r, nonce,
+ PendingRequest.OP_GENERATE_KEY, null);
+ int rid = register(fresh);
+ nativeInstance.appAttestGenerateKey(rid);
} else {
int rid = register(pending);
nativeInstance.appAttestGenerateKey(rid);
@@ -387,6 +411,9 @@ private void attestKey(AsyncResource r, String nonce, String keyId, bool
// The attestation binds to SHA-256 of the raw nonce. Hashing here rather
// than natively keeps a single hash implementation across both paths.
String hash = base64(Hash.sha256(bytes(nonce)));
+ // Recorded BEFORE the call, so a crash between here and the result is
+ // distinguishable from never having tried.
+ SecureStorage.getInstance().set(KEY_ATTEST_STARTED, "1");
PendingRequest pending = new PendingRequest(r, nonce, PendingRequest.OP_ATTEST, keyId);
pending.retried = retried;
int rid = register(pending);
@@ -458,6 +485,7 @@ public static void nativeKeyGenerated(final int requestId, final String keyId) {
// and leave nothing half-written behind.
store.remove(KEY_ID);
store.remove(KEY_STATE);
+ store.remove(KEY_ATTEST_STARTED);
instance.bootstrapInFlight = false;
fail(pending, "App Attest could not store its key identifier");
instance.failBootstrapWaiters(
From 0d53327911596cc66b776c0c51d0e725592482b7 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 22:30:01 +0300
Subject: [PATCH 27/96] Commit preference writes, guard the native callback,
persist the retry
SharedPreferences.apply() is asynchronous, so a write could land after a
reset that ran once the lock was released -- ciphertext persisted under a
key already deleted, unreadable and silent. Holding the lock is only
atomic if the persist finishes inside it, so both the write and the
reset's clear now use commit().
The iOS native certificate callback did not guard the guard. An unchecked
exception there runs on the TLS delegate thread with the handshake open
and would take the process with it; a guard that crashed has observed no
mismatch, so it now fails open while an IOException still vetoes.
The one-shot invalid-key recovery lived on the in-flight request, so the
next caller was reconstructed as a first attempt and could reset and burn
another rate-limited key -- repeatedly. It is persisted now, and cleared
by a successful attestation or an explicit reset.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../src/com/codename1/io/ConnectionRequest.java | 10 +++++++++-
.../impl/android/AndroidSecureStorage.java | 14 ++++++++++----
.../codename1/impl/ios/IOSDeviceIntegrity.java | 17 ++++++++++++++++-
3 files changed, 35 insertions(+), 6 deletions(-)
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index 78204c21867..727e7b9e2ad 100644
--- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java
+++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
@@ -960,7 +960,15 @@ boolean checkCertificatesNativeCallback() {
// may have opted into inspection by itself.
SSLCertificate[] forGuard = _connection == null
? null : guardSSLCertificates(_connection, url);
- guard.checkCertificates(this, forGuard == null ? certs : forGuard);
+ try {
+ guard.checkCertificates(this, forGuard == null ? certs : forGuard);
+ } catch (RuntimeException t) {
+ // This runs on the iOS TLS delegate thread with the handshake open,
+ // so an unchecked exception escaping would take the process with it.
+ // A guard that crashes has not observed a mismatch, so it fails open;
+ // an IOException is a real veto and is left to propagate.
+ Log.e(t);
+ }
}
return !shouldStop();
} catch (IOException ex) {
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
index 6077aaee3c7..bd0e5e55217 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
@@ -237,11 +237,14 @@ public boolean set(String account, String value) {
if (prefs == null) {
return false;
}
- prefs.edit()
+ // commit(), not apply(): apply() is asynchronous, so the write could land
+ // on disk after a reset that ran once this lock was released -- storing
+ // ciphertext under a key that had already been deleted. Holding the lock
+ // is only atomic if the persist finishes inside it.
+ return prefs.edit()
.putString(account, Base64.encodeToString(c.getIV(), Base64.NO_WRAP)
+ ":" + Base64.encodeToString(enc, Base64.NO_WRAP))
- .apply();
- return true;
+ .commit();
}
} catch (InvalidKeyException e) {
// Includes KeyPermanentlyInvalidatedException.
@@ -388,7 +391,10 @@ private void resetPlainKey() {
}
SharedPreferences prefs = plainPrefs();
if (prefs != null) {
- prefs.edit().clear().apply();
+ // Also commit(), for the same reason: this method's whole purpose is to
+ // make the key deletion and the ciphertext deletion one step, and an
+ // asynchronous clear can be reordered after a writer's pending write.
+ prefs.edit().clear().commit();
}
}
}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index e8a15a96c89..2268fee0e71 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -100,6 +100,15 @@ final class IOSDeviceIntegrity {
* -- and the key may well be spent, so the only safe move is a fresh one.
*/
private static final String KEY_ATTEST_STARTED = "cn1.appattest.attestStarted";
+ /**
+ * Marks the one-shot invalid-key recovery as already spent.
+ *
+ * Persisted rather than carried on the in-flight request: {@code pending.retried}
+ * dies with that request, so the next caller was reconstructed as a first attempt
+ * and allowed to reset and burn another rate-limited key -- repeatedly, once the OS
+ * has decided it dislikes this device's keys.
+ */
+ private static final String KEY_RECOVERY_SPENT = "cn1.appattest.recoverySpent";
private static final String STATE_NEW = "new";
private static final String STATE_ATTESTED = "attested";
@@ -215,6 +224,7 @@ private void resetLocked() {
store.remove(KEY_RETRY_AFTER);
store.remove(KEY_PENDING_SINCE);
store.remove(KEY_ATTEST_STARTED);
+ store.remove(KEY_RECOVERY_SPENT);
if (!idGone || !stateGone) {
// The two deletions are not atomic, so a partial failure leaves half the
// identity gone. Treating that as "untouched" would let a callback from the
@@ -633,7 +643,9 @@ public static void nativeAttestError(final int requestId, final int errorCode,
"App Attest state was reset while this request was in flight");
return;
}
- if (errorCode == DC_ERROR_INVALID_KEY && !pending.retried) {
+ boolean recoverySpent = pending.retried
+ || SecureStorage.getInstance().get(KEY_RECOVERY_SPENT) != null;
+ if (errorCode == DC_ERROR_INVALID_KEY && !recoverySpent) {
// The key is gone or was never valid. Wipe it and try once from
// scratch; a second failure is reported rather than looped.
try {
@@ -647,6 +659,9 @@ public static void nativeAttestError(final int requestId, final int errorCode,
"App Attest could not discard the rejected key");
return;
}
+ // Recorded before the replacement starts, so a request arriving after
+ // this process dies still sees the recovery as used.
+ SecureStorage.getInstance().set(KEY_RECOVERY_SPENT, "1");
PendingRequest retry = new PendingRequest(pending.result, pending.nonce,
PendingRequest.OP_GENERATE_KEY, null);
retry.retried = true;
From 86ad845d6e2d6005e6b73fa833cee8fa4e20449d Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Jul 2026 22:39:25 +0300
Subject: [PATCH 28/96] Check the marker write, and stop sharing the KeyStore
across tiers
The attestation-start marker write was unchecked, so a refused keychain
write let attestKey proceed anyway -- recreating exactly the case the
marker exists to catch: the app dies after Apple consumes the one-time
attestation, the next launch sees a key with no marker, and resubmits the
spent key. It now fails the request instead.
The non-prompting tier used the shared AndroidKeyStore instance, which
the biometric tier touches without PLAIN_KEY_LOCK. KeyStore is not thread
safe, so that traded a race inside one tier for a race across two,
surfacing as intermittent keystore errors neither tier's code explains.
This tier now uses its own instance, which avoids widening the lock into
the biometric path.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/android/AndroidSecureStorage.java | 20 +++++++++++++-----
.../impl/ios/IOSDeviceIntegrity.java | 21 +++++++++++++++++--
2 files changed, 34 insertions(+), 7 deletions(-)
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
index bd0e5e55217..41c708f95e8 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
@@ -359,8 +359,14 @@ private SecretKey plainKey(boolean create) throws Exception {
// one had already encrypted with -- leaving that ciphertext permanently
// undecryptable. The shared KeyStore is not thread safe either.
synchronized (PLAIN_KEY_LOCK) {
- keyStore().load(null);
- SecretKey existing = (SecretKey) keyStore.getKey(PLAIN_KEY_ID, null);
+ // A KeyStore instance of this tier's own. The biometric tier touches the
+ // shared one without PLAIN_KEY_LOCK, and KeyStore is not thread safe, so
+ // sharing it here would trade a race inside this tier for a race across the
+ // two -- surfacing as intermittent keystore errors that neither tier's code
+ // would explain. Widening this lock into the biometric path would be worse.
+ KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE);
+ ks.load(null);
+ SecretKey existing = (SecretKey) ks.getKey(PLAIN_KEY_ID, null);
if (existing != null || !create) {
return existing;
}
@@ -374,7 +380,7 @@ private SecretKey plainKey(boolean create) throws Exception {
.setRandomizedEncryptionRequired(true)
.build());
gen.generateKey();
- return (SecretKey) keyStore.getKey(PLAIN_KEY_ID, null);
+ return (SecretKey) ks.getKey(PLAIN_KEY_ID, null);
}
}
@@ -385,8 +391,12 @@ private void resetPlainKey() {
// a value this was about to wipe -- or worse, stored it just after.
synchronized (PLAIN_KEY_LOCK) {
try {
- keyStore().deleteEntry(PLAIN_KEY_ID);
- } catch (KeyStoreException e) {
+ // Same reasoning as plainKey(): this tier does not touch the shared
+ // KeyStore instance.
+ KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE);
+ ks.load(null);
+ ks.deleteEntry(PLAIN_KEY_ID);
+ } catch (Exception e) {
Log.e(e);
}
SharedPreferences prefs = plainPrefs();
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 2268fee0e71..f3b6ae63e4d 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -422,8 +422,25 @@ private void attestKey(AsyncResource r, String nonce, String keyId, bool
// than natively keeps a single hash implementation across both paths.
String hash = base64(Hash.sha256(bytes(nonce)));
// Recorded BEFORE the call, so a crash between here and the result is
- // distinguishable from never having tried.
- SecureStorage.getInstance().set(KEY_ATTEST_STARTED, "1");
+ // distinguishable from never having tried -- and the write is checked, because
+ // proceeding without the marker recreates exactly the case it exists to catch:
+ // the app dies after Apple consumes the one-time attestation, the next launch
+ // sees a key with no marker, and submits the spent key again.
+ if (!SecureStorage.getInstance().set(KEY_ATTEST_STARTED, "1")) {
+ bootstrapInFlight = false;
+ failBootstrapWaiters("App Attest could not record that attestation had started");
+ final AsyncResource target = r;
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ if (!target.isDone()) {
+ target.error(new RuntimeException("App Attest could not record that "
+ + "attestation had started, so it was not attempted rather "
+ + "than risk spending the key untracked"));
+ }
+ }
+ });
+ return;
+ }
PendingRequest pending = new PendingRequest(r, nonce, PendingRequest.OP_ATTEST, keyId);
pending.retried = retried;
int rid = register(pending);
From 4a001ce067ac0fe8d16b815aa469883ff4bb4cfd Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 01:09:33 +0300
Subject: [PATCH 29/96] Honour the exhausted recovery, and clear it on success
Two halves of the same marker, both wrong in opposite directions.
The interrupted-attestation branch never consulted KEY_RECOVERY_SPENT, so
once a replacement key also failed, every later request came back through
it, discarded the key and generated another -- exactly the loop the
marker exists to stop. That case now reports instead, pointing at
resetAttestation().
And the success path never cleared the marker -- my earlier edit for that
did not land. A device whose recovery had succeeded would then refuse a
future replacement when iOS legitimately invalidated the new key, leaving
every assertion failing until the app reset by hand.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/ios/IOSDeviceIntegrity.java | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index f3b6ae63e4d..a4057e57273 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -364,6 +364,24 @@ AsyncResource requestToken(String nonce) {
// previous attempt died between generating and submitting. Attest
// that key rather than burning another.
attestKey(r, nonce, keyId);
+ } else if (keyId != null && keyId.length() > 0
+ && store.get(KEY_RECOVERY_SPENT) != null) {
+ // The interrupted key belongs to a recovery that has already been
+ // used once. Discarding it here would generate yet another
+ // rate-limited key, and would do so on every subsequent request --
+ // the exact loop the spent marker exists to stop. Report instead.
+ bootstrapInFlight = false;
+ final AsyncResource exhausted = r;
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ if (!exhausted.isDone()) {
+ exhausted.error(new RuntimeException("App Attest could not "
+ + "establish a usable key on this device; call "
+ + "DeviceIntegrity.resetAttestation() to try again"));
+ }
+ }
+ });
+ return r;
} else if (keyId != null && keyId.length() > 0) {
// Attestation WAS started for this key and we never recorded the
// outcome, so Apple may already have consumed it. Re-submitting a
@@ -590,6 +608,11 @@ public static void nativeAttestationReady(final int requestId, final String atte
"App Attest could not record its registration deadline");
return;
}
+ // The recovery this key came from, if any, is now complete. Leaving the
+ // marker would refuse a future replacement when iOS legitimately invalidates
+ // THIS key, and every later assertion would fail until the app reset by hand.
+ store.remove(KEY_ATTEST_STARTED);
+ store.remove(KEY_RECOVERY_SPENT);
instance.currentBackoff = MIN_BACKOFF_MILLIS;
instance.bootstrapInFlight = false;
// Deliberately NOT asserted here, for the reason above. Queued callers
From 1a9dd40dd85f75ff1a8b70fb0baccd252466f9c0 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 01:20:03 +0300
Subject: [PATCH 30/96] Run the request hook only when the request asked for it
shouldInspectCertificates() is now also true when the guard pins the host,
and both certificate paths then called the request's own hook regardless
-- so a subclass that overrides checkSSLCertificates and deliberately
disables it could reject or mutate requests purely because App Shield
covers that host. The hook's contract is that it runs when the request
opted in, and it is gated on that again; the guard runs independently.
UnrecoverableKeyException on a write is handled like an invalid key. It
fell into the generic catch, leaving the unusable alias installed, so
every later set() returned false indefinitely and only a read happened to
clear it -- an app that only writes could never store anything again.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/io/ConnectionRequest.java | 20 ++++++++++++++-----
.../impl/android/AndroidSecureStorage.java | 7 +++++++
2 files changed, 22 insertions(+), 5 deletions(-)
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index 727e7b9e2ad..068f7f4caba 100644
--- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java
+++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
@@ -951,7 +951,10 @@ boolean checkCertificatesNativeCallback() {
}
try {
SSLCertificate[] certs = getSSLCertificates();
- checkSSLCertificates(certs);
+ if (checkSSLCertificates) {
+ // Same gate as the non-callback path.
+ checkSSLCertificates(certs);
+ }
NetworkGuard guard = NetworkManager.getNetworkGuard();
if (guard != null && guardWantsCertificates) {
// Same split as the non-callback path: the hook above sees the flat view
@@ -1132,10 +1135,17 @@ boolean performOperationComplete() throws IOException {
// empty POST bodies.
!Util.getImplementation().checkSSLCertificatesRequiresCallbackFromNative()) {
sslCertificates = getSSLCertificatesImpl(connection, url);
- // The request's own hook runs first and on the legacy flat view, so an
- // app that already pins by overriding it keeps working; the guard layers
- // on, and only the guard sees the enriched per-certificate form.
- checkSSLCertificates(sslCertificates);
+ // Only when the request asked for it. shouldInspectCertificates() is now
+ // also true when the guard pins the host, and running the hook on that
+ // basis would let a subclass that deliberately disabled it reject or
+ // mutate requests purely because App Shield covers the host. The hook's
+ // contract is that it runs when the request opted in.
+ if (checkSSLCertificates) {
+ // The legacy flat view, so an app that already pins by overriding it
+ // keeps working; only the guard sees the enriched per-certificate
+ // form.
+ checkSSLCertificates(sslCertificates);
+ }
NetworkGuard certGuard = NetworkManager.getNetworkGuard();
if (certGuard != null && guardWantsCertificates) {
SSLCertificate[] forGuard = guardSSLCertificates(connection, url);
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
index 41c708f95e8..3eb5d35a9f8 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java
@@ -250,6 +250,13 @@ public boolean set(String account, String value) {
// Includes KeyPermanentlyInvalidatedException.
resetPlainKey();
return false;
+ } catch (UnrecoverableKeyException e) {
+ // Handled like an invalid key rather than falling into the generic catch:
+ // leaving the unusable alias installed made every later write return false
+ // for good, and only a read happened to clear it -- so an app that only ever
+ // writes could never store anything again.
+ resetPlainKey();
+ return false;
} catch (Throwable t) {
Log.e(t);
return false;
From 08cbb049b4a5fd83e956bb70facc66e66dd0a15e Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 01:32:32 +0300
Subject: [PATCH 31/96] Resolve implicit host policies late, and hold the spent
marker in memory
ShieldConfig snapshotted the default failure mode when a host was
registered without an explicit policy, so .protect(h).defaultFailureMode(
CLOSED) left that host fail-open while the finished config reported a
closed default. A builder gets chained in whatever order reads well, so
implicit registrations are now recorded as implicit and resolved against
the default at read time.
The recovery-spent marker's write result was ignored, so a refused
keychain write let every later request discard the replacement key and
generate another. It is mirrored in memory, which bounds the one-shot
recovery for the life of the process even when the keychain will not
persist it.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../security/shield/ShieldConfig.java | 55 +++++++++++++++----
.../impl/ios/IOSDeviceIntegrity.java | 16 +++++-
2 files changed, 58 insertions(+), 13 deletions(-)
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java b/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
index 6fc70b2cb28..e23edcc5918 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
@@ -50,6 +50,9 @@ public final class ShieldConfig {
private String endpoint = DEFAULT_ENDPOINT;
private String tokenHeader = DEFAULT_TOKEN_HEADER;
private FailureMode defaultFailureMode = FailureMode.OPEN;
+ /// Hosts registered without an explicit policy. Their policy is resolved from the default at
+ /// read time, so it does not depend on the order the builder was called in.
+ private final java.util.Vector implicitHosts = new java.util.Vector();
private int refreshThresholdPercent = 50;
private boolean collectSignals = true;
private final Hashtable hostPolicies = new Hashtable();
@@ -100,8 +103,20 @@ public ShieldConfig collectSignals(boolean collect) {
/// subdomains. Hosts not registered here are never touched.
public ShieldConfig protect(String hostPattern, HostPolicy policy) {
if (hostPattern != null && hostPattern.length() > 0) {
- hostPolicies.put(ShieldHosts.normalize(hostPattern),
- policy == null ? implicitPolicy() : policy);
+ String key = ShieldHosts.normalize(hostPattern);
+ if (policy == null) {
+ // Recorded as implicit rather than resolved now. A builder is chained in
+ // whatever order reads well, so `.protect(h).defaultFailureMode(CLOSED)`
+ // must mean the same thing as the reverse -- snapshotting the default at
+ // registration time left the host fail-open while the finished config
+ // reported a closed default, which is the kind of disagreement nobody
+ // finds until it matters.
+ hostPolicies.remove(key);
+ implicitHosts.addElement(key);
+ } else {
+ implicitHosts.removeElement(key);
+ hostPolicies.put(key, policy);
+ }
}
return this;
}
@@ -109,7 +124,7 @@ public ShieldConfig protect(String hostPattern, HostPolicy policy) {
/// Registers a host with the default policy, which honours
/// [#defaultFailureMode(FailureMode)].
public ShieldConfig protect(String hostPattern) {
- return protect(hostPattern, implicitPolicy());
+ return protect(hostPattern, null);
}
/// The policy used when a host is registered without an explicit one.
@@ -153,28 +168,48 @@ public HostPolicy policyFor(String host) {
return HostPolicy.UNPROTECTED;
}
String h = ShieldHosts.normalize(host);
- Object exact = hostPolicies.get(h);
+ HostPolicy exact = policyForKey(h);
if (exact != null) {
- return (HostPolicy) exact;
+ return exact;
}
int dot = h.indexOf('.');
while (dot >= 0 && dot < h.length() - 1) {
- Object wild = hostPolicies.get("*." + h.substring(dot + 1));
+ HostPolicy wild = policyForKey("*." + h.substring(dot + 1));
if (wild != null) {
- return (HostPolicy) wild;
+ return wild;
}
dot = h.indexOf('.', dot + 1);
}
return HostPolicy.UNPROTECTED;
}
+ /// The policy registered for an exact key, or null. Implicit registrations resolve against the
+ /// default as it stands now, not as it stood when they were registered.
+ private HostPolicy policyForKey(String key) {
+ Object explicit = hostPolicies.get(key);
+ if (explicit != null) {
+ return (HostPolicy) explicit;
+ }
+ return implicitHosts.contains(key) ? implicitPolicy() : null;
+ }
+
/// True when at least one host is registered, so callers can skip work entirely.
public boolean hasProtectedHosts() {
- return !hostPolicies.isEmpty();
+ return !hostPolicies.isEmpty() || !implicitHosts.isEmpty();
}
- /// The registered host patterns.
+ /// The registered host patterns, explicit and implicit alike.
public Enumeration protectedHosts() {
- return hostPolicies.keys();
+ java.util.Vector all = new java.util.Vector();
+ Enumeration keys = hostPolicies.keys();
+ while (keys.hasMoreElements()) {
+ all.addElement(keys.nextElement());
+ }
+ for (int i = 0; i < implicitHosts.size(); i++) {
+ if (!all.contains(implicitHosts.elementAt(i))) {
+ all.addElement(implicitHosts.elementAt(i));
+ }
+ }
+ return all.elements();
}
}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index a4057e57273..f28fb7ad42d 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -165,6 +165,9 @@ final class IOSDeviceIntegrity {
* dies, which is the case the keychain was covering.
*/
private long retryAfterFallback;
+ /// Mirrors [#KEY_RECOVERY_SPENT] in memory, so a refused keychain write still bounds the
+ /// one-shot recovery for the life of the process rather than letting it repeat per request.
+ private boolean recoverySpentInMemory;
/** True while a generate-then-attest bootstrap is running. */
private boolean bootstrapInFlight;
/**
@@ -225,6 +228,7 @@ private void resetLocked() {
store.remove(KEY_PENDING_SINCE);
store.remove(KEY_ATTEST_STARTED);
store.remove(KEY_RECOVERY_SPENT);
+ recoverySpentInMemory = false;
if (!idGone || !stateGone) {
// The two deletions are not atomic, so a partial failure leaves half the
// identity gone. Treating that as "untouched" would let a callback from the
@@ -365,7 +369,8 @@ AsyncResource requestToken(String nonce) {
// that key rather than burning another.
attestKey(r, nonce, keyId);
} else if (keyId != null && keyId.length() > 0
- && store.get(KEY_RECOVERY_SPENT) != null) {
+ && (recoverySpentInMemory
+ || store.get(KEY_RECOVERY_SPENT) != null)) {
// The interrupted key belongs to a recovery that has already been
// used once. Discarding it here would generate yet another
// rate-limited key, and would do so on every subsequent request --
@@ -613,6 +618,7 @@ public static void nativeAttestationReady(final int requestId, final String atte
// THIS key, and every later assertion would fail until the app reset by hand.
store.remove(KEY_ATTEST_STARTED);
store.remove(KEY_RECOVERY_SPENT);
+ instance.recoverySpentInMemory = false;
instance.currentBackoff = MIN_BACKOFF_MILLIS;
instance.bootstrapInFlight = false;
// Deliberately NOT asserted here, for the reason above. Queued callers
@@ -683,7 +689,7 @@ public static void nativeAttestError(final int requestId, final int errorCode,
"App Attest state was reset while this request was in flight");
return;
}
- boolean recoverySpent = pending.retried
+ boolean recoverySpent = pending.retried || instance.recoverySpentInMemory
|| SecureStorage.getInstance().get(KEY_RECOVERY_SPENT) != null;
if (errorCode == DC_ERROR_INVALID_KEY && !recoverySpent) {
// The key is gone or was never valid. Wipe it and try once from
@@ -700,7 +706,11 @@ public static void nativeAttestError(final int requestId, final int errorCode,
return;
}
// Recorded before the replacement starts, so a request arriving after
- // this process dies still sees the recovery as used.
+ // this process dies still sees the recovery as used. The in-memory
+ // copy is set regardless: if the keychain refuses the write, the
+ // one-shot limit still holds for the life of the process rather than
+ // letting every later request burn another key.
+ instance.recoverySpentInMemory = true;
SecureStorage.getInstance().set(KEY_RECOVERY_SPENT, "1");
PendingRequest retry = new PendingRequest(pending.result, pending.nonce,
PendingRequest.OP_GENERATE_KEY, null);
From c22f16730843754226d4605ce933fb1a2c0ca953 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 01:42:34 +0300
Subject: [PATCH 32/96] Use a per-start generation, and confirm the marker
deletion
MCPServer's reader thread identified its own run by transport identity, so
a caller that stopped and restarted with the SAME transport instance left
the old thread looking current -- it could run on beside the replacement
and eventually stop the server and close the transport underneath it. Each
start now carries a generation and the thread checks both.
The spent-recovery marker deletion result is honoured: if the keychain
refuses it, the in-memory copy stays set so the process keeps treating the
recovery as spent. Forgetting it while the persisted marker survived would
refuse the one-shot replacement the next time iOS legitimately invalidated
the key, leaving every assertion failing until the app reset by hand.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../src/com/codename1/mcp/MCPServer.java | 33 ++++++++++++-------
.../impl/ios/IOSDeviceIntegrity.java | 10 ++++--
2 files changed, 30 insertions(+), 13 deletions(-)
diff --git a/CodenameOne/src/com/codename1/mcp/MCPServer.java b/CodenameOne/src/com/codename1/mcp/MCPServer.java
index 51acdea382e..b1b2212c523 100644
--- a/CodenameOne/src/com/codename1/mcp/MCPServer.java
+++ b/CodenameOne/src/com/codename1/mcp/MCPServer.java
@@ -80,6 +80,9 @@ private static boolean isSupportedProtocol(String version) {
private String serverVersion = "1.0";
private boolean screenshotEnabled = true;
private boolean running;
+ /// Bumped by every start(), so a reader thread can tell its own run from a later one that
+ /// happens to have been handed the same transport instance.
+ private int startGeneration;
private MCPTransport transport;
private MCPVerbosity verbosity = MCPVerbosity.OFF;
@@ -139,6 +142,8 @@ public synchronized void start(MCPTransport transport) {
}
this.transport = transport;
running = true;
+ startGeneration++;
+ final int generation = startGeneration;
// Captured for the thread rather than read back off the field. A later start()
// replaces the field, and a reader thread that followed it would end up serving a
// transport the server no longer considers its own -- while the transport it was
@@ -147,7 +152,7 @@ public synchronized void start(MCPTransport transport) {
Thread readerThread = new Thread(new Runnable() {
@Override
public void run() {
- runLoop(mine);
+ runLoop(mine, generation);
}
}, "cn1-mcp-server");
readerThread.start();
@@ -164,27 +169,33 @@ public synchronized void stop() {
/// True while `t` is still the transport this server is serving over. False once stop()
/// has run, or once a restart handed the server a different transport -- in both cases
/// the thread holding `t` owns it alone and has to close it itself.
- private synchronized boolean isCurrent(MCPTransport t) {
- return running && transport == t; // NOPMD identity is the question, not equality
+ private synchronized boolean isCurrent(MCPTransport t, int generation) {
+ // The generation as well as the identity. A caller that stops and restarts with
+ // the SAME transport instance would otherwise leave the old reader thread looking
+ // current, so it could run on beside the replacement and eventually stop the
+ // server and close the transport underneath it.
+ return running && startGeneration == generation
+ && transport == t; // NOPMD identity is the question, not equality
}
/// Clears the running flag, but only on behalf of the transport that is still current.
/// A thread unwinding from a superseded transport must not stop a server that has since
/// been restarted over a new one.
- private synchronized void stopIfCurrent(MCPTransport t) {
- if (transport == t) { // NOPMD identity: is this still our transport?
+ private synchronized void stopIfCurrent(MCPTransport t, int generation) {
+ if (startGeneration == generation
+ && transport == t) { // NOPMD identity: is this still our transport?
running = false;
}
}
- private void runLoop(MCPTransport t) {
+ private void runLoop(MCPTransport t, int generation) {
// Opening is deferred to this thread, so by the time it happens the server may
// already have been stopped or restarted. Either way stop()'s close() ran against a
// transport that had not opened yet and therefore released nothing, so opening now
// would leave a transport registered with nobody left to close it. That registration
// is process-wide: every later open() is refused on the grounds that an agent is
// already being served.
- if (!isCurrent(t)) {
+ if (!isCurrent(t, generation)) {
return;
}
try {
@@ -199,17 +210,17 @@ private void runLoop(MCPTransport t) {
} catch (Throwable logErr) {
System.err.println("[cn1.mcp] transport open failed: " + ex);
}
- stopIfCurrent(t);
+ stopIfCurrent(t, generation);
t.close();
return;
}
- if (!isCurrent(t)) {
+ if (!isCurrent(t, generation)) {
// Same window, the far side of it: the server moved on while open() was in
// flight. Undo the registration this thread just took.
t.close();
return;
}
- while (isCurrent(t)) {
+ while (isCurrent(t, generation)) {
String line;
try {
line = t.readMessage();
@@ -231,7 +242,7 @@ private void runLoop(MCPTransport t) {
}
}
}
- stopIfCurrent(t);
+ stopIfCurrent(t, generation);
t.close();
}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index f28fb7ad42d..cc68b32c2b0 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -617,8 +617,14 @@ public static void nativeAttestationReady(final int requestId, final String atte
// marker would refuse a future replacement when iOS legitimately invalidates
// THIS key, and every later assertion would fail until the app reset by hand.
store.remove(KEY_ATTEST_STARTED);
- store.remove(KEY_RECOVERY_SPENT);
- instance.recoverySpentInMemory = false;
+ // The in-memory copy is cleared unconditionally, but the persisted marker is
+ // only forgotten once the keychain confirms the deletion. A stale marker
+ // would refuse the one-shot replacement the next time iOS legitimately
+ // invalidates this key, leaving every assertion failing until the app reset
+ // by hand -- so if the delete fails, the marker stays true here too and the
+ // process keeps behaving as though the recovery were spent, which is the
+ // conservative direction.
+ instance.recoverySpentInMemory = !store.remove(KEY_RECOVERY_SPENT);
instance.currentBackoff = MIN_BACKOFF_MILLIS;
instance.bootstrapInFlight = false;
// Deliberately NOT asserted here, for the reason above. Queued callers
From 4bba0eeca6157dee2c966dbdb65eae07242a6ba8 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 02:13:53 +0300
Subject: [PATCH 33/96] Compose the shield guard, and stop a stale MCP reader
closing a live transport
NetworkManager holds one guard and seals the slot, so an app with its own guard
had no way to keep the shield's. The advice it printed -- call attach() from
your own guard -- restores the token header and silently drops the certificate
callbacks, so HostPolicy.isEnforcePins() stopped being enforced and nothing
about the app's behaviour said so. AppShield.getNetworkGuard() now returns the
shield's guard so an app can delegate to it, documented with the whole
delegation including the interestingResponseHeaders/afterResponse positional
pairing, and the recovery message points at it.
MCPServer's stale-run teardown closed the transport unconditionally. A
stop()/start() pair over the same transport instance therefore had the old
reader close the transport the restarted server was serving over: the
generation check correctly kept it from stopping the server, then it killed the
server anyway one line later. Ownership and the close are now decided together
under the lock -- close when still current, close when superseded by a
replacement holding a different transport (nobody else can, and a leaked
transport stays registered process-wide), leave it alone when the replacement
reused this one. The test parks each reader on its own ticket so a superseded
reader can be released while the current one stays blocked, which is the only
way to observe what the stale thread does on its way out; it fails on the
previous code.
The two anonymous Runnables in IOSDeviceIntegrity's instance methods captured
the enclosing instance for as long as the EDT queue held them, for no reason.
Extracted as a static failResource(), which is also what SpotBugs was asking
for.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../src/com/codename1/mcp/MCPServer.java | 44 +++++--
.../codename1/security/shield/AppShield.java | 73 ++++++++++--
.../security/shield/ShieldNetworkGuard.java | 6 +-
.../impl/ios/IOSDeviceIntegrity.java | 42 +++----
.../mcp/MCPLoopbackTransportOpenTest.java | 112 ++++++++++++++++++
.../security/shield/ShieldApiTest.java | 26 ++++
6 files changed, 263 insertions(+), 40 deletions(-)
diff --git a/CodenameOne/src/com/codename1/mcp/MCPServer.java b/CodenameOne/src/com/codename1/mcp/MCPServer.java
index b1b2212c523..2b34d8e04d8 100644
--- a/CodenameOne/src/com/codename1/mcp/MCPServer.java
+++ b/CodenameOne/src/com/codename1/mcp/MCPServer.java
@@ -178,14 +178,32 @@ private synchronized boolean isCurrent(MCPTransport t, int generation) {
&& transport == t; // NOPMD identity is the question, not equality
}
- /// Clears the running flag, but only on behalf of the transport that is still current.
- /// A thread unwinding from a superseded transport must not stop a server that has since
- /// been restarted over a new one.
- private synchronized void stopIfCurrent(MCPTransport t, int generation) {
+ /// Clears the running flag when this thread is still the current run, and answers the
+ /// only other question a thread unwinding from `t` has: does it close `t`?
+ ///
+ /// Both halves have to be decided together under this lock. Deciding "am I current" and
+ /// then closing outside it leaves a window where a `stop()`/`start(sameTransport)` pair
+ /// hands `t` to a fresh generation -- and the old thread, correctly declining to stop
+ /// the *server*, would still close the transport the replacement is now serving over,
+ /// killing a server that had just been restarted.
+ ///
+ /// Three cases, only one of which closes nothing:
+ ///
+ /// - still current: this run is over, so clear `running` and close.
+ /// - superseded, and the replacement holds a different transport: nobody else can close
+ /// `t`, so this thread must. Leaking it is not benign -- an open transport stays
+ /// registered process-wide and every later `open()` is refused.
+ /// - superseded, and the replacement holds this same transport: leave it alone. It is
+ /// the live server's transport now, and the replacement thread will close it.
+ ///
+ /// @return true when the caller owns the close of `t`
+ private synchronized boolean releaseIfCurrent(MCPTransport t, int generation) {
if (startGeneration == generation
&& transport == t) { // NOPMD identity: is this still our transport?
running = false;
+ return true;
}
+ return transport != t; // NOPMD identity: reused by the replacement, or orphaned?
}
private void runLoop(MCPTransport t, int generation) {
@@ -210,14 +228,19 @@ private void runLoop(MCPTransport t, int generation) {
} catch (Throwable logErr) {
System.err.println("[cn1.mcp] transport open failed: " + ex);
}
- stopIfCurrent(t, generation);
- t.close();
+ if (releaseIfCurrent(t, generation)) {
+ t.close();
+ }
return;
}
if (!isCurrent(t, generation)) {
// Same window, the far side of it: the server moved on while open() was in
- // flight. Undo the registration this thread just took.
- t.close();
+ // flight. Undo the registration this thread just took -- unless the server
+ // moved on by restarting over this very transport, in which case it is not
+ // ours to undo.
+ if (releaseIfCurrent(t, generation)) {
+ t.close();
+ }
return;
}
while (isCurrent(t, generation)) {
@@ -242,8 +265,9 @@ private void runLoop(MCPTransport t, int generation) {
}
}
}
- stopIfCurrent(t, generation);
- t.close();
+ if (releaseIfCurrent(t, generation)) {
+ t.close();
+ }
}
/// Handles one inbound JSON-RPC message and returns the response line, or null
diff --git a/CodenameOne/src/com/codename1/security/shield/AppShield.java b/CodenameOne/src/com/codename1/security/shield/AppShield.java
index 1df5c8d64a6..ce318fecf8a 100644
--- a/CodenameOne/src/com/codename1/security/shield/AppShield.java
+++ b/CodenameOne/src/com/codename1/security/shield/AppShield.java
@@ -24,6 +24,7 @@
import com.codename1.io.ConnectionRequest;
import com.codename1.io.Log;
+import com.codename1.io.NetworkGuard;
import com.codename1.io.NetworkManager;
import com.codename1.security.shield.spi.ShieldEngine;
import com.codename1.security.shield.spi.ShieldEngineRegistry;
@@ -77,6 +78,7 @@ public final class AppShield {
private static ShieldConfig config;
private static boolean initialized;
+ private static NetworkGuard guard;
private static ShieldStatus lastStatus = ShieldStatus.NOT_INITIALIZED;
private static final Vector listeners = new Vector();
private static final Hashtable runtimeHosts = new Hashtable();
@@ -130,20 +132,75 @@ public static void init(ShieldConfig cfg) {
/// behaviour identical whether or not the enterprise engine was injected.
private static void installNetworkGuard() {
try {
- NetworkManager.setNetworkGuard(new ShieldNetworkGuard());
+ NetworkManager.setNetworkGuard(getNetworkGuard());
} catch (IllegalStateException e) {
- // The slot seals after the first install. An app that installed its
- // own guard keeps it; say so rather than failing startup, because the
- // consequence is that protected hosts are not decorated automatically
- // and that is worth knowing about.
- Log.p("AppShield: a network guard is already installed, so protected hosts will "
- + "not be decorated automatically. Call AppShield.attach(request) from "
- + "your own guard if you need both.");
+ // The slot seals after the first install. An app that installed its own guard
+ // keeps it; say so rather than failing startup, and point at the composition
+ // that gets everything back -- not just the token. Advising attach() alone
+ // would restore header decoration while quietly dropping pin enforcement,
+ // which is the half of the shield an app cannot notice is missing.
+ Log.p("AppShield: a network guard is already installed, so protected hosts are "
+ + "not decorated or pinned automatically. Delegate to "
+ + "AppShield.getNetworkGuard() from your own guard to restore both; "
+ + "see the AppShield.getNetworkGuard() documentation.");
} catch (Throwable t) {
Log.e(t);
}
}
+ /// The shield's own [NetworkGuard], for an app that has to install a guard of its own.
+ ///
+ /// [NetworkManager] holds a single guard and seals the slot on first install, so an app
+ /// with its own guard leaves no room for the shield's. Delegating to this one is the
+ /// supported way to have both. Delegate every method, not only
+ /// [NetworkGuard#beforeRequest(ConnectionRequest)]: attaching the token is the visible
+ /// half of the shield, and the certificate callbacks are the half that enforces
+ /// [HostPolicy#isEnforcePins()]. An app that forwards only `beforeRequest` gets tokens
+ /// and no pinning, and nothing about its behaviour says so.
+ ///
+ /// ```java
+ /// final NetworkGuard shield = AppShield.getNetworkGuard();
+ /// NetworkManager.setNetworkGuard(new NetworkGuard() {
+ /// public void beforeRequest(ConnectionRequest r) throws IOException {
+ /// myOwnHeaders(r);
+ /// shield.beforeRequest(r);
+ /// }
+ /// public boolean isCertificateCheckRequired(String url) {
+ /// return myOwnCheckNeeded(url) || shield.isCertificateCheckRequired(url);
+ /// }
+ /// public void checkCertificates(ConnectionRequest r,
+ /// ConnectionRequest.SSLCertificate[] c) throws IOException {
+ /// myOwnCheck(r, c);
+ /// shield.checkCertificates(r, c);
+ /// }
+ /// public String[] interestingResponseHeaders() {
+ /// return concat(myOwnHeaderNames(), shield.interestingResponseHeaders());
+ /// }
+ /// public void afterResponse(ConnectionRequest r, int code, String[] headers) {
+ /// shield.afterResponse(r, code, headers);
+ /// }
+ /// });
+ /// AppShield.init(cfg);
+ /// ```
+ ///
+ /// Note that `interestingResponseHeaders()` has to be the union of both guards' names, and
+ /// that the `headers` array handed to `afterResponse` is positional against it -- so a
+ /// composing guard must pass the shield the slice that corresponds to the shield's own
+ /// names, in that order. Installing the shield's guard directly, by calling
+ /// [#init(ShieldConfig)] before installing anything of your own, avoids the bookkeeping
+ /// entirely and is what most apps should do.
+ ///
+ /// Safe to call before [#init(ShieldConfig)]; the returned guard reads the configuration
+ /// live rather than capturing it.
+ public static NetworkGuard getNetworkGuard() {
+ synchronized (AppShield.class) {
+ if (guard == null) {
+ guard = new ShieldNetworkGuard();
+ }
+ return guard;
+ }
+ }
+
/// True when a real attestation engine is present and available. False in an open-source or
/// unentitled build, and in the simulator unless simulation is switched on.
public static boolean isProtected() {
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java b/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
index 650b378a1cf..6eceedd9804 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java
@@ -35,8 +35,10 @@
/// would only ever be attached by an app calling [AppShield#attach(ConnectionRequest)] by hand.
/// Installed once from [AppShield#init(ShieldConfig)].
///
-/// Package-private: apps configure behaviour through [ShieldConfig], and an app-supplied guard
-/// could only weaken this one.
+/// The class stays package-private -- apps configure behaviour through [ShieldConfig], and there
+/// is nothing here to subclass or reconfigure. The *instance* is reachable through
+/// [AppShield#getNetworkGuard()], because [com.codename1.io.NetworkManager] holds one guard and
+/// an app that needs its own has to be able to delegate to this one rather than displace it.
final class ShieldNetworkGuard implements NetworkGuard {
@Override
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index cc68b32c2b0..a5d5c3ea876 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -376,16 +376,8 @@ AsyncResource requestToken(String nonce) {
// rate-limited key, and would do so on every subsequent request --
// the exact loop the spent marker exists to stop. Report instead.
bootstrapInFlight = false;
- final AsyncResource exhausted = r;
- Display.getInstance().callSerially(new Runnable() {
- public void run() {
- if (!exhausted.isDone()) {
- exhausted.error(new RuntimeException("App Attest could not "
- + "establish a usable key on this device; call "
- + "DeviceIntegrity.resetAttestation() to try again"));
- }
- }
- });
+ failResource(r, "App Attest could not establish a usable key on this "
+ + "device; call DeviceIntegrity.resetAttestation() to try again");
return r;
} else if (keyId != null && keyId.length() > 0) {
// Attestation WAS started for this key and we never recorded the
@@ -452,16 +444,8 @@ private void attestKey(AsyncResource r, String nonce, String keyId, bool
if (!SecureStorage.getInstance().set(KEY_ATTEST_STARTED, "1")) {
bootstrapInFlight = false;
failBootstrapWaiters("App Attest could not record that attestation had started");
- final AsyncResource target = r;
- Display.getInstance().callSerially(new Runnable() {
- public void run() {
- if (!target.isDone()) {
- target.error(new RuntimeException("App Attest could not record that "
- + "attestation had started, so it was not attempted rather "
- + "than risk spending the key untracked"));
- }
- }
- });
+ failResource(r, "App Attest could not record that attestation had started, so it "
+ + "was not attempted rather than risk spending the key untracked");
return;
}
PendingRequest pending = new PendingRequest(r, nonce, PendingRequest.OP_ATTEST, keyId);
@@ -842,6 +826,24 @@ public void run() {
});
}
+ /**
+ * Fails a caller that has no PendingRequest yet, on the EDT.
+ *
+ * Static, and not merely for tidiness: the callers are instance methods, so an
+ * anonymous Runnable written inline there captures the enclosing IOSDeviceIntegrity.
+ * That reference outlives the call for as long as the EDT queue holds the Runnable,
+ * and it is a reference nothing here needs.
+ */
+ private static void failResource(final AsyncResource r, final String msg) {
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ if (!r.isDone()) {
+ r.error(new RuntimeException(msg));
+ }
+ }
+ });
+ }
+
private static void fail(final PendingRequest pending, final String msg) {
Display.getInstance().callSerially(new Runnable() {
public void run() {
diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java
index 6872b64af19..d27fbdc20e0 100644
--- a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java
@@ -29,6 +29,7 @@
import java.io.IOException;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -173,6 +174,117 @@ public void close() {
}
}
+ /// A transport that can be opened more than once, and whose readMessage parks each
+ /// reader thread on its own ticket. That is what lets a test release a *superseded*
+ /// reader while the current one stays parked, which is the only way to observe what the
+ /// superseded thread does on its way out.
+ private static final class ReusableTransport implements MCPTransport {
+ private final Object gate = new Object();
+ private int nextTicket;
+ private int parked;
+ private int releaseBelow;
+ volatile int openCount;
+ volatile int closeCount;
+
+ void awaitParked(int n) throws InterruptedException {
+ synchronized (gate) {
+ while (parked < n) {
+ gate.wait(5000);
+ }
+ }
+ }
+
+ /// Lets the readers holding the first `n` tickets out of readMessage, leaving any
+ /// later reader parked.
+ void releaseFirst(int n) {
+ synchronized (gate) {
+ releaseBelow = n;
+ gate.notifyAll();
+ }
+ }
+
+ public void open() throws IOException {
+ synchronized (gate) {
+ openCount++;
+ }
+ }
+
+ public String readMessage() throws IOException {
+ int ticket;
+ synchronized (gate) {
+ ticket = nextTicket++;
+ parked++;
+ gate.notifyAll();
+ while (releaseBelow <= ticket) {
+ try {
+ gate.wait(5000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("interrupted");
+ }
+ }
+ }
+ return null;
+ }
+
+ public void writeMessage(String message) throws IOException {
+ }
+
+ public void close() {
+ synchronized (gate) {
+ closeCount++;
+ gate.notifyAll();
+ }
+ }
+ }
+
+ private static int liveReaderThreads() {
+ int n = 0;
+ for (Thread t : Thread.getAllStackTraces().keySet()) {
+ if (t.isAlive() && "cn1-mcp-server".equals(t.getName())) {
+ n++;
+ }
+ }
+ return n;
+ }
+
+ @Test
+ void aSupersededReaderMustNotCloseATransportTheRestartIsNowServing() throws Exception {
+ // Restarting over the SAME transport instance. Declining to clear `running` is only
+ // half of what a superseded thread owes the replacement: if it still closes the
+ // transport on its way out, it closes the one the restarted server is serving over,
+ // and a server that came back up is dead again a moment later.
+ MCPServer server = new MCPServer();
+ ReusableTransport transport = new ReusableTransport();
+ server.start(transport);
+ transport.awaitParked(1);
+
+ server.stop();
+ assertEquals(1, transport.closeCount, "stop() closes the transport it was serving");
+
+ // Same instance, which is what a caller that keeps one transport around does.
+ server.start(transport);
+ transport.awaitParked(2);
+ assertEquals(2, transport.openCount, "the restart should have reopened the transport");
+
+ // Only the superseded reader is let go. The current one stays parked, so anything
+ // that happens to the transport from here is the superseded thread's doing.
+ transport.releaseFirst(1);
+ for (int i = 0; i < 500 && liveReaderThreads() > 1; i++) {
+ Thread.sleep(10);
+ }
+ assertEquals(1, liveReaderThreads(),
+ "the superseded reader thread should have exited");
+
+ assertEquals(1, transport.closeCount,
+ "the superseded reader must leave the restarted server's transport open");
+ assertTrue(server.isRunning(),
+ "the restarted server must still be serving after the old reader unwinds");
+
+ server.stop();
+ transport.releaseFirst(2);
+ }
+
@Test
void stoppingWhileTheReaderThreadIsStillOpeningStillClosesTheTransport() throws Exception {
// start() hands the transport to a reader thread, so stop() can run before that
diff --git a/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
index 607ba007c75..d3042fffe9d 100644
--- a/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
@@ -22,6 +22,7 @@
*/
package com.codename1.security.shield;
+import com.codename1.io.NetworkGuard;
import org.junit.jupiter.api.Test;
import java.util.Hashtable;
@@ -29,6 +30,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -389,6 +391,30 @@ void severityIsClampedToTheDocumentedRange() {
assertEquals(0, new ShieldSignal("x", -5, null).getSeverity());
}
+ // --- guard composition ----------------------------------------------
+
+ @Test
+ void theShieldGuardIsReachableAndStableSoAnAppCanDelegateToIt() {
+ // NetworkManager holds exactly one guard and seals the slot, so an app with its own
+ // guard can only keep the shield by delegating to it. Before this was reachable, the
+ // documented recovery was "call attach() yourself", which restores the token header
+ // and silently drops the certificate callbacks that enforce isEnforcePins() -- the
+ // half of the shield whose absence looks exactly like success.
+ NetworkGuard g = AppShield.getNetworkGuard();
+ assertNotNull(g, "the shield's guard must be reachable for composition");
+ assertSame(g, AppShield.getNetworkGuard(),
+ "delegating apps hold on to the guard, so it has to be the same instance");
+
+ // The pin callbacks are the point: they must be present on the returned type, not
+ // only on a package-private class an app cannot name.
+ assertFalse(g.isCertificateCheckRequired("https://unregistered.example/x"),
+ "an unregistered host must not trigger certificate collection");
+ String[] interesting = g.interestingResponseHeaders();
+ assertNotNull(interesting,
+ "a composing guard has to be able to union these with its own");
+ assertTrue(interesting.length > 0);
+ }
+
@Test
void hasSignalAtLeastReflectsRecordedSeverities() {
ShieldSignals.clear();
From b4efd02fdf51ba38fcf9962a14168fc7651c1437 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 02:30:42 +0300
Subject: [PATCH 34/96] Reuse the App Attest key when Apple says it never
processed the attestation
serverUnavailable recorded the backoff and left the started marker in place, so
after the deadline the interrupted-attestation branch treated an explicit
retryable failure as an unknown outcome, discarded a perfectly good
rate-limited hardware key and generated another. Repeated often enough -- once
per backoff through an Apple outage -- the recovery throttles the device more
thoroughly than the outage did.
The marker means "submitted, outcome unknown", and discarding the key is the
right answer to that, because a spent one-time attestation cannot be
resubmitted. serverUnavailable is not that: Apple has told us it did not process
the attestation, so the key is untouched and the marker has nothing left to
protect. Cleared for that code alone, on the attest operation alone. A failed
removal leaves it in place, which is the conservative state the branch reached
before.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../codename1/impl/ios/IOSDeviceIntegrity.java | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index a5d5c3ea876..4bec4a436e9 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -724,6 +724,21 @@ public static void nativeAttestError(final int requestId, final int errorCode,
SecureStorage.getInstance().set(KEY_RETRY_AFTER,
Long.toString(deadline));
instance.currentBackoff = Math.min(backoff * 2, MAX_BACKOFF_MILLIS);
+ if (pending.op == PendingRequest.OP_ATTEST) {
+ // Apple told us it did not process this attestation, so the key
+ // is untouched and the started marker has nothing left to
+ // protect. Clearing it is what makes the next attempt re-attest
+ // THIS key: the marker means "submitted, outcome unknown", and
+ // the interrupted-attestation branch answers an unknown outcome
+ // by discarding the key, since a spent one-time attestation
+ // cannot be resubmitted. Applied to an explicit
+ // serverUnavailable, that reasoning burns a rate-limited
+ // hardware key on every Apple outage -- once per backoff, until
+ // the device is throttled by the recovery rather than by the
+ // outage. A failed removal leaves the marker in place, which is
+ // the conservative state this branch used to reach anyway.
+ SecureStorage.getInstance().remove(KEY_ATTEST_STARTED);
+ }
}
if (pending.op != PendingRequest.OP_ASSERT) {
// Still the same acquisition: releasing here and reacquiring would
From 0d605094af6de54cb0d2161a0abf1b7c82d80dfb Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 02:52:27 +0300
Subject: [PATCH 35/96] Release the bootstrap queue when a native callback
returns nothing
nativeKeyGenerated with no key identifier, and nativeAttestationReady with no
attestation, failed only the request that started the bootstrap. bootstrapInFlight
stayed set, so every caller already queued behind it -- and every caller that
arrived afterwards and was queued precisely because a bootstrap looked live --
waited on a bootstrap that had already stopped, with nothing left to release them.
The mid-flow storage failures in both callbacks got this right; the two
give-up-early branches did not.
Both now go through one helper that clears the flag and fails the waiters under
the same lock a reset takes. A stale request still only fails itself: a reset has
already cleared the flag and failed the waiters, and clearing it again would clear
the flag belonging to the replacement bootstrap that reset started.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/ios/IOSDeviceIntegrity.java | 37 ++++++++++++++++++-
1 file changed, 35 insertions(+), 2 deletions(-)
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 4bec4a436e9..a4208ebe34f 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -490,6 +490,36 @@ private static String escapeJson(String s) {
// ---- Callbacks invoked from native code (do not rename) ----------------
+ /**
+ * Fails a request and the bootstrap it was driving.
+ *
+ * For the branches that give up before touching storage. Failing only the
+ * initiating request there left {@code bootstrapInFlight} set, so every caller
+ * already queued behind it -- and every caller that arrived afterwards and was queued
+ * because a bootstrap looked live -- waited on a bootstrap that had already stopped,
+ * with nothing left to release them. The mid-flow storage failures got this right;
+ * these two did not.
+ *
+ * A stale request only fails itself: a reset has already cleared the flag and
+ * failed the waiters, and clearing it again would clear the flag belonging to the
+ * replacement bootstrap that reset started.
+ */
+ private static void failBootstrapAttempt(PendingRequest pending, String msg) {
+ if (instance == null) {
+ fail(pending, msg);
+ return;
+ }
+ synchronized (instance.flowLock) {
+ if (isStale(pending)) {
+ fail(pending, "App Attest state was reset while this request was in flight");
+ return;
+ }
+ instance.bootstrapInFlight = false;
+ fail(pending, msg);
+ instance.failBootstrapWaiters(msg);
+ }
+ }
+
/** Called from native once a fresh hardware key exists. */
public static void nativeKeyGenerated(final int requestId, final String keyId) {
PendingRequest pending = take(requestId);
@@ -497,7 +527,8 @@ public static void nativeKeyGenerated(final int requestId, final String keyId) {
return;
}
if (keyId == null || keyId.length() == 0) {
- fail(pending, "App Attest key generation returned no identifier");
+ failBootstrapAttempt(pending,
+ "App Attest key generation returned no identifier");
return;
}
// The staleness check and the writes it guards happen under the same lock a
@@ -540,7 +571,9 @@ public static void nativeAttestationReady(final int requestId, final String atte
return;
}
if (attestationB64 == null) {
- fail(pending, "App Attest attestation returned no data");
+ // Same cleanup as key generation returning nothing: this bootstrap is over,
+ // and the queue behind it has to be told.
+ failBootstrapAttempt(pending, "App Attest attestation returned no data");
return;
}
if (instance == null) {
From a88c9a8c33fe33009109c2757c83831b459287c2 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 03:11:22 +0300
Subject: [PATCH 36/96] Hold one lock across the assertion callback's staleness
check and what it clears
nativeAssertionReady checked staleness under the lock, released it, and then
reacquired it to reset the throttle state. A reset landing in between let the old
callback clear currentBackoff, retryAfterFallback and the stored retry deadline on
behalf of the replacement generation -- and if that replacement had already
recorded a serverUnavailable, its deadline went with them, while succeed() went on
to reject this token as stale anyway. So the next request walked straight back into
a service Apple had just told the device to stay away from, which is how an app
gets its attestation budget suspended.
One acquisition now covers both, which is the rule nativeAttestError already
states and follows. The empty-payload check moved above it: it is a test of an
argument, not of shared state, and doing it first keeps the lock holding only what
the generation guards.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/ios/IOSDeviceIntegrity.java | 20 +++++++++++--------
1 file changed, 12 insertions(+), 8 deletions(-)
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index a4208ebe34f..3fe27c033cc 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -660,7 +660,19 @@ public static void nativeAssertionReady(final int requestId, final String assert
if (pending == null) {
return;
}
+ if (assertionB64 == null) {
+ fail(pending, "App Attest assertion returned no data");
+ return;
+ }
if (instance != null) {
+ // ONE acquisition covering the staleness check and everything it guards, for
+ // the reason spelled out on nativeAttestError. Split in two, a reset landing
+ // between them let this callback clear the throttle state belonging to the
+ // replacement generation: if that replacement had already recorded a
+ // serverUnavailable, its deadline was erased here while succeed() went on to
+ // reject this token as stale anyway -- so the next request went straight back
+ // at a service Apple had just told us to stay away from, which is how an app
+ // gets its whole attestation budget suspended.
synchronized (instance.flowLock) {
if (isStale(pending)) {
// A reset landed while this assertion was in flight -- typically
@@ -671,14 +683,6 @@ public static void nativeAssertionReady(final int requestId, final String assert
"App Attest state was reset while this request was in flight");
return;
}
- }
- }
- if (assertionB64 == null) {
- fail(pending, "App Attest assertion returned no data");
- return;
- }
- if (instance != null) {
- synchronized (instance.flowLock) {
// A working assertion means Apple is answering again, so the throttle
// sequence starts over. Without this the doubling only ever accumulated:
// outages separated by months of successful requests would compound
From f599ebfb1a750ea4500a754cc01dca00f7617ec7 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 03:26:30 +0300
Subject: [PATCH 37/96] Close the transport under the monitor, and keep the key
on any answered failure
releaseIfCurrent decided ownership under the lock and returned; the caller closed
afterwards. A reader reaching EOF clears running, a restart over the same
transport lands in that gap, and the close then lands on the replacement's
transport -- the same outcome the ownership check was added to prevent, moved a
few instructions later. The decision and the close now happen in one synchronized
method, which is what stop() already does.
Attestation kept its start marker for every error except serverUnavailable, so a
persistent invalidInput or featureUnsupported discarded a still-unconsumed
hardware key and generated another on every request, with nothing bounding it.
Reaching that callback at all means Apple answered, so the outcome is not unknown
-- and for anything but invalidKey, which resets separately, the key was not
consumed. The marker is cleared for all of them, including unknownSystemFailure:
if that one did land after Apple consumed the attestation, resubmitting is
answered with invalidKey and self-corrects through the one-shot recovery, which
is cheaper than a fresh rate-limited key per request forever.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../src/com/codename1/mcp/MCPServer.java | 32 +++++++++-------
.../impl/ios/IOSDeviceIntegrity.java | 38 +++++++++++--------
2 files changed, 42 insertions(+), 28 deletions(-)
diff --git a/CodenameOne/src/com/codename1/mcp/MCPServer.java b/CodenameOne/src/com/codename1/mcp/MCPServer.java
index 2b34d8e04d8..5250eef5868 100644
--- a/CodenameOne/src/com/codename1/mcp/MCPServer.java
+++ b/CodenameOne/src/com/codename1/mcp/MCPServer.java
@@ -196,14 +196,26 @@ private synchronized boolean isCurrent(MCPTransport t, int generation) {
/// - superseded, and the replacement holds this same transport: leave it alone. It is
/// the live server's transport now, and the replacement thread will close it.
///
- /// @return true when the caller owns the close of `t`
- private synchronized boolean releaseIfCurrent(MCPTransport t, int generation) {
+ /// The close happens here, under the monitor, rather than being reported back to a
+ /// caller that closes afterwards. Deciding and then closing outside the lock leaves
+ /// the very gap this method exists to remove: a reader reaching EOF clears `running`,
+ /// a restart over the same transport lands in the gap, and the close then lands on
+ /// the replacement's transport.
+ private synchronized void releaseAndCloseIfCurrent(MCPTransport t, int generation) {
+ boolean mine;
if (startGeneration == generation
&& transport == t) { // NOPMD identity: is this still our transport?
running = false;
- return true;
+ mine = true;
+ } else {
+ mine = transport != t; // NOPMD identity: reused by the replacement, or orphaned?
+ }
+ if (mine) {
+ // Closing under the monitor matches stop(), which is synchronized and closes
+ // the same way. A transport's close() releases a socket; it does not call
+ // back into the server, so there is nothing here to deadlock against.
+ t.close();
}
- return transport != t; // NOPMD identity: reused by the replacement, or orphaned?
}
private void runLoop(MCPTransport t, int generation) {
@@ -228,9 +240,7 @@ private void runLoop(MCPTransport t, int generation) {
} catch (Throwable logErr) {
System.err.println("[cn1.mcp] transport open failed: " + ex);
}
- if (releaseIfCurrent(t, generation)) {
- t.close();
- }
+ releaseAndCloseIfCurrent(t, generation);
return;
}
if (!isCurrent(t, generation)) {
@@ -238,9 +248,7 @@ private void runLoop(MCPTransport t, int generation) {
// flight. Undo the registration this thread just took -- unless the server
// moved on by restarting over this very transport, in which case it is not
// ours to undo.
- if (releaseIfCurrent(t, generation)) {
- t.close();
- }
+ releaseAndCloseIfCurrent(t, generation);
return;
}
while (isCurrent(t, generation)) {
@@ -265,9 +273,7 @@ private void runLoop(MCPTransport t, int generation) {
}
}
}
- if (releaseIfCurrent(t, generation)) {
- t.close();
- }
+ releaseAndCloseIfCurrent(t, generation);
}
/// Handles one inbound JSON-RPC message and returns the response line, or null
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 3fe27c033cc..dbd67bc6d33 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -761,21 +761,29 @@ public static void nativeAttestError(final int requestId, final int errorCode,
SecureStorage.getInstance().set(KEY_RETRY_AFTER,
Long.toString(deadline));
instance.currentBackoff = Math.min(backoff * 2, MAX_BACKOFF_MILLIS);
- if (pending.op == PendingRequest.OP_ATTEST) {
- // Apple told us it did not process this attestation, so the key
- // is untouched and the started marker has nothing left to
- // protect. Clearing it is what makes the next attempt re-attest
- // THIS key: the marker means "submitted, outcome unknown", and
- // the interrupted-attestation branch answers an unknown outcome
- // by discarding the key, since a spent one-time attestation
- // cannot be resubmitted. Applied to an explicit
- // serverUnavailable, that reasoning burns a rate-limited
- // hardware key on every Apple outage -- once per backoff, until
- // the device is throttled by the recovery rather than by the
- // outage. A failed removal leaves the marker in place, which is
- // the conservative state this branch used to reach anyway.
- SecureStorage.getInstance().remove(KEY_ATTEST_STARTED);
- }
+ }
+ if (pending.op == PendingRequest.OP_ATTEST) {
+ // The marker means "submitted, outcome unknown", and the
+ // interrupted-attestation branch answers an unknown outcome by
+ // discarding the key, because a spent one-time attestation cannot be
+ // resubmitted. But reaching this callback at all means Apple
+ // answered: the outcome is known, and for anything other than
+ // invalidKey -- which is handled above by resetting -- the key was
+ // not consumed. Leaving the marker set therefore burned a
+ // rate-limited hardware key on every request for as long as the
+ // condition lasted, whether that was an outage or a persistently
+ // invalid input.
+ //
+ // unknownSystemFailure is the one code that could in principle have
+ // landed after Apple consumed the attestation. It is cleared anyway:
+ // resubmitting a consumed key is answered with invalidKey, which
+ // routes into the one-shot recovery above and self-corrects, whereas
+ // keeping the marker costs a fresh hardware key per request with
+ // nothing bounding it.
+ //
+ // A failed removal leaves the marker in place, which is the
+ // conservative state this branch used to reach anyway.
+ SecureStorage.getInstance().remove(KEY_ATTEST_STARTED);
}
if (pending.op != PendingRequest.OP_ASSERT) {
// Still the same acquisition: releasing here and reacquiring would
From c917081f6fe943e51169ab6bc971fef83e390288 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 03:51:49 +0300
Subject: [PATCH 38/96] Make the simulator's shield switches act, and bound the
DER walk
Simulate > App Shield wrote fields nothing read. No engine is registered in the
simulator, so the inert default answered every call: Force Pin Mismatch left
requests succeeding, the status dialog agreed the state was "forcing mismatch",
and the fail-closed branch the switch exists to reach was unreachable. A control
that reports a state it does not cause is worse than no control -- it is a test
that passes for the wrong reason.
JavaSEShieldEngine implements the SPI against those toggles: outcomes arrive as
their own ShieldStatus, an expired token is actually expired (validity is
answered from a monotonic reading, so backdating fetchedAt would not have done
it), a failed pin fetch yields no enforcement rather than a mismatch, and the
forced mismatch is one-shot because the switch says "on next request". The pin
set covers the app's registered hosts, which is load-bearing rather than
decoration: with no pins for a host the guard never asks verifyPins() at all.
Registered when a toggle is switched on -- including one restored from
preferences -- and never merely because the menu exists, since sealing the
registry at startup would make every app run in the simulator report itself
protected. It initializes itself there as well as from AppShield.init(), because
the two happen in either order and a null config means no pinned hosts, which is
the same do-nothing switch reached by a different route.
cn1ReadDerTlv now bounds its arithmetic against what is left in the buffer rather
than by forming sums that could wrap. I could not construct an input that
overreads on the old code -- rejecting more than four length bytes already caps
the content length below what a 64-bit NSUInteger can wrap -- so this is
defence in depth, not a fix for a reachable bug. A standalone harness over
short-form, long-form, oversized, truncated, exact-fit, past-the-end and
walked-offset cases confirms the behaviour is unchanged for everything valid.
The App Attest start marker is also remembered in memory when the keychain
refuses to drop it, so a refused removal does not put the device straight back to
burning one rate-limited key per request.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/impl/javase/JavaSEPort.java | 35 ++-
.../impl/javase/JavaSEShieldEngine.java | 267 ++++++++++++++++++
.../nativeSources/NetworkConnectionImpl.m | 15 +-
.../impl/ios/IOSDeviceIntegrity.java | 36 ++-
.../impl/javase/JavaSEShieldEngineTest.java | 136 +++++++++
5 files changed, 480 insertions(+), 9 deletions(-)
create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java
create mode 100644 maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index 7647e1dbdbe..344c8782dd7 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -8446,7 +8446,17 @@ public void set(boolean v) {
status.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent ae) {
- JOptionPane.showMessageDialog(canvas, JavaSEShield.describe(),
+ // The engine line matters: every toggle below the attestation section
+ // is read by the simulator engine, so "not registered" is the
+ // difference between a switch that acts and a switch that only shows.
+ JOptionPane.showMessageDialog(canvas, JavaSEShield.describe()
+ + "Simulator engine: "
+ + (com.codename1.security.shield.spi.ShieldEngineRegistry
+ .isEngineRegistered()
+ ? com.codename1.security.shield.spi.ShieldEngineRegistry
+ .getEngine().getName() + " (registered)"
+ : "not registered -- switch on any toggle above to install it")
+ + "\n",
"App Shield Simulation", JOptionPane.INFORMATION_MESSAGE);
}
});
@@ -8467,17 +8477,36 @@ private JCheckBoxMenuItem shieldToggle(final Preferences pref, String label,
final String prefKey, final ShieldToggleSink sink) {
final JCheckBoxMenuItem item = new JCheckBoxMenuItem(label,
pref.getBoolean(prefKey, false));
- sink.set(item.isSelected());
+ applyShieldToggle(sink, item.isSelected());
item.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent ae) {
- sink.set(item.isSelected());
+ applyShieldToggle(sink, item.isSelected());
pref.putBoolean(prefKey, item.isSelected());
}
});
return item;
}
+ /**
+ * Applies a shield toggle and, when it is being switched ON, makes sure the
+ * simulator engine is registered.
+ *
+ * The token and pinning toggles are read by {@link JavaSEShieldEngine}, and with
+ * no engine registered the inert default answers instead -- so the switches set a
+ * field, the status dialog reported the field, and nothing behaved differently.
+ * Registration happens here rather than when the menu is built, because sealing the
+ * registry at startup would make every app run in the simulator report itself as
+ * protected. It also covers a toggle restored from preferences, since a developer
+ * who left one armed expects it to still be armed.
+ */
+ private void applyShieldToggle(ShieldToggleSink sink, boolean value) {
+ sink.set(value);
+ if (value) {
+ JavaSEShieldEngine.ensureRegistered();
+ }
+ }
+
private JMenu installNfcSimulationMenu(JMenu simulateMenu, final Preferences pref) {
JMenu nfcMenu = new JMenu("NFC");
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java
new file mode 100644
index 00000000000..5f2c6785e7c
--- /dev/null
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java
@@ -0,0 +1,267 @@
+/*
+ * 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.io.Log;
+import com.codename1.security.shield.AppShield;
+import com.codename1.security.shield.FailureMode;
+import com.codename1.security.shield.HostPolicy;
+import com.codename1.security.shield.PinSet;
+import com.codename1.security.shield.ShieldConfig;
+import com.codename1.security.shield.ShieldException;
+import com.codename1.security.shield.ShieldSignal;
+import com.codename1.security.shield.ShieldStatus;
+import com.codename1.security.shield.ShieldToken;
+import com.codename1.security.shield.spi.EngineContext;
+import com.codename1.security.shield.spi.ShieldEngine;
+import com.codename1.security.shield.spi.ShieldEngineRegistry;
+import java.util.Hashtable;
+import java.util.Vector;
+
+/**
+ * The engine behind {@code Simulate > App Shield}.
+ *
+ * The toggles in that menu described outcomes nothing produced: a developer could
+ * switch on Force Pin Mismatch, watch the status dialog agree that pinning was
+ * "forcing mismatch", and see every request succeed -- because no engine was registered
+ * in the simulator, so the inert default answered every call and the fail-closed branch
+ * the switch exists to reach was unreachable. A switch that reports a state it does not
+ * cause is worse than no switch: it is a test that passes for the wrong reason.
+ *
+ * Registered on demand, the first time a developer touches that menu, and never
+ * automatically -- a simulator that reported {@code isProtected()} true out of the box
+ * would change what every existing app does when run there. Tokens are stamped
+ * {@link #SIMULATED_MARKER} so nothing can mistake one for the real thing, and every
+ * one of them says so in its own value.
+ */
+public final class JavaSEShieldEngine implements ShieldEngine {
+
+ /**
+ * Present in every simulated token.
+ *
+ * These are minted locally with no attestation behind them at all, so a backend
+ * that ever sees one is talking to a simulator. It is in the token value rather than
+ * only in a header because the value is the part that gets copied into a curl command
+ * and pasted into a bug report.
+ */
+ public static final String SIMULATED_MARKER = "cn1-simulated";
+
+ private ShieldConfig config;
+ private ShieldToken cached;
+
+ /**
+ * Installs the simulator engine, once, if nothing has claimed the slot.
+ *
+ * Called when a shield simulation is switched on -- including one restored from
+ * preferences at startup, because a developer who left "Force Pin Mismatch" armed
+ * expects it to still be armed. Not called merely because the menu exists: the
+ * registry seals on first registration and a simulator that always reported
+ * {@code isProtected()} true would change what every app does when run there.
+ *
+ * The engine is initialized here as well as by
+ * {@code AppShield.init(ShieldConfig)}, because the two can happen in either order.
+ * An app that called {@code init()} at startup and a developer who opens the menu
+ * afterwards would otherwise leave this holding a null config -- and a null config
+ * means no pinned hosts, which means {@code verifyPins()} is never reached and the
+ * force switch quietly does nothing. That is the exact failure this class exists to
+ * remove, so it must not be reintroduced by ordering.
+ */
+ public static void ensureRegistered() {
+ if (ShieldEngineRegistry.isEngineRegistered()) {
+ return;
+ }
+ JavaSEShieldEngine engine = new JavaSEShieldEngine();
+ try {
+ ShieldEngineRegistry.setEngine(engine);
+ } catch (IllegalStateException alreadySealed) {
+ // A real engine got there first -- a build-server bootstrap, or a test. The
+ // simulator does not displace it.
+ return;
+ } catch (RuntimeException other) {
+ Log.e(other);
+ return;
+ }
+ try {
+ engine.initialize(ShieldEngineRegistry.getDefaultContext(),
+ AppShield.getConfig());
+ } catch (RuntimeException e) {
+ Log.e(e);
+ }
+ }
+
+ public String getName() {
+ return "simulator";
+ }
+
+ public boolean isAvailable() {
+ return JavaSEShield.attestationSupported;
+ }
+
+ public void initialize(EngineContext ctx, ShieldConfig cfg) {
+ this.config = cfg;
+ }
+
+ public ShieldToken fetchToken(String bindingData) throws ShieldException {
+ if (!JavaSEShield.attestationSupported) {
+ throw new ShieldException(ShieldStatus.UNPROTECTED,
+ "The simulated platform reports no attestation support");
+ }
+ switch (JavaSEShield.attestOutcome) {
+ case FAIL_REJECTED:
+ throw new ShieldException(ShieldStatus.REJECTED,
+ "Simulated: the service rejected this device");
+ case FAIL_NO_NETWORK:
+ throw new ShieldException(ShieldStatus.NO_NETWORK,
+ "Simulated: no network");
+ case FAIL_SERVICE_DOWN:
+ throw new ShieldException(ShieldStatus.SERVICE_DOWN,
+ "Simulated: the attestation service is down");
+ case FAIL_RATE_LIMITED:
+ throw new ShieldException(ShieldStatus.RATE_LIMITED,
+ "Simulated: rate limited");
+ case UNSUPPORTED:
+ throw new ShieldException(ShieldStatus.UNPROTECTED,
+ "Simulated: this platform has no attestation");
+ default:
+ break;
+ }
+ long ttl = (long) Math.max(1, JavaSEShield.tokenTtlSeconds) * 1000L;
+ long fetchedAt = System.currentTimeMillis();
+ if (JavaSEShield.serveExpiredToken) {
+ // Handed out already lapsed rather than with a short lifetime, so a test does
+ // not have to wait for it. isValid() is answered from a monotonic reading
+ // taken at construction, so backdating fetchedAt alone would not do it.
+ ttl = 0L;
+ }
+ cached = new ShieldToken(SIMULATED_MARKER + "." + Long.toHexString(fetchedAt)
+ + (bindingData == null ? "" : "." + Integer.toHexString(bindingData.hashCode())),
+ ShieldStatus.OK, fetchedAt, ttl, bindingData);
+ return cached;
+ }
+
+ public ShieldToken getCachedToken() {
+ return cached;
+ }
+
+ public boolean verifyPins(String host, String[] spkiDigests, String[] certDigests) {
+ if (!JavaSEShield.forcePinMismatch) {
+ return true;
+ }
+ // One shot, because the switch is labelled "on next request". Leaving it armed
+ // would fail every subsequent request too, and a developer testing a recovery
+ // path would be testing a permanently broken app instead.
+ JavaSEShield.forcePinMismatch = false;
+ return false;
+ }
+
+ public PinSet getPinSet() {
+ if (JavaSEShield.failPinFetch) {
+ // An unavailable pin set is not a mismatch. Pinning fails OPEN on
+ // unavailability everywhere in this design, and the simulator has to be able
+ // to demonstrate that rather than assert it.
+ return new PinSet(new Hashtable(), 0, 0L, 0L);
+ }
+ // Every host the app registered, pinned to a digest no real chain can produce.
+ // Enforcement is what makes verifyPins() run at all, so without this the force
+ // switch would still have nothing to act on: PinSet.isEnforcedFor() is false for
+ // a host with no pins, and ShieldNetworkGuard checks that before asking.
+ Hashtable hostToPins = new Hashtable();
+ if (config != null) {
+ java.util.Enumeration hosts = config.protectedHosts();
+ while (hosts.hasMoreElements()) {
+ String host = (String) hosts.nextElement();
+ if (host == null || host.startsWith("*.")) {
+ // A wildcard is a policy pattern, not a host a chain is served for.
+ continue;
+ }
+ Vector pins = new Vector();
+ pins.addElement(SIMULATED_PIN);
+ hostToPins.put(host, pins);
+ }
+ }
+ long now = System.currentTimeMillis();
+ return new PinSet(hostToPins, 1, now + DAY_MILLIS, now + 30L * DAY_MILLIS);
+ }
+
+ public ShieldSignal[] collectSignals() {
+ String[] reasons = JavaSEShield.simReasons();
+ Vector out = new Vector();
+ for (int i = 0; i < reasons.length; i++) {
+ out.addElement(new ShieldSignal(reasons[i], severityFor(reasons[i]),
+ "simulated"));
+ }
+ String[] accessibility = JavaSEShield.simAccessibility();
+ for (int i = 0; i < accessibility.length; i++) {
+ out.addElement(new ShieldSignal(ShieldSignal.ACCESSIBILITY, 60,
+ accessibility[i]));
+ }
+ ShieldSignal[] arr = new ShieldSignal[out.size()];
+ out.copyInto(arr);
+ return arr;
+ }
+
+ public void invalidate() {
+ cached = null;
+ }
+
+ public void shutdown() {
+ cached = null;
+ }
+
+ private static int severityFor(String reason) {
+ if (ShieldSignal.HOOK.equals(reason) || "frida".equals(reason)) {
+ return 90;
+ }
+ if (ShieldSignal.ROOT.equals(reason) || ShieldSignal.JAILBREAK.equals(reason)) {
+ return 70;
+ }
+ if (ShieldSignal.REPACKAGED.equals(reason)) {
+ return 80;
+ }
+ if (ShieldSignal.DEBUGGER.equals(reason)) {
+ return 50;
+ }
+ return 30;
+ }
+
+ private static final long DAY_MILLIS = 24L * 60L * 60L * 1000L;
+
+ /**
+ * A digest no live chain can match, so an enforced host fails when the switch is on.
+ *
+ * Deliberately not a real digest: if it ever collided with a host's actual key the
+ * force switch would silently stop working, which is the failure mode this whole
+ * class exists to remove.
+ */
+ private static final String SIMULATED_PIN =
+ "c2ltdWxhdGVkLXBpbi1uby1yZWFsLWNoYWluLW1hdGNoZXMtdGhpcw==";
+
+ /**
+ * The policy a simulated host gets when the app registered none, so the menu's
+ * pinning switches have something to act on even in an app that only called
+ * {@code AppShield.init()}.
+ */
+ static HostPolicy simulatedPolicy() {
+ return new HostPolicy(true, true, FailureMode.CLOSED);
+ }
+}
diff --git a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
index 412a99070f4..58c3ffb6855 100644
--- a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
+++ b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
@@ -238,19 +238,23 @@ - (NSString*) getFingerprint: (SecCertificateRef) cert {
*/
static BOOL cn1ReadDerTlv(const uint8_t* buf, NSUInteger len, NSUInteger off,
uint8_t* tag, NSUInteger* headerLen, NSUInteger* totalLen) {
- if (off + 2 > len) {
+ // off is walked forward by the caller, so an off past the end must not be able to
+ // make `off + 2` wrap back into range before the comparison.
+ if (off > len || len - off < 2) {
return NO;
}
*tag = buf[off];
NSUInteger n = buf[off + 1];
if (n < 0x80) {
*headerLen = 2;
+ // n < 0x80 and len - off >= 2, so this cannot overflow; the range check below
+ // is what decides whether the TLV actually fits.
*totalLen = 2 + n;
} else {
NSUInteger countBytes = n & 0x7f;
// 0x80 is indefinite length, which DER forbids; more than 4 length bytes
// would mean a certificate larger than anything we will ever be handed.
- if (countBytes == 0 || countBytes > 4 || off + 2 + countBytes > len) {
+ if (countBytes == 0 || countBytes > 4 || len - off - 2 < countBytes) {
return NO;
}
NSUInteger contentLen = 0;
@@ -258,6 +262,13 @@ static BOOL cn1ReadDerTlv(const uint8_t* buf, NSUInteger len, NSUInteger off,
contentLen = (contentLen << 8) | buf[off + 2 + i];
}
*headerLen = 2 + countBytes;
+ // Checked against what is actually left rather than by forming the sum first.
+ // A crafted length wraps NSUInteger, `off + *totalLen <= len` then passes, and
+ // the caller hands the wrapped value to CC_SHA256, which reads past the buffer.
+ // The certificate comes off the wire, so it is attacker-shaped by definition.
+ if (contentLen > len - off - *headerLen) {
+ return NO;
+ }
*totalLen = *headerLen + contentLen;
}
return off + *totalLen <= len;
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index dbd67bc6d33..ec17f083e9c 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -168,6 +168,20 @@ final class IOSDeviceIntegrity {
/// Mirrors [#KEY_RECOVERY_SPENT] in memory, so a refused keychain write still bounds the
/// one-shot recovery for the life of the process rather than letting it repeat per request.
private boolean recoverySpentInMemory;
+
+ /**
+ * The key whose attestation Apple has already answered, when the keychain refused to
+ * drop its start marker.
+ *
+ * The marker means "submitted, outcome unknown", and clearing it is what stops the
+ * next request discarding a perfectly good key. A refused removal therefore puts the
+ * device straight back into burning one rate-limited hardware key per request -- the
+ * failure the clearing was added to prevent, reached through the storage layer
+ * instead. Held in memory so at least the life of this process is covered; a restart
+ * still reads the marker and discards the key once, which is the pre-existing
+ * behaviour and bounded.
+ */
+ private String attestAnsweredForKey;
/** True while a generate-then-attest bootstrap is running. */
private boolean bootstrapInFlight;
/**
@@ -229,6 +243,7 @@ private void resetLocked() {
store.remove(KEY_ATTEST_STARTED);
store.remove(KEY_RECOVERY_SPENT);
recoverySpentInMemory = false;
+ attestAnsweredForKey = null;
if (!idGone || !stateGone) {
// The two deletions are not atomic, so a partial failure leaves half the
// identity gone. Treating that as "untouched" would let a callback from the
@@ -363,7 +378,8 @@ AsyncResource requestToken(String nonce) {
}
bootstrapInFlight = true;
if (keyId != null && keyId.length() > 0
- && store.get(KEY_ATTEST_STARTED) == null) {
+ && (store.get(KEY_ATTEST_STARTED) == null
+ || keyId.equals(attestAnsweredForKey))) {
// A key exists but attestation was never started for it -- the
// previous attempt died between generating and submitting. Attest
// that key rather than burning another.
@@ -388,6 +404,8 @@ AsyncResource requestToken(String nonce) {
store.remove(KEY_ID);
store.remove(KEY_STATE);
store.remove(KEY_ATTEST_STARTED);
+ // The discarded key is the one the in-memory marker named.
+ attestAnsweredForKey = null;
PendingRequest fresh = new PendingRequest(r, nonce,
PendingRequest.OP_GENERATE_KEY, null);
int rid = register(fresh);
@@ -551,6 +569,7 @@ public static void nativeKeyGenerated(final int requestId, final String keyId) {
store.remove(KEY_ID);
store.remove(KEY_STATE);
store.remove(KEY_ATTEST_STARTED);
+ instance.attestAnsweredForKey = null;
instance.bootstrapInFlight = false;
fail(pending, "App Attest could not store its key identifier");
instance.failBootstrapWaiters(
@@ -634,6 +653,8 @@ public static void nativeAttestationReady(final int requestId, final String atte
// marker would refuse a future replacement when iOS legitimately invalidates
// THIS key, and every later assertion would fail until the app reset by hand.
store.remove(KEY_ATTEST_STARTED);
+ // Attested, so there is no interrupted attestation left to remember.
+ instance.attestAnsweredForKey = null;
// The in-memory copy is cleared unconditionally, but the persisted marker is
// only forgotten once the keychain confirms the deletion. A stale marker
// would refuse the one-shot replacement the next time iOS legitimately
@@ -781,9 +802,16 @@ public static void nativeAttestError(final int requestId, final int errorCode,
// keeping the marker costs a fresh hardware key per request with
// nothing bounding it.
//
- // A failed removal leaves the marker in place, which is the
- // conservative state this branch used to reach anyway.
- SecureStorage.getInstance().remove(KEY_ATTEST_STARTED);
+ // The removal is checked. If the keychain refuses it, the marker
+ // stays and the next request reads it as an unknown outcome -- back
+ // to discarding a reusable key and generating another, which is the
+ // whole failure this clearing exists to prevent. Remembering the
+ // answered key in memory covers the life of this process; a restart
+ // still reads the marker and discards once, which is where this
+ // branch started and is bounded.
+ if (!SecureStorage.getInstance().remove(KEY_ATTEST_STARTED)) {
+ instance.attestAnsweredForKey = pending.keyId;
+ }
}
if (pending.op != PendingRequest.OP_ASSERT) {
// Still the same acquisition: releasing here and reacquiring would
diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
new file mode 100644
index 00000000000..273de3fee9e
--- /dev/null
+++ b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
@@ -0,0 +1,136 @@
+package com.codename1.impl.javase;
+
+import com.codename1.security.shield.PinSet;
+import com.codename1.security.shield.ShieldConfig;
+import com.codename1.security.shield.ShieldException;
+import com.codename1.security.shield.ShieldToken;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The simulator's shield switches have to change behaviour, not just state.
+ *
+ * Every assertion here failed before {@link JavaSEShieldEngine} existed, for the same
+ * reason: the menu wrote a field in {@link JavaSEShield}, the status dialog read the
+ * field back, and nothing in the request path consulted either -- so "Force Pin Mismatch"
+ * left requests succeeding and the fail-closed branch it exists to reach was unreachable.
+ * A control that reports a state it does not cause is worse than no control.
+ */
+class JavaSEShieldEngineTest {
+
+ private JavaSEShieldEngine engine;
+
+ @BeforeEach
+ void setUp() {
+ JavaSEShield.reset();
+ engine = new JavaSEShieldEngine();
+ engine.initialize(null, new ShieldConfig()
+ .protect("api.example.com")
+ .protect("*.wild.example.com"));
+ }
+
+ @AfterEach
+ void tearDown() {
+ JavaSEShield.reset();
+ }
+
+ @Test
+ void forcingAPinMismatchActuallyFailsTheCheck() {
+ assertTrue(engine.verifyPins("api.example.com", new String[] {"whatever"},
+ new String[] {"whatever"}), "an unarmed simulator must not fail pins");
+
+ JavaSEShield.forcePinMismatch = true;
+ assertFalse(engine.verifyPins("api.example.com", new String[] {"whatever"},
+ new String[] {"whatever"}), "the switch has to reach the pin check");
+ }
+
+ @Test
+ void theForcedMismatchIsOneShot() {
+ // The switch is labelled "on next request". Left armed, a developer testing what
+ // happens after a mismatch would be testing a permanently broken app instead.
+ JavaSEShield.forcePinMismatch = true;
+ assertFalse(engine.verifyPins("api.example.com", new String[0], new String[0]));
+ assertTrue(engine.verifyPins("api.example.com", new String[0], new String[0]),
+ "the second request should be back to normal");
+ assertFalse(JavaSEShield.forcePinMismatch, "and the menu should show it disarmed");
+ }
+
+ @Test
+ void registeredHostsAreEnforcedSoTheMismatchHasSomethingToActOn() {
+ // Without pins for the host, ShieldNetworkGuard never asks verifyPins() at all --
+ // PinSet.isEnforcedFor() is false for a host with no pins. So a pin set that
+ // covers the app's own hosts is load-bearing for the force switch, not decoration.
+ PinSet pins = engine.getPinSet();
+ assertTrue(pins.isEnforcedFor("api.example.com"),
+ "a registered host must be enforced or the mismatch switch does nothing");
+ assertFalse(pins.isEnforcedFor("unregistered.example.com"),
+ "a host the app never registered must be left alone");
+ }
+
+ @Test
+ void failingThePinFetchYieldsNoEnforcementRatherThanAMismatch() {
+ // Pinning fails OPEN on unavailability everywhere in this design; the simulator
+ // has to be able to demonstrate that rather than have it asserted in a doc.
+ JavaSEShield.failPinFetch = true;
+ PinSet pins = engine.getPinSet();
+ assertFalse(pins.isEnforcedFor("api.example.com"),
+ "an unavailable pin set is not a mismatch");
+ }
+
+ @Test
+ void aSimulatedTokenIsMarkedAndHonoursTheConfiguredLifetime() throws Exception {
+ JavaSEShield.tokenTtlSeconds = 120;
+ ShieldToken token = engine.fetchToken(null);
+
+ assertNotNull(token);
+ assertTrue(token.isValid(), "a fresh simulated token should be usable");
+ assertTrue(token.getValue().contains(JavaSEShieldEngine.SIMULATED_MARKER),
+ "a backend that ever sees one of these is talking to a simulator, and the "
+ + "value is the part that ends up pasted into a bug report");
+ assertTrue(token.getMillisUntilExpiry() > 0);
+ assertEquals(token, engine.getCachedToken());
+ }
+
+ @Test
+ void servingAnExpiredTokenProducesOneThatIsActuallyExpired() throws Exception {
+ // Backdating fetchedAt alone would not do it: validity is answered from a
+ // monotonic reading taken at construction, precisely so a device clock cannot
+ // make a lapsed token look fresh.
+ JavaSEShield.serveExpiredToken = true;
+ ShieldToken token = engine.fetchToken(null);
+ assertFalse(token.isValid(), "the switch has to produce a token that fails");
+ assertEquals(0L, token.getMillisUntilExpiry());
+ }
+
+ @Test
+ void eachSimulatedFailureCarriesItsOwnStatus() {
+ // The distinction between "cannot reach the shield" and "the shield says no" is
+ // the most important thing in the API, so each outcome has to arrive as itself.
+ JavaSEShield.attestOutcome = JavaSEShield.AttestOutcome.FAIL_REJECTED;
+ assertEquals(com.codename1.security.shield.ShieldStatus.REJECTED,
+ assertThrows(ShieldException.class, () -> engine.fetchToken(null)).getStatus());
+
+ JavaSEShield.attestOutcome = JavaSEShield.AttestOutcome.FAIL_NO_NETWORK;
+ assertEquals(com.codename1.security.shield.ShieldStatus.NO_NETWORK,
+ assertThrows(ShieldException.class, () -> engine.fetchToken(null)).getStatus());
+
+ JavaSEShield.attestOutcome = JavaSEShield.AttestOutcome.FAIL_RATE_LIMITED;
+ assertEquals(com.codename1.security.shield.ShieldStatus.RATE_LIMITED,
+ assertThrows(ShieldException.class, () -> engine.fetchToken(null)).getStatus());
+ }
+
+ @Test
+ void simulatedDeviceSignalsReachTheEngine() {
+ JavaSEShield.simHooked = true;
+ JavaSEShield.simUntrustedAccessibility = true;
+ assertEquals(2, engine.collectSignals().length,
+ "the device-signal switches feed what the server is told");
+ }
+}
From 10f8ffde155ef221f5f17661f63d15f07b490d42 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 03:56:20 +0300
Subject: [PATCH 39/96] Give the new test its header, and stop a Maven Central
reset failing setup
check-copyright-headers was right: JavaSEShieldEngineTest had none.
javase-simulator-tests died in Setup workspace on "Connection reset" fetching a
build plugin from Maven Central, before any project code compiled. Maven treats
that as a permanent resolution failure, so the job reported a broken build for a
network blip. The two installs in setup-workspace.sh now go through a retry with
a growing delay -- growing because a flat retry lands inside the same window a
429 is still rate limiting in. A genuine build failure fails identically on every
attempt and still propagates, which I checked with a stub mvn for both the
fails-then-succeeds and the always-fails paths.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/javase/JavaSEShieldEngineTest.java | 22 ++++++++++++++++
scripts/setup-workspace.sh | 25 +++++++++++++++++--
2 files changed, 45 insertions(+), 2 deletions(-)
diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
index 273de3fee9e..4f384e16a4f 100644
--- a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
+++ b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
@@ -1,3 +1,25 @@
+/*
+ * Copyright (c) 2026, 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.shield.PinSet;
diff --git a/scripts/setup-workspace.sh b/scripts/setup-workspace.sh
index 1171f7dcc83..eedbdff6068 100755
--- a/scripts/setup-workspace.sh
+++ b/scripts/setup-workspace.sh
@@ -232,11 +232,32 @@ fi
# longer costs nothing when there is no contention.
MVN_LOCK_ARGS="-Daether.syncContext.named.time=300 -Daether.syncContext.named.timeUnit=SECONDS"
+# Maven Central resets the connection or throttles a runner often enough to matter,
+# and Maven treats that as a PERMANENT resolution failure -- observed killing this
+# script while fetching a build plugin, before any project code compiled. The delay
+# grows because a flat retry lands inside the same window a 429 is still rate
+# limiting in. A real build failure fails identically every attempt, so this costs
+# one extra run of a broken build and rescues a green one.
+mvn_retry() {
+ local delay
+ for delay in 30 120 300 0; do
+ if "$MAVEN_HOME/bin/mvn" "$@"; then
+ return 0
+ fi
+ if [ "$delay" = "0" ]; then
+ log "maven failed after all retries"
+ return 1
+ fi
+ log "maven failed; retrying in ${delay}s in case Maven Central was flaky"
+ sleep "$delay"
+ done
+}
+
log "Building Codename One core modules"
-"$MAVEN_HOME/bin/mvn" -f maven/pom.xml $MVN_LOCK_ARGS -T 1C -Dmaven.javadoc.skip=true -Dmaven.source.skip=true -DskipTests -Djava.awt.headless=true -Dcn1.binaries="$CN1_BINARIES" -Dcodename1.platform=javase -P local-dev-javase,compile-android,!download-cn1-binaries install "$@"
+mvn_retry -f maven/pom.xml $MVN_LOCK_ARGS -T 1C -Dmaven.javadoc.skip=true -Dmaven.source.skip=true -DskipTests -Djava.awt.headless=true -Dcn1.binaries="$CN1_BINARIES" -Dcodename1.platform=javase -P local-dev-javase,compile-android,!download-cn1-binaries install "$@"
log "Building Codename One Maven plugin"
-"$MAVEN_HOME/bin/mvn" -f maven/pom.xml \
+mvn_retry -f maven/pom.xml \
-pl codenameone-maven-plugin -am \
$MVN_LOCK_ARGS -T 1C -Dmaven.javadoc.skip=true -Dmaven.source.skip=true \
-DskipTests -Djava.awt.headless=true \
From 2551b156c8a8ad41549f50a672daad368162b9bf Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 04:16:18 +0300
Subject: [PATCH 40/96] Keep an exhausted invalid-key attestation terminal
The answered-failure cleanup cleared the start marker for every reported error.
Reaching it with invalidKey means the reset branch above declined, so the one-shot
recovery is already spent and this key is both known bad and known unreplaceable
-- and clearing the marker made the next request read it as a reusable key whose
attestation had never started, and submit it to Apple again. Once per request,
against a rate limit, instead of surfacing the exhausted recovery the caller needs
to act on. invalidKey is now excluded from that cleanup, and from the in-memory
answered marker with it.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/impl/ios/IOSDeviceIntegrity.java | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index ec17f083e9c..0f0e3481f91 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -783,7 +783,16 @@ public static void nativeAttestError(final int requestId, final int errorCode,
Long.toString(deadline));
instance.currentBackoff = Math.min(backoff * 2, MAX_BACKOFF_MILLIS);
}
- if (pending.op == PendingRequest.OP_ATTEST) {
+ if (pending.op == PendingRequest.OP_ATTEST
+ && errorCode != DC_ERROR_INVALID_KEY) {
+ // invalidKey is excluded. Reaching here with it means the reset
+ // branch above declined -- the one-shot recovery is already spent --
+ // so this key is known bad and known unreplaceable. Clearing the
+ // marker would make the next request read it as a reusable key with
+ // an unstarted attestation and submit it to Apple again, once per
+ // request, instead of reporting the exhausted recovery the caller
+ // needs to see. Terminal has to stay terminal.
+ //
// The marker means "submitted, outcome unknown", and the
// interrupted-attestation branch answers an unknown outcome by
// discarding the key, because a spent one-time attestation cannot be
From 52a2ddfda753e68735104efc9796b131a1c51664 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 04:36:25 +0300
Subject: [PATCH 41/96] Keep the terminal markers until the key is confirmed
gone, and pin wildcards
resetLocked cleared the attestation-start and recovery-spent markers before it
checked whether the identity deletions had succeeded. A keychain that deleted
KEY_STATE and refused KEY_ID therefore left a known-invalid key with no state and
no start marker -- which the next request reads as a freshly generated key whose
attestation never began, and submits to Apple. Every request after it does the
same, against a rate limit, with the one-shot recovery marker gone too so nothing
stops the loop. Those markers are now cleared only once both identity deletions
are confirmed.
The simulated pin set dropped wildcard hosts, so an app registering
"*.example.com" -- which is the common way to do it -- had nothing enforced and
Force Pin Mismatch still did nothing. PinSet.isEnforcedFor already resolves a
concrete host against a "*." entry, so the exclusion was mine and wrong.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/javase/JavaSEShieldEngine.java | 8 ++++++--
.../codename1/impl/ios/IOSDeviceIntegrity.java | 17 +++++++++++++----
.../impl/javase/JavaSEShieldEngineTest.java | 8 ++++++++
3 files changed, 27 insertions(+), 6 deletions(-)
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java
index 5f2c6785e7c..9ce98b22162 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java
@@ -190,10 +190,14 @@ public PinSet getPinSet() {
java.util.Enumeration hosts = config.protectedHosts();
while (hosts.hasMoreElements()) {
String host = (String) hosts.nextElement();
- if (host == null || host.startsWith("*.")) {
- // A wildcard is a policy pattern, not a host a chain is served for.
+ if (host == null) {
continue;
}
+ // Wildcards included. PinSet.isEnforcedFor() resolves "api.example.com"
+ // against a "*.example.com" entry, so skipping them left every app that
+ // registers its hosts by pattern -- which is the common way to do it --
+ // with nothing enforced and the force switch doing nothing, which is the
+ // whole failure this engine exists to remove.
Vector pins = new Vector();
pins.addElement(SIMULATED_PIN);
hostToPins.put(host, pins);
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 0f0e3481f91..aed86b60d49 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -240,10 +240,19 @@ private void resetLocked() {
boolean stateGone = store.remove(KEY_STATE);
store.remove(KEY_RETRY_AFTER);
store.remove(KEY_PENDING_SINCE);
- store.remove(KEY_ATTEST_STARTED);
- store.remove(KEY_RECOVERY_SPENT);
- recoverySpentInMemory = false;
- attestAnsweredForKey = null;
+ // The markers that make a surviving key terminal are cleared only once the key
+ // itself is confirmed gone. Clearing them first meant a keychain that deleted
+ // KEY_STATE and refused KEY_ID left a known-invalid key behind with no state and
+ // no start marker -- which the next request reads as a freshly generated key
+ // whose attestation never began, and submits to Apple. Every request after it
+ // does the same, against a rate limit, with the one-shot recovery marker also
+ // gone so nothing stops the loop.
+ if (idGone && stateGone) {
+ store.remove(KEY_ATTEST_STARTED);
+ store.remove(KEY_RECOVERY_SPENT);
+ recoverySpentInMemory = false;
+ attestAnsweredForKey = null;
+ }
if (!idGone || !stateGone) {
// The two deletions are not atomic, so a partial failure leaves half the
// identity gone. Treating that as "untouched" would let a callback from the
diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
index 4f384e16a4f..a37abdc7f52 100644
--- a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
+++ b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
@@ -94,6 +94,14 @@ void registeredHostsAreEnforcedSoTheMismatchHasSomethingToActOn() {
"a registered host must be enforced or the mismatch switch does nothing");
assertFalse(pins.isEnforcedFor("unregistered.example.com"),
"a host the app never registered must be left alone");
+ // Wildcards too. Registering by pattern is the common way to do it, and
+ // PinSet.isEnforcedFor() resolves a concrete host against a "*." entry -- so
+ // dropping them left those apps with nothing enforced and the force switch
+ // doing nothing.
+ assertTrue(pins.isEnforcedFor("api.wild.example.com"),
+ "a host covered by a registered wildcard must be enforced too");
+ assertFalse(pins.isEnforcedFor("wild.example.com"),
+ "but the apex is not covered by its own wildcard");
}
@Test
From c4677c76b1f235e4f00d7682b7c1ae5431c39532 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 04:52:30 +0300
Subject: [PATCH 42/96] Arm the simulator engine from the outcome menu, and
disarm the spent one-shot
Selecting an attestation result -- or restoring a non-default one at startup --
only set a field. With no engine registered, AppShield still asked the inert
default, so a developer who chose FAIL_REJECTED got UNPROTECTED: the same
control-that-does-nothing the engine was added to remove, left in the one part of
the menu the engine did not cover. The outcome radio group and the
attestation-supported checkbox now register it too.
The forced mismatch cleared the engine's flag and left the checkbox and the
stored preference true, so the menu claimed a mismatch was still armed, the next
click disarmed instead of arming, and the next launch restored one that had
already fired. The engine now spends it through JavaSEShield, which notifies the
port to clear both.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/impl/javase/JavaSEPort.java | 39 +++++++++++++++++--
.../codename1/impl/javase/JavaSEShield.java | 22 +++++++++++
.../impl/javase/JavaSEShieldEngine.java | 7 +++-
.../impl/javase/JavaSEShieldEngineTest.java | 21 ++++++++++
4 files changed, 84 insertions(+), 5 deletions(-)
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index 344c8782dd7..e867994ca12 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -8340,6 +8340,10 @@ private JMenu installShieldSimulationMenu(JMenu simulateMenu, final Preferences
public void actionPerformed(ActionEvent ae) {
JavaSEShield.attestationSupported = supported.isSelected();
pref.putBoolean("ShieldSim.supported", supported.isSelected());
+ // Unchecking this is a simulation like any other -- "this platform has no
+ // attestation" is an outcome an app has to handle -- so it arms the
+ // engine as well.
+ JavaSEShieldEngine.ensureRegistered();
}
});
shieldMenu.add(supported);
@@ -8353,6 +8357,13 @@ public void actionPerformed(ActionEvent ae) {
if (outcome.name().equals(storedOutcome)) {
item.setSelected(true);
JavaSEShield.attestOutcome = outcome;
+ // Restored non-default state arms the engine too, for the same reason a
+ // restored checkbox does: a developer who left FAIL_REJECTED selected
+ // expects the next run to fail, and without an engine registered
+ // AppShield asks the inert default and reports UNPROTECTED instead.
+ if (outcome != JavaSEShield.AttestOutcome.PASS) {
+ JavaSEShieldEngine.ensureRegistered();
+ }
}
outcomeGroup.add(item);
item.addActionListener(new ActionListener() {
@@ -8360,6 +8371,10 @@ public void actionPerformed(ActionEvent ae) {
public void actionPerformed(ActionEvent ae) {
JavaSEShield.attestOutcome = outcome;
pref.put("ShieldSim.outcome", outcome.name());
+ // Selecting any outcome registers the engine, PASS included: PASS is
+ // "hand out a simulated token", which the inert default does not do
+ // either. Only the untouched menu leaves the simulator alone.
+ JavaSEShieldEngine.ensureRegistered();
}
});
outcomeMenu.add(item);
@@ -8425,13 +8440,31 @@ public void set(boolean v) {
}));
// The one branch that is otherwise effectively untestable.
- shieldMenu.add(shieldToggle(pref, "Force Pin Mismatch On Next Request",
- "ShieldSim.pinMismatch", new ShieldToggleSink() {
+ final JCheckBoxMenuItem pinMismatch = shieldToggle(pref,
+ "Force Pin Mismatch On Next Request", "ShieldSim.pinMismatch",
+ new ShieldToggleSink() {
@Override
public void set(boolean v) {
JavaSEShield.forcePinMismatch = v;
}
- }));
+ });
+ // It is a one-shot, so when the engine spends it the menu and the stored
+ // preference have to follow. Otherwise the checkbox says a mismatch is armed
+ // when it is not, the next click disarms instead of arming, and the next launch
+ // restores a mismatch that already fired.
+ JavaSEShield.onForcePinMismatchConsumed = new Runnable() {
+ @Override
+ public void run() {
+ SwingUtilities.invokeLater(new Runnable() {
+ @Override
+ public void run() {
+ pinMismatch.setSelected(false);
+ pref.putBoolean("ShieldSim.pinMismatch", false);
+ }
+ });
+ }
+ };
+ shieldMenu.add(pinMismatch);
shieldMenu.add(shieldToggle(pref, "Fail Pin Fetch", "ShieldSim.pinFetchFail",
new ShieldToggleSink() {
@Override
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShield.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShield.java
index dc384fc8780..5b7bfc0e3a1 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShield.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShield.java
@@ -98,6 +98,27 @@ public enum AttestOutcome {
/** Simulate being unable to fetch a pin set. Must never fail a request. */
public static boolean failPinFetch;
+ /**
+ * Notified when the one-shot forced mismatch is spent, so the menu can disarm too.
+ *
+ * Without it the engine cleared its own flag and the checkbox and the stored
+ * preference stayed true: the menu claimed a mismatch was still armed, the next click
+ * disarmed it instead of scheduling another, and restarting the simulator re-armed
+ * one that had already fired. A control that reports a state it does not cause is
+ * the failure this whole engine exists to remove, so it must not reappear in the
+ * engine's own bookkeeping.
+ */
+ public static Runnable onForcePinMismatchConsumed;
+
+ /** Clears the forced mismatch and lets the menu know, so the two cannot disagree. */
+ static void consumeForcePinMismatch() {
+ forcePinMismatch = false;
+ Runnable r = onForcePinMismatchConsumed;
+ if (r != null) {
+ r.run();
+ }
+ }
+
/** True when the window is displaying a screen marked secure. */
public static boolean secureScreen;
@@ -145,6 +166,7 @@ public static void reset() {
forcePinMismatch = false;
failPinFetch = false;
secureScreen = false;
+ onForcePinMismatchConsumed = null;
}
/** A human-readable dump for the menu's status dialog. */
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java
index 9ce98b22162..9aa755491de 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java
@@ -169,8 +169,11 @@ public boolean verifyPins(String host, String[] spkiDigests, String[] certDigest
}
// One shot, because the switch is labelled "on next request". Leaving it armed
// would fail every subsequent request too, and a developer testing a recovery
- // path would be testing a permanently broken app instead.
- JavaSEShield.forcePinMismatch = false;
+ // path would be testing a permanently broken app instead. Through
+ // consumeForcePinMismatch so the menu and the stored preference disarm with the
+ // flag -- clearing the field alone left the checkbox claiming it was still armed
+ // and re-armed it on the next launch.
+ JavaSEShield.consumeForcePinMismatch();
return false;
}
diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
index a37abdc7f52..44cb36135b2 100644
--- a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
+++ b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
@@ -84,6 +84,27 @@ void theForcedMismatchIsOneShot() {
assertFalse(JavaSEShield.forcePinMismatch, "and the menu should show it disarmed");
}
+ @Test
+ void spendingTheForcedMismatchTellsTheMenuToDisarm() {
+ // The engine clearing its own field is not enough: the checkbox and the stored
+ // preference live in the port, so without a notification the menu claims a
+ // mismatch is still armed, the next click disarms instead of arming, and the
+ // next launch restores one that already fired.
+ final int[] notified = {0};
+ JavaSEShield.onForcePinMismatchConsumed = new Runnable() {
+ public void run() {
+ notified[0]++;
+ }
+ };
+ JavaSEShield.forcePinMismatch = true;
+
+ engine.verifyPins("api.example.com", new String[0], new String[0]);
+ assertEquals(1, notified[0], "the menu has to be told the one-shot was spent");
+
+ engine.verifyPins("api.example.com", new String[0], new String[0]);
+ assertEquals(1, notified[0], "and only when there was something to spend");
+ }
+
@Test
void registeredHostsAreEnforcedSoTheMismatchHasSomethingToActOn() {
// Without pins for the host, ShieldNetworkGuard never asks verifyPins() at all --
From a34ee58f709414a1153e8b12341274d7f5bdf1a1 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 05:14:16 +0300
Subject: [PATCH 43/96] Refuse to attest from a half-deleted identity, and two
more simulator gaps
A partial discard leaves an identity with no markers to make it terminal -- a
successfully attested key deliberately has neither a start marker nor a spent
one -- so the next request read the retained key as never submitted and attested
it again, spending a rate-limited attempt on a key Apple had already attested and
the backend had already rejected, once per request. Nothing persisted can say
"this key is finished" while the keychain is refusing writes, so the state is
held in memory and attestation refuses with an explanation until a reset
succeeds.
The recovery-spent write result is no longer ignored. The in-memory copy covers
this process, but the replacement key's own start marker persists -- so after a
restart the next launch found a key with no record that a recovery had been
spent and started another one, a rate-limited key per launch caused by the write
meant to bound it. Without the marker there is no replacement.
A restored "Attestation Supported = false" armed the flag and no engine, since
the action listener does not fire during construction -- so a fail-closed host
was let through instead of exercising the unsupported-device rejection the
setting exists to simulate.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/impl/javase/JavaSEPort.java | 8 +++
.../impl/ios/IOSDeviceIntegrity.java | 51 ++++++++++++++++++-
2 files changed, 58 insertions(+), 1 deletion(-)
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index e867994ca12..865c8cc3781 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -8335,6 +8335,14 @@ private JMenu installShieldSimulationMenu(JMenu simulateMenu, final Preferences
final JCheckBoxMenuItem supported = new JCheckBoxMenuItem("Attestation Supported",
pref.getBoolean("ShieldSim.supported", true));
JavaSEShield.attestationSupported = supported.isSelected();
+ // A restored FALSE is a simulation left switched on, exactly like a restored
+ // checkbox elsewhere in this menu -- the action listener does not fire during
+ // construction, so without this the flag is set and no engine answers, and a
+ // fail-closed host is let through instead of exercising the unsupported-device
+ // rejection the setting exists to simulate. True is the default and arms nothing.
+ if (!supported.isSelected()) {
+ JavaSEShieldEngine.ensureRegistered();
+ }
supported.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent ae) {
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index aed86b60d49..b904f57c7e7 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -182,6 +182,18 @@ final class IOSDeviceIntegrity {
* behaviour and bounded.
*/
private String attestAnsweredForKey;
+
+ /**
+ * Set when the keychain refused to delete part of a discarded identity.
+ *
+ * Attestation refuses outright while it is set. The alternative is worse than a
+ * failing app: half an identity with no markers reads as a fresh key whose
+ * attestation never began, so every request would spend a rate-limited attempt on a
+ * key that is already attested and already rejected. Cleared by a reset that
+ * succeeds, and not persisted -- there is nowhere to persist it, since the failure
+ * being recorded is that persistence is not working.
+ */
+ private boolean discardFailed;
/** True while a generate-then-attest bootstrap is running. */
private boolean bootstrapInFlight;
/**
@@ -254,6 +266,15 @@ private void resetLocked() {
attestAnsweredForKey = null;
}
if (!idGone || !stateGone) {
+ // The identity survives in part, and there may be nothing left to make it
+ // terminal: a successfully attested key has no start marker and no spent
+ // marker, by design, so the next request would read the retained key as
+ // never submitted and attest it again -- a rate-limited attempt on a key
+ // Apple has already attested, repeated per request. Nothing persisted can
+ // express "this key is finished" once the keychain is refusing writes, so
+ // the state is held in memory and requestToken refuses until a reset
+ // succeeds or the process restarts.
+ discardFailed = true;
// The two deletions are not atomic, so a partial failure leaves half the
// identity gone. Treating that as "untouched" would let a callback from the
// rejected identity still pass its staleness check and act on inconsistent
@@ -337,6 +358,18 @@ AsyncResource requestToken(String nonce) {
return r;
}
synchronized (flowLock) {
+ if (discardFailed) {
+ // A previous discard left half an identity behind because the keychain
+ // refused a deletion. Attesting from here spends a rate-limited attempt
+ // on a key that is already attested and already rejected, once per
+ // request, so the app is told plainly instead. resetAttestation() clears
+ // it if the keychain recovers.
+ r.error(new RuntimeException("App Attest could not discard a rejected key "
+ + "and cannot safely generate another; call "
+ + "DeviceIntegrity.resetAttestation() once the device's keychain "
+ + "is writable again"));
+ return r;
+ }
long retryAfter = Math.max(readRetryAfter(), retryAfterFallback);
if (retryAfter > System.currentTimeMillis()) {
r.error(new RuntimeException("App Attest is backing off after a throttle; "
@@ -768,7 +801,23 @@ public static void nativeAttestError(final int requestId, final int errorCode,
// one-shot limit still holds for the life of the process rather than
// letting every later request burn another key.
instance.recoverySpentInMemory = true;
- SecureStorage.getInstance().set(KEY_RECOVERY_SPENT, "1");
+ if (!SecureStorage.getInstance().set(KEY_RECOVERY_SPENT, "1")) {
+ // No replacement without the marker. The in-memory copy only
+ // covers this process, and the replacement's own start marker
+ // DOES persist -- so after a restart the next launch would find a
+ // discarded-looking key with no record that a recovery had been
+ // spent, and start another one. That is a rate-limited hardware
+ // key per launch, caused by the very write that was meant to
+ // bound it. Failing here costs this caller one request, and the
+ // device is left with no key at all, so a later attempt starts
+ // cleanly rather than compounding.
+ instance.bootstrapInFlight = false;
+ fail(pending, "App Attest could not record that its one-time "
+ + "recovery had been used, so it was not attempted");
+ instance.failBootstrapWaiters("App Attest could not record that "
+ + "its one-time recovery had been used");
+ return;
+ }
PendingRequest retry = new PendingRequest(pending.result, pending.nonce,
PendingRequest.OP_GENERATE_KEY, null);
retry.retried = true;
From 9b164ac2cbb7082514953c43a06d20ab2ebd7420 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 05:41:38 +0300
Subject: [PATCH 44/96] Reopen the real loopback transport, and refuse
Content-Type as the token header
MCPLoopbackSocketTransport.close() sets its closed flag permanently and open()
never cleared it, so a stop()/start() over one instance -- which the server
supports, and which is how a caller holding a single transport restarts -- opened
successfully and then had its first readMessage() return null immediately,
shutting the restarted server straight back down. My restart tests used a double,
which agreed with whatever the double was written to do; there is now one against
the production class.
ConnectionRequest.addRequestHeader special-cases Content-Type into the request's
own content type rather than the header map, so configuring it as the token
header would replace the media type AND survive removeRequestHeader -- meaning
the token follows a redirect off a protected host and is handed to whatever is
there. Refused, case-insensitively.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../mcp/MCPLoopbackSocketTransport.java | 18 ++++++++++++
.../security/shield/ShieldConfig.java | 15 ++++++++++
.../mcp/MCPLoopbackTransportOpenTest.java | 29 +++++++++++++++++++
.../security/shield/ShieldApiTest.java | 19 ++++++++++++
4 files changed, 81 insertions(+)
diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java
index 5e715ae6fea..2616f77cb69 100644
--- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java
+++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java
@@ -118,6 +118,16 @@ public void open() throws IOException {
}
active = this;
}
+ // Reopening the same instance has to start a session, not resume a closed one.
+ // close() sets this permanently and nothing cleared it, so a stop()/start() pair
+ // over one transport -- which the server explicitly supports, and which is how a
+ // caller that keeps a single transport around restarts -- opened successfully and
+ // then had its first readMessage() return null immediately, shutting the restarted
+ // server straight back down. Cleared here rather than in close(), because a
+ // transport that has been closed and not reopened must keep refusing reads.
+ synchronized (lock) {
+ closed = false;
+ }
try {
listening = Socket.listenLoopback(port, Connection.class);
} catch (RuntimeException ex) {
@@ -351,6 +361,14 @@ private boolean isClosed() {
}
}
+ /// Test seam: has this transport been closed and not reopened? Package-private
+ /// because the reopen semantics are otherwise only observable through a real socket.
+ boolean isClosedForTest() {
+ synchronized (lock) {
+ return closed;
+ }
+ }
+
@Override
public void close() {
Socket.StopListening l;
diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java b/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
index e23edcc5918..11a28cbd48f 100644
--- a/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
+++ b/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java
@@ -68,8 +68,23 @@ public ShieldConfig endpoint(String url) {
/// Overrides the header used to carry the token. Change this only if it collides with
/// something already in use on your backend.
+ ///
+ /// `Content-Type` is refused. [com.codename1.io.ConnectionRequest#addRequestHeader]
+ /// special-cases that name into the request's own content type rather than the header
+ /// map, so the token would replace the request's media type and -- because the removal
+ /// path only clears the map -- would survive a redirect to an unprotected host and be
+ /// handed to it. A leak with no symptom on the way there.
+ ///
+ /// @throws IllegalArgumentException if the name cannot carry a token safely
public ShieldConfig tokenHeader(String name) {
if (name != null && name.length() > 0) {
+ if ("content-type".equals(ShieldHosts.normalize(name))) {
+ throw new IllegalArgumentException("Content-Type cannot carry the "
+ + "attestation token: it is not stored as an ordinary header, so it "
+ + "cannot be cleared when a request redirects off a protected host, "
+ + "and the token would follow the redirect. Use a header of your "
+ + "own, or leave the default " + DEFAULT_TOKEN_HEADER + ".");
+ }
this.tokenHeader = name;
}
return this;
diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java
index d27fbdc20e0..1f52ad1c3e3 100644
--- a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java
@@ -32,6 +32,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -248,6 +249,34 @@ private static int liveReaderThreads() {
return n;
}
+ @Test
+ void theProductionLoopbackTransportCanBeReopened() throws Exception {
+ // The restart tests above use a double, which cannot see this: the real
+ // transport's close() sets its closed flag permanently, and open() never cleared
+ // it -- so a stop()/start() over one instance opened fine and then had its first
+ // readMessage() return null immediately, shutting the restarted server straight
+ // back down. Asserted against the production class rather than a stand-in,
+ // because a double will agree with whatever the double was written to do.
+ MCPLoopbackSocketTransport transport = new MCPLoopbackSocketTransport(0);
+ transport.close();
+ // Reading a closed transport ends the session -- that part is correct.
+ assertNull(transport.readMessage(), "a closed transport must stop serving");
+ try {
+ transport.open();
+ } catch (IOException platformCannotBind) {
+ // No loopback server socket in this test JVM. The reopen semantics are still
+ // what this test is about, and they are visible without a real socket.
+ transport.close();
+ return;
+ }
+ try {
+ assertFalse(transport.isClosedForTest(),
+ "reopening has to start a session, not resume the closed one");
+ } finally {
+ transport.close();
+ }
+ }
+
@Test
void aSupersededReaderMustNotCloseATransportTheRestartIsNowServing() throws Exception {
// Restarting over the SAME transport instance. Declining to clear `running` is only
diff --git a/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
index d3042fffe9d..6b88ebb37dc 100644
--- a/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
@@ -33,6 +33,7 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -391,6 +392,24 @@ void severityIsClampedToTheDocumentedRange() {
assertEquals(0, new ShieldSignal("x", -5, null).getSeverity());
}
+ @Test
+ void contentTypeIsRefusedAsTheTokenHeader() {
+ // ConnectionRequest.addRequestHeader special-cases Content-Type into the
+ // request's own content type rather than the header map, so the token would
+ // replace the media type AND survive removeRequestHeader -- which means it
+ // follows a redirect off a protected host and is handed to whatever is there.
+ assertThrows(IllegalArgumentException.class,
+ () -> new ShieldConfig().tokenHeader("Content-Type"));
+ assertThrows(IllegalArgumentException.class,
+ () -> new ShieldConfig().tokenHeader("content-TYPE"),
+ "the check has to be case-insensitive, like the header itself");
+
+ // Everything else still works, including the default.
+ assertEquals("X-Mine", new ShieldConfig().tokenHeader("X-Mine").getTokenHeader());
+ assertEquals(ShieldConfig.DEFAULT_TOKEN_HEADER,
+ new ShieldConfig().getTokenHeader());
+ }
+
// --- guard composition ----------------------------------------------
@Test
From 84640f4222d2a197553eac6e846cd9f4fff97773 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Fri, 31 Jul 2026 15:14:04 +0300
Subject: [PATCH 45/96] Serialize transport reopen, and record a spent recovery
before discarding the key
Two generations could be inside one transport's open() at the same time: a
stop()/start() over the same instance while the old reader was still parked in
open(). The same-transport rule then correctly declines to close it on the way
out, so the instance was left holding two listeners with one handle -- the first
leaked and outlived stop(). Opening is now serialized per server on a lock that
is deliberately not the server monitor (open() blocks, and holding the monitor
across it would make stop() wait on what it is stopping), and the loopback
transport refuses a second open while one is live, since that handle is the only
reference to the listener.
The recovery-spent marker is written BEFORE the identity is discarded. Discarding
first and then failing to record left no key at all AND no record that a recovery
had happened, so the next request took the plain generate-key path -- which
consults neither marker -- and burned a fresh rate-limited hardware key on every
request, and on every launch once the in-memory copy was gone. Writing first means
a refused write costs one request and changes nothing else. resetLocked gains a
keepSpentMarker flag so the reset that IS the recovery does not erase the marker
it was just told to honour.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../mcp/MCPLoopbackSocketTransport.java | 10 +++
.../src/com/codename1/mcp/MCPServer.java | 21 +++++-
.../impl/ios/IOSDeviceIntegrity.java | 64 +++++++++++--------
3 files changed, 68 insertions(+), 27 deletions(-)
diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java
index 2616f77cb69..c0e6e22fd15 100644
--- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java
+++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java
@@ -128,6 +128,16 @@ public void open() throws IOException {
synchronized (lock) {
closed = false;
}
+ // One listener per instance. The server serializes opens, but this handle is the
+ // only reference to the listener -- overwriting it strands the previous one, and
+ // a stranded listener survives close() and keeps the port bound. Refusing is the
+ // honest answer: the caller asked to open something that is already open.
+ synchronized (lock) {
+ if (listening != null) {
+ throw new IOException("This MCP transport is already listening on port "
+ + port);
+ }
+ }
try {
listening = Socket.listenLoopback(port, Connection.class);
} catch (RuntimeException ex) {
diff --git a/CodenameOne/src/com/codename1/mcp/MCPServer.java b/CodenameOne/src/com/codename1/mcp/MCPServer.java
index 5250eef5868..467d6aa7720 100644
--- a/CodenameOne/src/com/codename1/mcp/MCPServer.java
+++ b/CodenameOne/src/com/codename1/mcp/MCPServer.java
@@ -218,6 +218,12 @@ private synchronized void releaseAndCloseIfCurrent(MCPTransport t, int generatio
}
}
+ /// Held across `open()` so two generations cannot be inside one transport's open at
+ /// the same time. Deliberately NOT the server monitor: `open()` blocks -- it binds a
+ /// listener -- and holding the monitor across it would make `stop()` wait on the
+ /// thing it is trying to stop.
+ private final Object openLock = new Object();
+
private void runLoop(MCPTransport t, int generation) {
// Opening is deferred to this thread, so by the time it happens the server may
// already have been stopped or restarted. Either way stop()'s close() ran against a
@@ -229,7 +235,20 @@ private void runLoop(MCPTransport t, int generation) {
return;
}
try {
- t.open();
+ // Serialized per server. A stop()/start() over the SAME transport while the
+ // old reader is still parked inside open() would otherwise have both
+ // generations open one instance: the same-transport rule then correctly
+ // declines to close it on the way out, and the transport is left holding two
+ // listeners with one handle for them -- so the first is leaked and outlives
+ // stop(). The superseded thread finds itself stale the moment it gets the
+ // lock, and unwinds without opening anything.
+ synchronized (openLock) {
+ if (!isCurrent(t, generation)) {
+ releaseAndCloseIfCurrent(t, generation);
+ return;
+ }
+ t.open();
+ }
} catch (IOException ex) {
// The transport failed to start listening. Log defensively: the CN1 Log
// routes through the platform implementation, which may not be registered
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index b904f57c7e7..f79c695c38f 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -242,6 +242,14 @@ void resetAttestation() {
/// The reset itself, for callers that must not release the lock between discarding
/// the old identity and starting the replacement. Caller holds `flowLock`.
private void resetLocked() {
+ resetLocked(false);
+ }
+
+ /// @param keepSpentMarker true when this reset is the discard step of a recovery
+ /// that has already recorded itself as spent. Clearing the marker there would
+ /// undo the one-shot limit at the exact moment it starts applying -- the
+ /// replacement key would look like a first recovery all over again.
+ private void resetLocked(boolean keepSpentMarker) {
SecureStorage store = SecureStorage.getInstance();
// The two that carry the identity are checked. If the keychain refuses to
// delete them the reset has not happened, and advancing the generation anyway
@@ -261,9 +269,12 @@ private void resetLocked() {
// gone so nothing stops the loop.
if (idGone && stateGone) {
store.remove(KEY_ATTEST_STARTED);
- store.remove(KEY_RECOVERY_SPENT);
- recoverySpentInMemory = false;
attestAnsweredForKey = null;
+ discardFailed = false;
+ if (!keepSpentMarker) {
+ store.remove(KEY_RECOVERY_SPENT);
+ recoverySpentInMemory = false;
+ }
}
if (!idGone || !stateGone) {
// The identity survives in part, and there may be nothing left to make it
@@ -784,8 +795,32 @@ public static void nativeAttestError(final int requestId, final int errorCode,
if (errorCode == DC_ERROR_INVALID_KEY && !recoverySpent) {
// The key is gone or was never valid. Wipe it and try once from
// scratch; a second failure is reported rather than looped.
+ //
+ // The spent marker is written BEFORE the identity is discarded, which
+ // is the ordering the whole one-shot limit rests on. Discarding first
+ // and then failing to record left no key at all AND no record that a
+ // recovery had happened -- so the next request took the plain
+ // generate-key path, which consults neither marker, and every request
+ // (and every launch, since the in-memory copy does not survive one)
+ // burned another rate-limited hardware key. Writing first means a
+ // refusal costs one request and changes nothing else: the rejected
+ // key is still there, and the terminal flag below stops it being
+ // attested again in this process.
+ instance.recoverySpentInMemory = true;
+ if (!SecureStorage.getInstance().set(KEY_RECOVERY_SPENT, "1")) {
+ instance.discardFailed = true;
+ instance.bootstrapInFlight = false;
+ fail(pending, "App Attest could not record that its one-time "
+ + "recovery had been used, so it was not attempted");
+ instance.failBootstrapWaiters("App Attest could not record that "
+ + "its one-time recovery had been used");
+ return;
+ }
try {
- instance.resetLocked();
+ // Keeps the marker written a moment ago: this reset IS the
+ // recovery, so clearing it here would let the replacement look
+ // like a first recovery all over again.
+ instance.resetLocked(true);
} catch (IllegalStateException e) {
// Nothing was discarded, so starting a replacement would leave
// two identities and the old one still usable. Fail the caller.
@@ -795,29 +830,6 @@ public static void nativeAttestError(final int requestId, final int errorCode,
"App Attest could not discard the rejected key");
return;
}
- // Recorded before the replacement starts, so a request arriving after
- // this process dies still sees the recovery as used. The in-memory
- // copy is set regardless: if the keychain refuses the write, the
- // one-shot limit still holds for the life of the process rather than
- // letting every later request burn another key.
- instance.recoverySpentInMemory = true;
- if (!SecureStorage.getInstance().set(KEY_RECOVERY_SPENT, "1")) {
- // No replacement without the marker. The in-memory copy only
- // covers this process, and the replacement's own start marker
- // DOES persist -- so after a restart the next launch would find a
- // discarded-looking key with no record that a recovery had been
- // spent, and start another one. That is a rate-limited hardware
- // key per launch, caused by the very write that was meant to
- // bound it. Failing here costs this caller one request, and the
- // device is left with no key at all, so a later attempt starts
- // cleanly rather than compounding.
- instance.bootstrapInFlight = false;
- fail(pending, "App Attest could not record that its one-time "
- + "recovery had been used, so it was not attempted");
- instance.failBootstrapWaiters("App Attest could not record that "
- + "its one-time recovery had been used");
- return;
- }
PendingRequest retry = new PendingRequest(pending.result, pending.nonce,
PendingRequest.OP_GENERATE_KEY, null);
retry.retried = true;
From 872d84faa6074478e29da436f851868f4c7a9003 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Fri, 31 Jul 2026 20:07:54 +0300
Subject: [PATCH 46/96] Escape control characters in the nonce instead of
erasing them
clientData is what the assertion signs over, and the server recomputes the same
hash from the challenge it issued. Replacing a control character with a space
rewrote the challenge on only one side of that comparison, so a nonce carrying
one produced an assertion the server could never verify -- and two different
challenges, "a\nb" and "a b", produced byte-identical clientData. The one-to-one
binding between a challenge and the bytes signed over it is the entire point of
the nonce.
Control characters now get JSON escapes: the short forms where they exist and
\u00XX otherwise, hand-rolled because String.format is not in the CLDC-era
subset and the serialization is part of the wire contract.
Verified against a harness carrying every code point below 0x80 through
clientData and back out with a JSON string reader: 132 nonces round-trip
exactly, a nonce shaped like ",\"k\":\" cannot forge the key id, and the
collision the old code produced is gone.
Also carries the per-transport reopen lock, which the last commit had as a
server-wide one: a restart over a DIFFERENT transport has nothing to serialize
against, and making it wait behind a superseded open that may never return
deadlocked the restart.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../src/com/codename1/mcp/MCPServer.java | 29 ++++++++--------
.../impl/ios/IOSDeviceIntegrity.java | 33 ++++++++++++++++++-
quality-report.md | 12 +++++++
3 files changed, 59 insertions(+), 15 deletions(-)
create mode 100644 quality-report.md
diff --git a/CodenameOne/src/com/codename1/mcp/MCPServer.java b/CodenameOne/src/com/codename1/mcp/MCPServer.java
index 467d6aa7720..03842bfcebd 100644
--- a/CodenameOne/src/com/codename1/mcp/MCPServer.java
+++ b/CodenameOne/src/com/codename1/mcp/MCPServer.java
@@ -218,12 +218,6 @@ private synchronized void releaseAndCloseIfCurrent(MCPTransport t, int generatio
}
}
- /// Held across `open()` so two generations cannot be inside one transport's open at
- /// the same time. Deliberately NOT the server monitor: `open()` blocks -- it binds a
- /// listener -- and holding the monitor across it would make `stop()` wait on the
- /// thing it is trying to stop.
- private final Object openLock = new Object();
-
private void runLoop(MCPTransport t, int generation) {
// Opening is deferred to this thread, so by the time it happens the server may
// already have been stopped or restarted. Either way stop()'s close() ran against a
@@ -235,14 +229,21 @@ private void runLoop(MCPTransport t, int generation) {
return;
}
try {
- // Serialized per server. A stop()/start() over the SAME transport while the
- // old reader is still parked inside open() would otherwise have both
- // generations open one instance: the same-transport rule then correctly
- // declines to close it on the way out, and the transport is left holding two
- // listeners with one handle for them -- so the first is leaked and outlives
- // stop(). The superseded thread finds itself stale the moment it gets the
- // lock, and unwinds without opening anything.
- synchronized (openLock) {
+ // Serialized per TRANSPORT, on that transport's own monitor. A stop()/start()
+ // over the SAME transport while the old reader is still parked inside open()
+ // would otherwise have both generations open one instance: the same-transport
+ // rule then correctly declines to close it on the way out, and the transport
+ // is left holding two listeners with one handle for them -- so the first is
+ // leaked and outlives stop(). The superseded thread finds itself stale the
+ // moment it gets the lock, and unwinds without opening anything.
+ //
+ // Per transport and not per server, which is what a server-wide lock got
+ // wrong: a restart over a DIFFERENT transport has nothing to serialize
+ // against, and making it wait behind a superseded open that may never return
+ // deadlocks the restart -- the replacement never opens, so nothing wakes the
+ // thread that would release it. Not the server monitor either: open() blocks,
+ // and holding that across it would make stop() wait on what it is stopping.
+ synchronized (t) {
if (!isCurrent(t, generation)) {
releaseAndCloseIfCurrent(t, generation);
return;
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index f79c695c38f..24cb7ebade6 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -544,14 +544,40 @@ private static String clientDataJson(String nonce, String keyId) {
return "{\"n\":\"" + escapeJson(nonce) + "\",\"k\":\"" + escapeJson(keyId) + "\"}";
}
+ /**
+ * JSON string escaping that is reversible.
+ *
+ * Control characters are escaped, not replaced. Substituting a space for them
+ * lost information: a nonce containing a newline and the same nonce containing a
+ * space produced identical clientData, so the assertion no longer bound the exact
+ * challenge the server issued -- and the server, recomputing the hash over what it
+ * sent, would not match it either. The one-to-one binding between a challenge and
+ * the bytes signed over it is the whole point of the nonce.
+ */
private static String escapeJson(String s) {
StringBuilder sb = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '"' || c == '\\') {
sb.append('\\').append(c);
+ } else if (c == '\n') {
+ sb.append("\\n");
+ } else if (c == '\r') {
+ sb.append("\\r");
+ } else if (c == '\t') {
+ sb.append("\\t");
+ } else if (c == '\b') {
+ sb.append("\\b");
+ } else if (c == '\f') {
+ sb.append("\\f");
} else if (c < 0x20) {
- sb.append(' ');
+ // Everything else below 0x20 has no short form and must be \\u-escaped.
+ // Hand-rolled rather than String.format, which the CLDC-era core does
+ // not have -- and the exact serialization is part of the wire contract,
+ // so it has to be predictable rather than locale-dependent.
+ sb.append("\\u00");
+ sb.append(HEX[(c >> 4) & 0xf]);
+ sb.append(HEX[c & 0xf]);
} else {
sb.append(c);
}
@@ -559,6 +585,11 @@ private static String escapeJson(String s) {
return sb.toString();
}
+ private static final char[] HEX = {
+ '0', '1', '2', '3', '4', '5', '6', '7',
+ '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
+ };
+
// ---- Callbacks invoked from native code (do not rename) ----------------
/**
diff --git a/quality-report.md b/quality-report.md
new file mode 100644
index 00000000000..67aebc851ce
--- /dev/null
+++ b/quality-report.md
@@ -0,0 +1,12 @@
+## ✅ Continuous Quality Report
+
+### Test & Coverage
+- ✅ **Tests:** 2984 total, 0 failed, 0 skipped
+- ⚠️ Coverage report not generated.
+
+### Static Analysis
+- ✅ SpotBugs: no findings (report was not generated by the build).
+- ✅ **PMD:** 0 findings (no issues)
+- ⚠️ Checkstyle report not generated.
+
+_Generated automatically by the PR CI workflow._
From 037e319747feb2e37651285f39fed51555e7d183 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Sat, 1 Aug 2026 22:24:36 +0700
Subject: [PATCH 47/96] Do not run the native callbacks while retaining them
The static initializer invoked each App Attest callback with no-op values to
keep the ParparVM dead-code eliminator from stripping them. It ran their real
bodies, during class initialization, before the static fields declared below the
block were assigned -- static initializers run in textual order -- so take()
synchronized on a null REQUESTS map and threw.
The class initializes on the EDT, via getCompromiseReasons(). So the NPE reached
Display's EDT handler, which shows a modal error dialog: HelloCodenameOne hung
at launch on a dialog nobody could dismiss, and every iOS and Mac screenshot job
timed out having delivered zero screenshots. The app log carries the whole
story:
Exception: java.lang.NullPointerException - null
at com_codename1_impl_ios_IOSDeviceIntegrity.take:982
at com_codename1_impl_ios_IOSDeviceIntegrity.nativeKeyGenerated:627
at com_codename1_impl_ios_IOSImplementation.deviceIntegrity:4599
A reference the optimizer can see is all that was ever needed, so the calls now
sit behind a branch that is never taken. The flag is a plain non-final boolean
because javac deletes the body of an if on a constant false, which would take
the very references this exists to keep.
IOSBiometrics has the identical shape and the identical latent failure -- its
block also precedes its REQUESTS field, so touching biometrics on iOS would have
failed class initialization the same way. Fixed alongside, since it is the same
two lines and this branch is what proved the pattern bites. IOSBluetooth already
placed its block after the fields and says why; IOSCarPlayCallbacks and
IOSSurfaceCallbacks already use a guard flag.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/impl/ios/IOSBiometrics.java | 27 ++++++++++++---
.../impl/ios/IOSDeviceIntegrity.java | 33 ++++++++++++++++---
2 files changed, 50 insertions(+), 10 deletions(-)
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java
index 62ed7e02570..ebd5acf1641 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java
@@ -42,16 +42,33 @@
* 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.
+ * stripping these (no Java caller exists), the static initializer holds a
+ * reference to each behind a branch that is never taken.
*/
public final class IOSBiometrics extends Biometrics {
+ /**
+ * Never true. Exists so the call sites below are real call sites.
+ *
+ * Not {@code final}, and not a compile-time constant: javac deletes the body
+ * of an {@code if} on a constant false, which would take the very references
+ * this is here to keep.
+ */
+ private static boolean retainNativeCallbacks;
+
static {
// Prevents the iOS VM optimizer from eliding these callbacks.
- nativeAuthSuccess(-1);
- nativeAuthError(-1, 0, null);
+ //
+ // Guarded rather than invoked, which is what the FingerprintScanner cn1lib
+ // idiom this was copied from did. Invoking them for real ran the callback
+ // bodies during class initialization, before REQUESTS below was assigned --
+ // static initializers run in textual order -- so take() locked on a null map
+ // and the class failed to initialize. A reference the optimizer can see is
+ // all that was ever needed.
+ if (retainNativeCallbacks) {
+ nativeAuthSuccess(-1);
+ nativeAuthError(-1, 0, null);
+ }
}
// Map request id -> pending AsyncResource. Static because the native
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index 24cb7ebade6..d9b49d409f3 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -76,12 +76,35 @@
*/
final class IOSDeviceIntegrity {
+ /**
+ * Never true. Exists so the call sites below are real call sites.
+ *
+ * Not {@code final}, and not a compile-time constant: javac deletes the body of
+ * an {@code if} on a constant false, which would take the very references this is
+ * here to keep. A plain field is opaque to it, and the translator's elimination pass
+ * works on reachability in the bytecode rather than on whether the branch can be
+ * taken -- so the callbacks survive and nothing runs.
+ */
+ private static boolean retainNativeCallbacks;
+
static {
- // Prevents the iOS VM optimizer from eliding these native callbacks.
- nativeKeyGenerated(-1, null);
- nativeAttestationReady(-1, null);
- nativeAssertionReady(-1, null);
- nativeAttestError(-1, -1, null);
+ // Prevents the iOS VM optimizer from eliding these native callbacks: no Java
+ // caller exists, and without a reference they translate to empty stubs and the
+ // native dispatch silently does nothing.
+ //
+ // Guarded rather than invoked. Calling them for real ran the callback bodies
+ // during class initialization -- before the static fields below this block were
+ // assigned, since static initializers run in textual order -- so take() locked
+ // on a null REQUESTS map and threw. The class initializes on the EDT, so that
+ // NPE reached Display's EDT handler, which shows a modal error dialog: the app
+ // hung on a dialog nobody could dismiss the first time anything touched device
+ // integrity. A reference the optimizer can see is all that was ever needed.
+ if (retainNativeCallbacks) {
+ nativeKeyGenerated(-1, null);
+ nativeAttestationReady(-1, null);
+ nativeAssertionReady(-1, null);
+ nativeAttestError(-1, -1, null);
+ }
}
static final String TOKEN_PREFIX = "cn1aa1";
From f050250144a98db7c4921154d7195700cd488afb Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Sat, 1 Aug 2026 22:27:13 +0700
Subject: [PATCH 48/96] Use the guard-flag retention idiom rather than a
never-taken branch
Same fix, stronger guarantee. A branch on a field nothing ever assigns is
something an optimizer is entitled to fold away, taking the reference with it --
silently, leaving the native dispatch calling an empty stub, which is the exact
failure the retention exists to prevent. The call has to be unconditional.
So both classes now follow IOSCarPlayCallbacks and IOSSurfaceCallbacks: invoke
each callback for real, with a static flag set across the block that makes every
callback return before it touches anything.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/impl/ios/IOSBiometrics.java | 37 +++++------
.../impl/ios/IOSDeviceIntegrity.java | 63 ++++++++++++-------
2 files changed, 58 insertions(+), 42 deletions(-)
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java
index ebd5acf1641..7119bc73ae1 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java
@@ -42,33 +42,28 @@
* 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 holds a
- * reference to each behind a branch that is never taken.
+ * stripping these (no Java caller exists), the static initializer invokes each
+ * once; they return immediately while that is happening.
*/
public final class IOSBiometrics extends Biometrics {
/**
- * Never true. Exists so the call sites below are real call sites.
- *
- * Not {@code final}, and not a compile-time constant: javac deletes the body
- * of an {@code if} on a constant false, which would take the very references
- * this is here to keep.
+ * True only while the retention calls below are running, so each callback returns
+ * before it touches anything. The same idiom as {@code IOSSurfaceCallbacks}.
*/
- private static boolean retainNativeCallbacks;
+ private static boolean dceGuard;
static {
// Prevents the iOS VM optimizer from eliding these callbacks.
//
- // Guarded rather than invoked, which is what the FingerprintScanner cn1lib
- // idiom this was copied from did. Invoking them for real ran the callback
- // bodies during class initialization, before REQUESTS below was assigned --
- // static initializers run in textual order -- so take() locked on a null map
- // and the class failed to initialize. A reference the optimizer can see is
- // all that was ever needed.
- if (retainNativeCallbacks) {
- nativeAuthSuccess(-1);
- nativeAuthError(-1, 0, null);
- }
+ // The guard is what makes the calls harmless, and it was missing: they ran
+ // their real bodies during class initialization, before REQUESTS below was
+ // assigned -- static initializers run in textual order -- so take() locked on
+ // a null map and the class failed to initialize.
+ dceGuard = true;
+ nativeAuthSuccess(-1);
+ nativeAuthError(-1, 0, null);
+ dceGuard = false;
}
// Map request id -> pending AsyncResource. Static because the native
@@ -140,6 +135,9 @@ public boolean stopAuthentication() {
/** Called from native when the LAContext.evaluatePolicy succeeds. */
public static void nativeAuthSuccess(final int requestId) {
+ if (dceGuard) {
+ return;
+ }
final AsyncResource r = take(requestId);
if (r == null) {
return;
@@ -156,6 +154,9 @@ public void run() {
/** Called from native when evaluatePolicy fails or is cancelled. */
public static void nativeAuthError(final int requestId, final int errorCode, final String msg) {
+ if (dceGuard) {
+ return;
+ }
final AsyncResource r = take(requestId);
if (r == null) {
return;
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index d9b49d409f3..d6bed0d2783 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -69,42 +69,45 @@
* ParparVM note
*
* The native side dispatches results back through the static callbacks
- * below. As with {@link IOSBiometrics}, the static initializer invokes each with
- * no-op values so the dead-code eliminator does not strip them -- no Java caller
- * exists, and without a reachable reference they become empty stubs and the
- * native call silently does nothing.
+ * below. As with {@link IOSBiometrics}, the static initializer invokes each once
+ * so the dead-code eliminator does not strip them -- no Java caller exists, and
+ * without a reachable reference they become empty stubs and the native call
+ * silently does nothing. Each returns immediately while that is happening; see
+ * {@code dceGuard}.
*/
final class IOSDeviceIntegrity {
/**
- * Never true. Exists so the call sites below are real call sites.
+ * True only while the retention calls below are running, so each callback returns
+ * before it touches anything.
*
- * Not {@code final}, and not a compile-time constant: javac deletes the body of
- * an {@code if} on a constant false, which would take the very references this is
- * here to keep. A plain field is opaque to it, and the translator's elimination pass
- * works on reachability in the bytecode rather than on whether the branch can be
- * taken -- so the callbacks survive and nothing runs.
+ * The same idiom as {@code IOSCarPlayCallbacks} and {@code IOSSurfaceCallbacks},
+ * and it is deliberately not the more obvious {@code if (neverTrue) { call(); }}:
+ * the call has to be unconditional for the eliminator to be certain to keep it,
+ * whereas a branch on a field nothing ever assigns is something an optimizer is
+ * entitled to fold away -- taking the reference with it, silently, and leaving the
+ * native dispatch calling an empty stub.
*/
- private static boolean retainNativeCallbacks;
+ private static boolean dceGuard;
static {
// Prevents the iOS VM optimizer from eliding these native callbacks: no Java
// caller exists, and without a reference they translate to empty stubs and the
// native dispatch silently does nothing.
//
- // Guarded rather than invoked. Calling them for real ran the callback bodies
- // during class initialization -- before the static fields below this block were
- // assigned, since static initializers run in textual order -- so take() locked
- // on a null REQUESTS map and threw. The class initializes on the EDT, so that
- // NPE reached Display's EDT handler, which shows a modal error dialog: the app
- // hung on a dialog nobody could dismiss the first time anything touched device
- // integrity. A reference the optimizer can see is all that was ever needed.
- if (retainNativeCallbacks) {
- nativeKeyGenerated(-1, null);
- nativeAttestationReady(-1, null);
- nativeAssertionReady(-1, null);
- nativeAttestError(-1, -1, null);
- }
+ // The guard is what makes the calls harmless. Without it they ran their real
+ // bodies during class initialization -- before the static fields below this
+ // block were assigned, since static initializers run in textual order -- so
+ // take() synchronized on a null REQUESTS map and threw. This class initializes
+ // on the EDT, so that NPE reached Display's EDT handler, which shows a modal
+ // error dialog: the app hung at launch on a dialog nobody could dismiss, the
+ // first time anything asked about device integrity.
+ dceGuard = true;
+ nativeKeyGenerated(-1, null);
+ nativeAttestationReady(-1, null);
+ nativeAssertionReady(-1, null);
+ nativeAttestError(-1, -1, null);
+ dceGuard = false;
}
static final String TOKEN_PREFIX = "cn1aa1";
@@ -647,6 +650,9 @@ private static void failBootstrapAttempt(PendingRequest pending, String msg) {
/** Called from native once a fresh hardware key exists. */
public static void nativeKeyGenerated(final int requestId, final String keyId) {
+ if (dceGuard) {
+ return;
+ }
PendingRequest pending = take(requestId);
if (pending == null || instance == null) {
return;
@@ -692,6 +698,9 @@ public static void nativeKeyGenerated(final int requestId, final String keyId) {
/** Called from native with the attestation object for a newly attested key. */
public static void nativeAttestationReady(final int requestId, final String attestationB64) {
+ if (dceGuard) {
+ return;
+ }
PendingRequest pending = take(requestId);
if (pending == null) {
return;
@@ -784,6 +793,9 @@ public static void nativeAttestationReady(final int requestId, final String atte
/** Called from native with an assertion over an already attested key. */
public static void nativeAssertionReady(final int requestId, final String assertionB64) {
+ if (dceGuard) {
+ return;
+ }
PendingRequest pending = take(requestId);
if (pending == null) {
return;
@@ -828,6 +840,9 @@ public static void nativeAssertionReady(final int requestId, final String assert
/** Called from native when any step fails. errorCode is the raw DCError code. */
public static void nativeAttestError(final int requestId, final int errorCode,
final String msg) {
+ if (dceGuard) {
+ return;
+ }
PendingRequest pending = take(requestId);
if (pending == null) {
return;
From 2df7c846a5e410ba1117f9f7160d4eff85ca42da Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Sat, 1 Aug 2026 22:41:39 +0700
Subject: [PATCH 49/96] Close the restart race inside the transport lock, pin
runtime hosts, and narrow the jailbreak API
Four review findings.
The per-transport lock covered open() but not the teardown that follows it, so a
superseded thread released the monitor and only then decided what to tear down.
The replacement could acquire the monitor first and call open() while the stale
listener was still registered -- and a transport that refuses a second listener,
which is the fix for the earlier leak, answers that with an IOException whose
catch stops the server the restart had just started. The whole open now happens
inside the critical section, and a thread unwinding from its own open closes
that open unconditionally: releaseAndCloseIfCurrent's "leave it, the replacement
owns it" rule is right for the read loop and wrong here, because the replacement
is still blocked and has opened nothing.
The simulator built its pin set from the startup ShieldConfig alone, so a
backend registered later through AppShield.addProtectedHost() had no simulated
pin. Unpinned means PinSet.isEnforcedFor() is false, which means the certificate
check is skipped, which means "Force Pin Mismatch On Next Request" could not
reach it -- the one host an app went out of its way to register was the one host
whose failure path could not be rehearsed. AppShield now exposes every protected
host, config and runtime alike.
cn1ReadDerTlv compared off + *totalLen against len, and that sum can wrap for a
large off -- the check meant to stop an overread would have been what allowed
it. Compared against len - off instead, which cannot wrap because off <= len is
the function's precondition. Verified with a standalone harness: 23,396,355
cases over every small buffer, offset and two-byte header, plus the wrapped-off
case, with zero out-of-range accepts.
isJailbrokenDevice() returned true for any compromise reason, and a clean device
with Xcode attached reports `traced` -> `debugger`. That made a plain debug
session look like a jailbreak to a specifically documented API. It now looks for
the jailbreak reason only; aggregation stays isDeviceCompromised()'s job.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../src/com/codename1/mcp/MCPServer.java | 113 ++++++++++------
.../codename1/security/shield/AppShield.java | 30 +++++
.../impl/javase/JavaSEShieldEngine.java | 21 ++-
.../nativeSources/NetworkConnectionImpl.m | 7 +-
.../codename1/impl/ios/IOSImplementation.java | 22 +++-
.../mcp/MCPLoopbackTransportOpenTest.java | 121 ++++++++++++++++++
.../impl/javase/JavaSEShieldEngineTest.java | 24 ++++
7 files changed, 295 insertions(+), 43 deletions(-)
diff --git a/CodenameOne/src/com/codename1/mcp/MCPServer.java b/CodenameOne/src/com/codename1/mcp/MCPServer.java
index 03842bfcebd..0f39727bed6 100644
--- a/CodenameOne/src/com/codename1/mcp/MCPServer.java
+++ b/CodenameOne/src/com/codename1/mcp/MCPServer.java
@@ -218,6 +218,30 @@ private synchronized void releaseAndCloseIfCurrent(MCPTransport t, int generatio
}
}
+ /// Unwinds an open this thread performed and must not keep: clears `running` when the
+ /// server is still on this run, and closes `t` either way.
+ ///
+ /// Unconditionally, which is the one thing it does differently from
+ /// `releaseAndCloseIfCurrent`, and the difference is the whole reason it exists. That
+ /// method's same-transport rule -- leave `t` alone, the replacement owns it -- is
+ /// right for a thread unwinding from the read loop, where the replacement has already
+ /// opened the transport and closing it would kill a live server. It is wrong here:
+ /// this runs inside the transport's monitor, so the replacement is still blocked and
+ /// has opened nothing, and the listener registered on `t` is this thread's alone.
+ /// Declining to close it left the replacement's own `open()` refused with "already
+ /// listening" -- and that IOException stopped the server the restart had just brought
+ /// up.
+ ///
+ /// Only ever called while holding `t`'s monitor, which is what makes "the replacement
+ /// cannot have opened yet" true rather than merely likely.
+ private synchronized void discardOwnOpen(MCPTransport t, int generation) {
+ if (startGeneration == generation
+ && transport == t) { // NOPMD identity: is this still our transport?
+ running = false;
+ }
+ t.close();
+ }
+
private void runLoop(MCPTransport t, int generation) {
// Opening is deferred to this thread, so by the time it happens the server may
// already have been stopped or restarted. Either way stop()'s close() ran against a
@@ -228,48 +252,59 @@ private void runLoop(MCPTransport t, int generation) {
if (!isCurrent(t, generation)) {
return;
}
- try {
- // Serialized per TRANSPORT, on that transport's own monitor. A stop()/start()
- // over the SAME transport while the old reader is still parked inside open()
- // would otherwise have both generations open one instance: the same-transport
- // rule then correctly declines to close it on the way out, and the transport
- // is left holding two listeners with one handle for them -- so the first is
- // leaked and outlives stop(). The superseded thread finds itself stale the
- // moment it gets the lock, and unwinds without opening anything.
- //
- // Per transport and not per server, which is what a server-wide lock got
- // wrong: a restart over a DIFFERENT transport has nothing to serialize
- // against, and making it wait behind a superseded open that may never return
- // deadlocks the restart -- the replacement never opens, so nothing wakes the
- // thread that would release it. Not the server monitor either: open() blocks,
- // and holding that across it would make stop() wait on what it is stopping.
- synchronized (t) {
- if (!isCurrent(t, generation)) {
- releaseAndCloseIfCurrent(t, generation);
- return;
- }
- t.open();
+ // Serialized per TRANSPORT, on that transport's own monitor, and the WHOLE
+ // open -- the attempt, its failure path, and the teardown of a superseded one --
+ // happens inside. A stop()/start() over the SAME transport while the old reader
+ // is still parked in open() would otherwise have both generations open one
+ // instance: the same-transport rule then correctly declines to close it on the
+ // way out, and the transport is left holding two listeners with one handle for
+ // them, so the first is leaked and outlives stop().
+ //
+ // The teardown has to be in here too, not after. Releasing the monitor first let
+ // the replacement acquire it and call open() while this thread's now-stale
+ // listener was still registered -- and the transport refuses a second listener,
+ // so the replacement took an IOException and stopped the server it had just
+ // started. Holding the monitor until the stale one is actually closed is what
+ // makes "the replacement may open" mean what it says.
+ //
+ // Per transport and not per server, which is what a server-wide lock got wrong:
+ // a restart over a DIFFERENT transport has nothing to serialize against, and
+ // making it wait behind a superseded open that may never return deadlocks the
+ // restart -- the replacement never opens, so nothing wakes the thread that would
+ // release it. Not the server monitor either: open() blocks, and holding that
+ // across it would make stop() wait on what it is stopping. The server monitor is
+ // taken inside this one (by isCurrent and releaseAndCloseIfCurrent) and never the
+ // other way round -- stop() closes through the transport's own internal lock, not
+ // its monitor -- so the nesting has one direction only.
+ synchronized (t) {
+ if (!isCurrent(t, generation)) {
+ releaseAndCloseIfCurrent(t, generation);
+ return;
}
- } catch (IOException ex) {
- // The transport failed to start listening. Log defensively: the CN1 Log
- // routes through the platform implementation, which may not be registered
- // yet when the server is auto-started early in Display.init(), and a raw
- // NullPointerException here would silently kill the reader thread.
try {
- Log.e(ex);
- } catch (Throwable logErr) {
- System.err.println("[cn1.mcp] transport open failed: " + ex);
+ t.open();
+ } catch (IOException ex) {
+ // The transport failed to start listening. Log defensively: the CN1 Log
+ // routes through the platform implementation, which may not be registered
+ // yet when the server is auto-started early in Display.init(), and a raw
+ // NullPointerException here would silently kill the reader thread.
+ try {
+ Log.e(ex);
+ } catch (Throwable logErr) {
+ System.err.println("[cn1.mcp] transport open failed: " + ex);
+ }
+ // A failed open can still have registered something -- the loopback
+ // transport claims the process-wide slot before it binds -- so this
+ // unwinds the same way a successful one does.
+ discardOwnOpen(t, generation);
+ return;
+ }
+ if (!isCurrent(t, generation)) {
+ // Same window, the far side of it: the server moved on while open() was
+ // in flight. The listener now on `t` is this thread's and has to go.
+ discardOwnOpen(t, generation);
+ return;
}
- releaseAndCloseIfCurrent(t, generation);
- return;
- }
- if (!isCurrent(t, generation)) {
- // Same window, the far side of it: the server moved on while open() was in
- // flight. Undo the registration this thread just took -- unless the server
- // moved on by restarting over this very transport, in which case it is not
- // ours to undo.
- releaseAndCloseIfCurrent(t, generation);
- return;
}
while (isCurrent(t, generation)) {
String line;
diff --git a/CodenameOne/src/com/codename1/security/shield/AppShield.java b/CodenameOne/src/com/codename1/security/shield/AppShield.java
index ce318fecf8a..015376d12f3 100644
--- a/CodenameOne/src/com/codename1/security/shield/AppShield.java
+++ b/CodenameOne/src/com/codename1/security/shield/AppShield.java
@@ -30,6 +30,7 @@
import com.codename1.security.shield.spi.ShieldEngineRegistry;
import com.codename1.ui.Display;
import com.codename1.util.AsyncResource;
+import java.util.Enumeration;
import java.util.Hashtable;
import java.util.Vector;
@@ -435,6 +436,35 @@ public static void addProtectedHost(String host) {
addProtectedHost(host, null);
}
+ /// Every host currently protected: the ones [ShieldConfig] carried into
+ /// [#init(ShieldConfig)], plus anything registered since through
+ /// [#addProtectedHost(String, HostPolicy)].
+ ///
+ /// The config alone is not the answer to "what is protected", and treating it as such
+ /// is a quiet way to lose the runtime registrations. An engine building a pin set from
+ /// the config would leave a backend discovered at runtime with no pins, so
+ /// [PinSet#isEnforcedFor] returns false for it and the certificate check is skipped
+ /// entirely -- the host the app went out of its way to register is the one host not
+ /// pinned.
+ public static Enumeration protectedHosts() {
+ Vector all = new Vector();
+ Enumeration configured = getConfig().protectedHosts();
+ while (configured.hasMoreElements()) {
+ Object host = configured.nextElement();
+ if (host != null && !all.contains(host)) {
+ all.addElement(host);
+ }
+ }
+ Enumeration runtime = runtimeHosts.keys();
+ while (runtime.hasMoreElements()) {
+ Object host = runtime.nextElement();
+ if (host != null && !all.contains(host)) {
+ all.addElement(host);
+ }
+ }
+ return all.elements();
+ }
+
private static HostPolicy implicitPolicy() {
FailureMode mode = getConfig().getDefaultFailureMode();
if (mode == FailureMode.OPEN) {
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java
index 9aa755491de..fc9950de88b 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java
@@ -189,11 +189,28 @@ public PinSet getPinSet() {
// switch would still have nothing to act on: PinSet.isEnforcedFor() is false for
// a host with no pins, and ShieldNetworkGuard checks that before asking.
Hashtable hostToPins = new Hashtable();
+ // Both sources, not just the ShieldConfig handed to initialize(). That config is a
+ // snapshot of what the app declared up front and never learns about
+ // AppShield.addProtectedHost() -- so a backend discovered at runtime, which is
+ // exactly the case that call exists for, had no simulated pin, hence no
+ // enforcement, hence no way for "Force Pin Mismatch On Next Request" to reach it.
+ // The one pin-failure path a developer cannot produce any other way was the one
+ // the simulator could not produce either.
+ //
+ // The config is still read directly rather than only through AppShield, because
+ // an engine can be initialized with a config that AppShield was never handed --
+ // which is how the tests drive it, and losing that would trade one blind spot
+ // for another.
+ Vector sources = new Vector();
if (config != null) {
- java.util.Enumeration hosts = config.protectedHosts();
+ sources.addElement(config.protectedHosts());
+ }
+ sources.addElement(AppShield.protectedHosts());
+ for (int i = 0; i < sources.size(); i++) {
+ java.util.Enumeration hosts = (java.util.Enumeration) sources.elementAt(i);
while (hosts.hasMoreElements()) {
String host = (String) hosts.nextElement();
- if (host == null) {
+ if (host == null || hostToPins.containsKey(host)) {
continue;
}
// Wildcards included. PinSet.isEnforcedFor() resolves "api.example.com"
diff --git a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
index 58c3ffb6855..8b533ab89d9 100644
--- a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
+++ b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m
@@ -271,7 +271,12 @@ static BOOL cn1ReadDerTlv(const uint8_t* buf, NSUInteger len, NSUInteger off,
}
*totalLen = *headerLen + contentLen;
}
- return off + *totalLen <= len;
+ // Against what is left, not by forming off + *totalLen. That sum can wrap
+ // NSUInteger for a large off, and a wrapped sum compares small -- so the check
+ // that exists to stop an overread would be the thing that let it through. The
+ // subtraction cannot wrap: off <= len is this function's precondition and is
+ // re-established on the short-form path above.
+ return off <= len && *totalLen <= len - off;
}
/**
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
index 3dc1f0979ed..c9265d25d63 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
@@ -12573,9 +12573,29 @@ public boolean isPolygon() {
}
}
+ /**
+ * The documented jailbreak/root check, and only that.
+ *
+ * Deliberately not "any compromise reason". {@code getCompromiseReasons()} also
+ * reports a debugger, and a clean device with Xcode attached emits {@code traced} --
+ * so a plain debug session made this return true. That is a specifically documented
+ * jailbreak API with callers that branch on it, and telling them a developer's own
+ * device is jailbroken every time they hit Run is a regression, not extra vigilance.
+ * Aggregating the rest is {@link #isDeviceCompromised()}'s job.
+ *
+ * Hooking is left out for the same reason it is reported separately: a hooking
+ * framework is evidence of instrumentation, which usually accompanies a jailbreak but
+ * is not one, and callers that want the broader question have the broader method.
+ */
@Override
public boolean isJailbrokenDevice() {
- return getCompromiseReasons().length > 0;
+ String[] reasons = getCompromiseReasons();
+ for (int i = 0; i < reasons.length; i++) {
+ if ("jailbreak".equals(reasons[i])) {
+ return true;
+ }
+ }
+ return false;
}
/**
diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java
index 1f52ad1c3e3..da0a044006e 100644
--- a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java
@@ -314,6 +314,127 @@ void aSupersededReaderMustNotCloseATransportTheRestartIsNowServing() throws Exce
transport.releaseFirst(2);
}
+ /// A transport that refuses a second listener, as the real one does, and whose first
+ /// open can be held open on demand.
+ private static final class SingleListenerTransport implements MCPTransport {
+ private final Object gate = new Object();
+ private boolean listening;
+ private boolean holdNextOpen = true;
+ private boolean released;
+ private boolean openEntered;
+ volatile int openCount;
+ volatile int closeCount;
+ volatile IOException openFailure;
+
+ void awaitOpenEntered() throws InterruptedException {
+ synchronized (gate) {
+ while (!openEntered) {
+ gate.wait(5000);
+ }
+ }
+ }
+
+ void releaseOpen() {
+ synchronized (gate) {
+ released = true;
+ gate.notifyAll();
+ }
+ }
+
+ public void open() throws IOException {
+ boolean hold;
+ synchronized (gate) {
+ openEntered = true;
+ hold = holdNextOpen;
+ holdNextOpen = false;
+ gate.notifyAll();
+ while (hold && !released) {
+ try {
+ gate.wait(5000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("interrupted");
+ }
+ }
+ // The refusal the real transport makes: one listener per instance, and a
+ // second is an error rather than a silent overwrite.
+ if (listening) {
+ IOException failure = new IOException("This MCP transport is already "
+ + "listening");
+ openFailure = failure;
+ throw failure;
+ }
+ listening = true;
+ openCount++;
+ gate.notifyAll();
+ }
+ }
+
+ public String readMessage() throws IOException {
+ synchronized (gate) {
+ while (listening) {
+ try {
+ gate.wait(5000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("interrupted");
+ }
+ }
+ }
+ return null;
+ }
+
+ public void writeMessage(String message) throws IOException {
+ }
+
+ public void close() {
+ synchronized (gate) {
+ listening = false;
+ closeCount++;
+ gate.notifyAll();
+ }
+ }
+ }
+
+ /// The restart must not be refused by the listener the superseded thread opened.
+ ///
+ /// Holding the transport's monitor only across `open()` was not enough. The
+ /// superseded thread released it and then decided what to tear down, so the
+ /// replacement could acquire the monitor first and call `open()` while the stale
+ /// listener was still registered -- and a transport that refuses a second listener
+ /// (which is the fix for the earlier leak) answers that with an IOException, whose
+ /// catch stops the server the restart had just brought up. A restart that reports
+ /// success and is dead is worse than the leak it replaced.
+ @Test
+ void aRestartIsNotRefusedByTheListenerTheSupersededThreadOpened() throws Exception {
+ MCPServer server = new MCPServer();
+ SingleListenerTransport transport = new SingleListenerTransport();
+
+ // Generation 1 is parked inside open().
+ server.start(transport);
+ transport.awaitOpenEntered();
+
+ // Restart over the SAME instance while it is still in there.
+ server.stop();
+ server.start(transport);
+
+ // Now let the first open finish. It registers a listener, discovers it is stale,
+ // and has to close it before the replacement is allowed to open.
+ transport.releaseOpen();
+
+ for (int i = 0; i < 500 && transport.openCount < 2; i++) {
+ Thread.sleep(10);
+ }
+ assertNull(transport.openFailure,
+ "the replacement's open() must not be refused by the stale listener");
+ assertEquals(2, transport.openCount,
+ "the replacement should have opened the transport for itself");
+ assertTrue(server.isRunning(),
+ "the restarted server must survive the superseded thread unwinding");
+
+ server.stop();
+ }
+
@Test
void stoppingWhileTheReaderThreadIsStillOpeningStillClosesTheTransport() throws Exception {
// start() hands the transport to a reader thread, so stop() can run before that
diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
index 44cb36135b2..1a84567c9bf 100644
--- a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
+++ b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
@@ -22,6 +22,7 @@
*/
package com.codename1.impl.javase;
+import com.codename1.security.shield.AppShield;
import com.codename1.security.shield.PinSet;
import com.codename1.security.shield.ShieldConfig;
import com.codename1.security.shield.ShieldException;
@@ -125,6 +126,29 @@ void registeredHostsAreEnforcedSoTheMismatchHasSomethingToActOn() {
"but the apex is not covered by its own wildcard");
}
+ /// A backend registered after init has to be enforced too.
+ ///
+ /// AppShield.addProtectedHost() exists for a host discovered at runtime, and a pin set
+ /// built only from the startup ShieldConfig never sees it. The consequence is not
+ /// cosmetic: ShieldNetworkGuard asks PinSet.isEnforcedFor() before it asks
+ /// verifyPins(), so an unpinned host skips the certificate check entirely and "Force
+ /// Pin Mismatch On Next Request" cannot reach it. The one host the app went out of its
+ /// way to register was the one host whose failure path could not be rehearsed.
+ @Test
+ void aHostRegisteredAtRuntimeIsEnforcedToo() {
+ String host = "discovered.example.com";
+ assertFalse(engine.getPinSet().isEnforcedFor(host),
+ "the fixture must start with this host unregistered, or this proves nothing");
+
+ AppShield.addProtectedHost(host);
+ assertTrue(engine.getPinSet().isEnforcedFor(host),
+ "a runtime registration must reach the simulated pin set");
+
+ JavaSEShield.forcePinMismatch = true;
+ assertFalse(engine.verifyPins(host, new String[0], new String[0]),
+ "and the force switch has to be able to fail it");
+ }
+
@Test
void failingThePinFetchYieldsNoEnforcementRatherThanAMismatch() {
// Pinning fails OPEN on unavailability everywhere in this design; the simulator
From d9e14cb13a999a6233c03fec29c1127138b0bb8e Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Sat, 1 Aug 2026 23:00:19 +0700
Subject: [PATCH 50/96] Keep the App Attest identity readable when a deletion
fails, and always run the Cydia probe
Three findings, all about a partial failure looking like a clean one.
resetLocked deleted KEY_ID and KEY_STATE unconditionally, so a keychain that
refused the first and accepted the second left an already-attested key behind
with no state. The in-memory terminal flag covers that for the current process
and does not survive a restart -- and on the next launch requestToken() reads a
key with no state as freshly generated and submits it to Apple. That is a
rate-limited attestation of a key Apple has already attested, once per launch,
indefinitely. The state now goes only after the identifier is confirmed gone, so
a surviving key stays readable as what it is.
The explicit reset cleared recoverySpentInMemory whether or not the persisted
marker actually went, and reported success. A later invalidKey then found the
recovery already spent and refused the one replacement it is allowed -- which is
exactly what resetAttestation() was called to restore. The flag now follows what
the keychain did, and a failed removal is reported as a failed reset.
Deliberately not the terminal discardFailed state: the identity is gone and
requests can proceed, so refusing every request over a marker would be a larger
outage than the thing it is reacting to.
getCompromiseReasons() ran the Cydia probe only when the native probes found
nothing, and those two sets of evidence are independent -- the native side reads
hard-coded paths and dyld, the scheme catches installs those miss. So a single
unrelated signal suppressed it, and a debugger alone was enough: `traced` made
the list non-empty, the probe was skipped, and a jailbreak only Cydia can see
went unreported. A developer running under Xcode was being told their
jailbroken device was clean.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/ios/IOSDeviceIntegrity.java | 36 +++++++++++++++++--
.../codename1/impl/ios/IOSImplementation.java | 19 +++++-----
2 files changed, 43 insertions(+), 12 deletions(-)
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
index d6bed0d2783..f750f3d74f7 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSDeviceIntegrity.java
@@ -283,7 +283,15 @@ private void resetLocked(boolean keepSpentMarker) {
// asked us to discard -- and keeps asserting with it. Better to leave the state
// visibly unchanged so the caller fails and can retry.
boolean idGone = store.remove(KEY_ID);
- boolean stateGone = store.remove(KEY_STATE);
+ // The state goes only once the identifier is confirmed gone, and the ordering is
+ // the whole point. Deleted unconditionally, a keychain that refused KEY_ID and
+ // accepted KEY_STATE left an already-attested key behind with no state at all --
+ // and the in-memory terminal flag that covers this does not survive a restart.
+ // On the next launch requestToken() reads a key with no state as freshly
+ // generated and submits it to Apple, which is a rate-limited attestation of a
+ // key Apple has already attested, once per launch, forever. Leaving the state in
+ // place keeps the surviving key readable as what it actually is.
+ boolean stateGone = idGone && store.remove(KEY_STATE);
store.remove(KEY_RETRY_AFTER);
store.remove(KEY_PENDING_SINCE);
// The markers that make a surviving key terminal are cleared only once the key
@@ -293,15 +301,37 @@ private void resetLocked(boolean keepSpentMarker) {
// whose attestation never began, and submits to Apple. Every request after it
// does the same, against a rate limit, with the one-shot recovery marker also
// gone so nothing stops the loop.
+ boolean markerGone = true;
if (idGone && stateGone) {
store.remove(KEY_ATTEST_STARTED);
attestAnsweredForKey = null;
discardFailed = false;
if (!keepSpentMarker) {
- store.remove(KEY_RECOVERY_SPENT);
- recoverySpentInMemory = false;
+ // Checked, and the in-memory flag follows what the keychain actually
+ // did. Clearing it regardless reported a successful reset while the
+ // persisted marker survived -- so the next invalidKey found the recovery
+ // already spent and refused the one replacement it is allowed, which is
+ // precisely what resetAttestation() was called to restore. A reset that
+ // does not restore the thing it promises has to say so.
+ markerGone = store.remove(KEY_RECOVERY_SPENT);
+ recoverySpentInMemory = !markerGone;
}
}
+ if (idGone && stateGone && !markerGone) {
+ // The identity is gone, so requests can proceed and a fresh key will be
+ // generated -- deliberately NOT the terminal discardFailed state, which
+ // would refuse every request over a marker. What is wrong is narrower: the
+ // one-shot recovery still reads as spent, which is the documented behaviour
+ // of that marker rather than a new failure. The caller is told the reset
+ // did not do everything it was asked to, and can retry it.
+ generation++;
+ bootstrapInFlight = false;
+ failBootstrapWaiters("App Attest could not clear its recovery marker");
+ throw new IllegalStateException("App Attest discarded its stored key but "
+ + "could not clear the spent-recovery marker; the keychain refused "
+ + "the deletion, so a replacement key would still be refused its "
+ + "one recovery attempt");
+ }
if (!idGone || !stateGone) {
// The identity survives in part, and there may be nothing left to make it
// terminal: a successfully attested key has no start marker and no spent
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
index c9265d25d63..3c3abc95d05 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
@@ -12624,15 +12624,6 @@ public String[] getCompromiseReasons() {
String[] signals = deviceIntegrity().jailbreakSignals();
java.util.ArrayList out = new java.util.ArrayList();
boolean jailbreakReported = false;
- if (signals.length == 0) {
- // The native probes found nothing, but an app that declares the
- // cydia scheme can still detect one this way. Dropping it here would
- // regress isDeviceCompromised() for exactly those apps.
- if (cydiaProbe()) {
- out.add("jailbreak");
- }
- return out.toArray(new String[out.size()]);
- }
for (int i = 0; i < signals.length; i++) {
String s = signals[i];
if ("hookLib".equals(s) || "dyldInsert".equals(s)) {
@@ -12648,6 +12639,16 @@ public String[] getCompromiseReasons() {
out.add("jailbreak");
}
}
+ // Always, not only when the native probes found nothing. Those two sets of
+ // evidence are independent: the native side checks hard-coded paths and dyld,
+ // and the cydia scheme catches installs those miss -- so gating one on the other
+ // being empty meant a single unrelated signal suppressed it. A debugger alone
+ // was enough: `traced` made the list non-empty, the probe was skipped, and a
+ // jailbreak only cydia could see went unreported. Which is how a developer
+ // running under Xcode ends up being told their jailbroken device is clean.
+ if (!jailbreakReported && cydiaProbe()) {
+ out.add("jailbreak");
+ }
return out.toArray(new String[out.size()]);
}
From ae7e497dea2e7c4a74b2bb1c051c8d9bb2eaff5d Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Sat, 1 Aug 2026 23:43:46 +0700
Subject: [PATCH 51/96] Merge master, and deliver a late-attached health
listener on the EDT
The merge brings in HealthEdtDeliveryTest, which fails intermittently under this
branch's CI. It is not this branch's bug but it is this branch's red, and the
mechanism is worth having fixed.
AsyncResource.ready runs its callback immediately, on the registering thread,
when the resource has already settled. EdtResult's guarantee therefore covered
only listeners attached BEFORE completion -- and the facade actions that answer
without a backend (openHealthSettings, openProviderSetup) complete before they
return, so a caller cannot attach in time. Which thread the callback ran on came
down to whether the EDT had drained the completion hop yet: on the EDT on an idle
machine, off it on a loaded one. On the CI runner that test ran directly after a
class that took 944 seconds.
EdtResult.ready now marshals a late registration onto the EDT.
Deliberately not the same for except. Reading the error out of an already-failed
resource by registering a callback and looking at what it captured is an
established idiom here -- HealthFallbackTest.errorOf and BtTestUtil both do it,
and it depends on that call being synchronous; marshalling it broke fifteen
tests. The asymmetry is the honest one: this contract exists so a callback that
acts on a VALUE is on the EDT, and introspecting a failure that already happened
is not that.
aListenerAttachedAfterCompletionStillArrivesOnTheEdt settles the resource and
waits for the hop before attaching, so it takes the late path every time rather
than by luck. Confirmed it fails with the override removed. Full core suite:
4725 tests green.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/impl/health/EdtResult.java | 38 +++++++++++++++
.../health/HealthEdtDeliveryTest.java | 46 +++++++++++++++++++
2 files changed, 84 insertions(+)
diff --git a/CodenameOne/src/com/codename1/impl/health/EdtResult.java b/CodenameOne/src/com/codename1/impl/health/EdtResult.java
index 780a1d2c79e..9649d57df15 100644
--- a/CodenameOne/src/com/codename1/impl/health/EdtResult.java
+++ b/CodenameOne/src/com/codename1/impl/health/EdtResult.java
@@ -22,6 +22,9 @@
*/
package com.codename1.impl.health;
+import com.codename1.util.AsyncResource;
+import com.codename1.util.SuccessCallback;
+import com.codename1.util.EasyThread;
import com.codename1.ui.Display;
/// The resource every public health operation hands back: one outcome,
@@ -47,6 +50,41 @@
/// completes another resource does not queue a runnable per link.
public final class EdtResult extends OneShot {
+ /// Late registration is delivered on the EDT too.
+ ///
+ /// `AsyncResource.ready` runs the callback immediately, on the registering thread,
+ /// when the resource has already settled -- so the guarantee this class exists to
+ /// make held only for listeners attached before completion. Every facade action that
+ /// answers without a backend (`openHealthSettings`, `openProviderSetup`) completes
+ /// the resource before returning it, so the caller CANNOT attach in time, and which
+ /// thread the callback ran on came down to whether the EDT had drained the hop yet.
+ /// Off the EDT on a busy machine, on it on an idle one -- a callback that is usually
+ /// on the EDT is exactly the thing the class doc calls not-a-design.
+ ///
+ /// Deliberately NOT done for `except`. Reading the error out of an already-failed
+ /// resource by registering a callback and looking at what it captured is an
+ /// established idiom here -- `HealthFallbackTest.errorOf` and `BtTestUtil` both do
+ /// it, and it depends on that call being synchronous. The asymmetry is the honest
+ /// one: this contract exists so a callback that acts on a VALUE -- updates a label,
+ /// touches a form -- is on the EDT, and introspecting a failure that has already
+ /// happened is not that.
+ @Override
+ public AsyncResource ready(SuccessCallback callback, EasyThread t) {
+ if (isDone() && !isCancelled() && Display.isInitialized()
+ && !Display.getInstance().isEdt()) {
+ final SuccessCallback target = callback;
+ final EasyThread thread = t;
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ EdtResult.super.ready(target, thread);
+ }
+ });
+ return this;
+ }
+ return super.ready(callback, t);
+ }
+
@Override
public void complete(T value) {
if (Display.getInstance().isEdt()) {
diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java
index c51fc2e35d6..1c0b5230da7 100644
--- a/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java
@@ -320,6 +320,52 @@ public void run() {
}, true);
}
+ /// A listener attached AFTER the result settled still arrives on the EDT.
+ ///
+ /// This is the same guarantee as the tests above, at the one moment it used not to
+ /// hold. `AsyncResource.ready` invokes the callback immediately, on the registering
+ /// thread, when the resource is already done -- so EdtResult's contract covered only
+ /// listeners attached before completion. The facade actions complete before
+ /// returning, so a caller cannot attach in time, and which thread the callback ran
+ /// on came down to whether the EDT had drained the hop yet: on the EDT on an idle
+ /// machine, off it on a loaded one. `aFacadeActionDeliversOnTheEdt` therefore passed
+ /// or failed by timing. This one settles the resource and waits for the hop to drain
+ /// before attaching, so the late path is the only one it can take.
+ @Test
+ void aListenerAttachedAfterCompletionStillArrivesOnTheEdt() {
+ final Landing landing = new Landing();
+ CN.invokeAndBlock(new Runnable() {
+ public void run() {
+ assertFalse(CN.isEdt(), "the operation must start off the EDT");
+ AsyncResource settled = Health.getInstance().openHealthSettings();
+ // Wait for the completion hop itself, so the registration below is
+ // unambiguously late rather than merely probably late.
+ long deadline = System.currentTimeMillis() + 10_000L;
+ while (!settled.isDone() && System.currentTimeMillis() < deadline) {
+ try {
+ Thread.sleep(10L);
+ } catch (InterruptedException ex) {
+ return;
+ }
+ }
+ assertTrue(settled.isDone(), "the fixture needs a settled resource");
+ settled.onResult(landing);
+ deadline = System.currentTimeMillis() + 10_000L;
+ while (!landing.arrived.get() && System.currentTimeMillis() < deadline) {
+ try {
+ Thread.sleep(10L);
+ } catch (InterruptedException ex) {
+ return;
+ }
+ }
+ }
+ });
+ assertTrue(landing.arrived.get(), "the callback must arrive");
+ assertTrue(landing.onEdt.get(),
+ "a result must arrive on the EDT even when the listener was attached "
+ + "after it settled");
+ }
+
@Test
void aDeleteDeliversOnTheEdt() {
final FakeHealthStore store = new FakeHealthStore();
From 04de358fa5268c3f733a85a74af15d69befc849f Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Sat, 1 Aug 2026 23:56:42 +0700
Subject: [PATCH 52/96] Wait for the component-inspector details panel before
capturing, and adopt the populated reference
javase-simulator-tests passed on the commit before the master merge and failed on
the merge, with a diff confined to the lower "Component Details" panel: the new
capture shows its ten field labels, the stored reference shows it empty.
That panel populates a moment after the inspector opens and nothing was waiting
for it. The warmup is a fixed floor, and the two existing readiness predicates
only ask whether the desktop and the device window have painted -- so the capture
landed on either side of the population depending on how loaded the runner was.
The suite passed and failed on successive runs of identical code, which is worse
than a wrong expectation, because it teaches everyone to re-run it.
So the harness now polls for the panel, in the same shape as the device-window
predicate beside it, and the reference is updated to the populated state, which
is what the test was always meant to be photographing. The threshold is measured
rather than guessed: 3712 dark pixels in that region populated, 1512 empty -- the
empty state is not blank, it keeps the tab label, the borders and the overflow
button, which is why a plain is-it-flat check does not separate them.
Nothing here is App Shield's; it is master's harness, surfaced by merging master.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../lib/SimulatorWindowModeVerifier.java | 51 +++++++++++++++++-
.../javase-single-component-inspector.png | Bin 55098 -> 61490 bytes
2 files changed, 50 insertions(+), 1 deletion(-)
diff --git a/scripts/javase/lib/SimulatorWindowModeVerifier.java b/scripts/javase/lib/SimulatorWindowModeVerifier.java
index bf8370ec1b5..7ccb56b55d1 100644
--- a/scripts/javase/lib/SimulatorWindowModeVerifier.java
+++ b/scripts/javase/lib/SimulatorWindowModeVerifier.java
@@ -123,7 +123,8 @@ public static void main(String[] args) {
// real failure).
BufferedImage image = captureDesktop();
Instant renderDeadline = Instant.now().plusSeconds(30);
- while ((isBlankOrFlat(image) || isSingleWindowDeviceMissing(parsed, image))
+ while ((isBlankOrFlat(image) || isSingleWindowDeviceMissing(parsed, image)
+ || isComponentInspectorDetailsEmpty(parsed, image))
&& Instant.now().isBefore(renderDeadline)) {
Thread.sleep(500);
image = captureDesktop();
@@ -197,6 +198,54 @@ private static boolean isBlankOrFlat(BufferedImage image) {
return sampleColorCount(image) < 3;
}
+ /**
+ * True while the component inspector's lower "Component Details" panel is still
+ * blank.
+ *
+ * That panel populates a moment after the inspector opens, and nothing was
+ * waiting for it -- the warmup is a fixed floor and the existing predicates only
+ * ask whether the desktop and the device window have painted. So the capture landed
+ * on either side of the population depending on how loaded the runner was, and the
+ * stored reference happens to hold the empty one. A test that photographs a race
+ * reports its own timing rather than the product: this suite passed and failed on
+ * successive runs of identical code, which is worse than a wrong expectation because
+ * it teaches everyone to re-run it.
+ *
+ * Same shape as the device-window predicate above: count dark pixels in the
+ * region the panel occupies, and keep polling until its field labels are there. The
+ * outer loop has a deadline, so a panel that genuinely never populates still fails
+ * on the assertion that follows rather than hanging.
+ */
+ private static boolean isComponentInspectorDetailsEmpty(Args args, BufferedImage image) {
+ if (!"component-inspector".equals(args.scenario)) {
+ return false;
+ }
+ int xMax = Math.min(image.getWidth(), 700);
+ int yMin = Math.min(image.getHeight(), 660);
+ int yMax = Math.min(image.getHeight(), 960);
+ if (xMax <= 0 || yMax <= yMin) {
+ return false;
+ }
+ int darkPixels = 0;
+ for (int y = yMin; y < yMax; y++) {
+ for (int x = 0; x < xMax; x++) {
+ int rgb = image.getRGB(x, y);
+ int r = (rgb >> 16) & 0xff;
+ int g = (rgb >> 8) & 0xff;
+ int b = rgb & 0xff;
+ if (r < 90 && g < 90 && b < 90) {
+ darkPixels++;
+ }
+ }
+ }
+ // Measured on the two real captures rather than guessed: the populated panel
+ // gives 3712 dark pixels in this region, the empty one 1512 -- the empty state is
+ // not blank, it still carries the tab label, the borders and the overflow button,
+ // which is why a naive "is it flat" test does not separate them. The midpoint is
+ // clear of both by a wide margin.
+ return darkPixels < 2500;
+ }
+
private static boolean isSingleWindowDeviceMissing(Args args, BufferedImage image) {
int darkPixels = countSingleWindowDevicePixels(args, image);
return darkPixels >= 0 && darkPixels < minimumSingleWindowDevicePixels(args);
diff --git a/scripts/javase/screenshots/javase-single-component-inspector.png b/scripts/javase/screenshots/javase-single-component-inspector.png
index 11b57b43dcdf030df61a88d6d1e1b9a7dca575ad..a19089a3756e55dd7e719f46a8e2d3e353ee896b 100644
GIT binary patch
literal 61490
zcmb5WcR&-_7dFhU1=j-WA|Qx>4HTq_2$8mkhz$^_QWeBVm(XieRF)n^1SP;KDjlQ-
z2t}m@)F2|A5QRXb_fE)nCzAl~Z}Tee
z+}YEY+1R$kvaxOQ+VU&-8(SeO9yYeyZ0Am^>3dlWlYHMew3-Wf#V%_Yh%JQP6-(Q{
z&-97gA=g91f}>lOW%$j9OmYREycN-SeJpjkmYn+Z`{pe&QbAScMAQWzY&&LNo1iQ>g}NWoY{m
z5@&qVQp=cc$AXf@S1d0BG<_*XV+
z_7A&0e4sGW`EugKj~^*mwr>uLf!Kyz?S;tA&<}`~jcsC&_NWmPwxHN%U#b!owhuj~
z--Q0@+j4Y#X7gNM#tu}(lX|fs0WXZ}8&3zc$0WuR^~bj`a37tVwfnTUoA6J?DG|;+
z{d8C=!v4oW!hrsAspIvY6t9lpYFe5opL2SV##J5Yvh|N|uJ;T&N;u;4xwo);+G8VP
zPx}bXmUb|*I@3Vs@~(9I+j|E&Q7Z3F_Nnqit5QH~Sf?-UMR3__Vb4eBwNfQGQE8m?
zc2OHScS7ibhk^S$V{8glFVjjzu4T%~LMmjz!(L0qKp!=s)Vz4wrEPg<-%RNrxL(}_
z50^*5)qf_h2ve3L{
zO-?y7y4g-TSS5o=(kXkFnG`#hE%GL1vRs{+;Z};|5!F3!9D^ed<6F3%ri~zoPyVKay+7gQztef1}Y}Lt1i7fzW>gk$I!v%
zp3};ScHFfiR`QlgEvB`GoDSTfi$(DswVQhNyLR^t)t*05u@b@XgIb&F9xJJB~d%GZBVzMy$yNYeNd0n6Yn@5T&!CPo8IJm)OH-TAlq<_)S88%@!f$QQN9uZ6%
zgkP?pGJhWGri?_aFGELs>^FH_q!rmWjlU8;i8(FrPcqG5ivTK3wrMCdeC7Ojo
z+~LVY@T)b8IR;{_a6Zp39wEljmqaCh{7!+^C4k9Y_}ZA*GkkI>jLji!YSZjdHKH`Q
z8Lx7+cb+gi_{q1wca0=pf1z5f?KaDo1pZiUxNSz==J4@`NEldO_5H~g$qJem?h1h4
z-rn#CmuNyu{n}C&6=)Ts-6BqmGCrDka0a2BFr=a_mL
zfKc#<<7`XV;tM;p-LQQ4%s;Pm+DqAm?{#-|4B-llvYK1g9O7VJMQ97*uU^r&n?9JD
z;7pBvk-)pH{dr%#mEDsX6>Fc;i5%XB%A!5;Y=n4qjO$umXVN00UF6AGlJ<_ZsW~dv-WWY4?&>q?0f9=oY;hg-7_P_;
z@OSZzHXi8Qu5@^WD{>rI%+t?#k!;pubT->Jrq`*Z<@JFPr1|kPz%-05ffEdD|$Vnb_W~EcL(Z04HCoQ
zy9HI#lVZ@2+;cZL?ynDD3Yn{=kW!wL60LA7XY&{mPHrO|CbTybl4AU~Gnd9^s+(ko
z4j~N=%l`T%mB`zcw-KQ}zx|6E2FlKF6O=k1*H^9V!Z))bKql^~v}ACoCpKD50i_YCZ{S#PXnjEG5Bbk*`vEyR}Y|->1wgM|seF2F^eDYGdB-pE-hx
z_jB2o(qKXhzl*`&Fw4RZ{)qaHLj2-Czr%=H|U;V(8;XDss8
zEyF!}qJEkjY*Q^lB@{Jck<58DZKc5;4tS5(fGwL>^mTu^&DSPL;Cmmf3-2wUkCgHi
z^Q>*tZSUQAA-{RQ(G^g1id56~9^Z5jcCph}tOu)J&^&9|q&aJO@v2vnxhdPfE21J|
zUa4E)b<*dY=D_iTKS4JOWZd_U>0}bw-U=M$36;v=3dGvIh`Nh)BiLImIc&{!UH9X|
zV`n7nXsIuL75KZ?(!hE)X}MyQ?(6%wLI2alp|Po)HSLoJHz5}8?lmd%
z#EUYQq!D#^HhI$goAh3Kn##)P`NWRnNxki=
z7IvEum4iskYhls7lO6t>5xrW&EnYWZ)
z`BE4;PeFoJ(PvB(9lMhj%`~hHb-hN81uqO=-j1pXZ=7iYw|vAQ)iQiOfD8Y{r}W%v
z4(Z-!a<5wtsTii!=XmUg{MP^-b~WOrhj+26%O9yfEp#yZOU^EbFX;U7o)XsOM7
z9@dT+S2s8PCns=GQMWFo?dY3k+&PaE@Y|kQMC70=w*fl(LDSVaaX~AlN{#hb)m*X*dKF-1(IDQ`S*(xT
zqDGwG)fep$aG^OW3}?`_pZBgX^%TBHDATaiNg&j|#-^#0D!pbC9m#6om*^g8z-2JO
zk8P56i6T@Es#bQywVi@Yy%#E8zAba_h;K{eC-P8UjuiO@e(1ceS+Fc3<&^33=!LO)
z<-0#9W;cP|D;xZrLUnhD7OHffX!2e^3O6Ux>P)=#XgpNQ#P)?u?P`
zP4+$2xf6!OKPY~EMge`1w6@5yxw0|;IXszG(pon9#MJ4C5q2M8Vc}IS|IdrMQop39
zE^)g~T)8u|en+|}p)R>*q*Nt`)N#0QinNk9A&_?Bag)wtVe0rRVXC^&(nXao*{1qO
z(;sk6IQa56<97`$IN2`=oS7*ONvk+tY5Qg(_|@&t@9Qpe{m
zcF+l_ELEe9@>VuAK)B}IrrrY{a6yf@q_Q|*m9cU;K-WxfhiDqIv-a>9c|iFiE|kr|C@o-iAuoQyAChz*Z!o
zmFofnxTC>&wMqQi`NYDh#u94{WwN7VVkPt36n30YDbxqKah&hv5yH8ki!YD%ZJNJO
zPSK&Is?OM0&f3^6b(W8GMzs13;R1#RA4Pwd?ua8ePB+?3Oo&v+L(K9U;*y8kDYRY6
zCTZ_iNW4MuwfIAXx}hXHWp2}CF^|@kboqCP&>ejfqUSNL^OrGGbpLN$%{hK|ltN~X
zr+u%gYWGkynafO};xk8Fcdy}q(9wds*nLa*AQ{%e6kMl1$yd-uoY4)w-BW#*D}B}a
z0S^k~A9fxl1cUG$-42qxNQ&&mH0k^|AL?I^ndd
zbHT~wftQ1ea6_=2I)ZlkHx!5ER#d=}zYy6+{e7%^w6UXv|?P@h@;T*H+CVfZ+B2x9#0dBf*W58Wj-l{DR$6d$c;X?aa?dL
z>M9Z;hj$iF{62Z2z2~?BV(>51FCJ!ztyAZ_1Y@{ELsN4uA0>Dq(08%hBsL)`X1XIC
zWq^@fCLG6TZHTbt>U~!fxp7nV7Mg4R-U$4CdwaA?*IN812lR*r^mxriXZzV8avmpM
z8_^0ou>;iuVA+@9+6&_RCv#Mz$8yCrFdO1GB6v-J0ea0VA$<2U^Bcm`-yU8UQcD+_
z0iGqQJ%f_;>H5nSw{^;9GM)YVpCZerFI41Xjq4lU%!;7)_~)AdXZ3c5PC@}~qbWL_
zoXh8N24s?Tn6ey0Uul#$Gt)d@G(S|aq?@$v*fpQ5!T&MX#yE1o(yI$G^@FL)+wGnA
z<8(1xLhEr2f{=9C;RhhU=bx5qQFW;0fb}oWK*ZYig@41D1lqt@IPqQx?;YV>j;+GD`#G9njESl$Yuvm!C}j
zS~`tf`IzY46xTi7FJH0Hu+pw-N|fkB8vAk;hpU)KsC2(9m4`Nlt;kK{TGVUbe(Uec
z7;AwQg%#Bq*NTNZ(AjwLZS()kFq+Agv*K-spM>L3HNwbD=nSm3W!8$r9BY@2Ch`b_
z%mw}MncsNAIsh=V@@4u;Ud6&M3`^MA>^ssn{}xb=9^Fu)8+Dn7*^L_NhqAd)c7WBnd=f`~lQYvei
zbt_!5X*7NgrltDvvh_cYz7H;ax;{bTj$F7VB;V*$%H(&t5PSiCcl+s0Xr*4{%i;t+
zNHpSTcdWnMR;!gS$XeUuz45O~RSQmTU~~Okf9mp^3Ewql@z2{n^mMhdK8xA#_UK;A
zNsG=~N;BP1oPMuYfH;AhSNYYTnz**i!BzhL;@qw!y#-0OkCm|o`en#ee#%Sh+7Sr-
zUYZ>=#a|c@dEo9Dunhq3DpRwGISj=YTWoM|C<)(cy9uE_q6k4kmm^_~`4;|R`{+i^_9aaVx#lGsj&^?v~X!$-fK8S~1
z_M?4t%IwCIxZqmjMd_Ys{>xnWJqQ!A^rbm7k|Y~1!768dY)BuR?{JYaw8i6i(RDoj
zM|=6#RE?3b2)&cX_<1k>u;f!Vs{Qp=ph9_DnQOcusDaB5BrDDT`Ump~%{5*Y?Wp=dDPblxugB}A?&J1M-tee2
zLF#Y|?ukKih;?o1AYVvzvo+@xowH4On{Nk9IP<<>aV_G^xORQ0+-9J}fKXDn*f9G_
zo12!J|M1Jh#-giMxB_M6X0h4CeS*H;J?1k#7szXQ_&|f_%Une6R2@TI(5gVClgfaq+sgI)dZIid6^fg0dlRHQ
zBr=7O<3O~7a5WBgUj|aJp7j{MiUvH5abj$5w^v=T0IL8&1`~y0qwetYJ5qzq*BWa7@-T0mjzUt6bS=X-5p-awxz3_3;i7R1Zli6>Xa8o$(s
zOwC})cjAXwPe^!k5QO<*mfy~~SW(Yn@?@duu;$j5-BurrZ$g}Z
z_NjH(P&z(oz=mr`DxauwfGuv&@6O0?7t((6>F6@-=S2QfRgXRl_2ydnm*b;S`F^ZD
z`X$gWu3Y}I71a}+K6UCuLUKvDNf;cjQ@XRLC%~^S4V^LQ`%p8|(-urxS$A>lr_)fy
zxvKj_>W%2^C~*3ESgUW+r(E}IuM_?2b|6~9hf_+?0GaL7nPWRCVf0Vp_I=bN90N*~
zzmHN(;1pHKZHjQgMJMf=8FVTzCXFOMyTjTP=_JUBNDR|elX7D~I#*ZJJTll!>Ulf(
zti{9KPLL&PK?*A7(4i(ZZFlQuurqSoNI}_q$CZp@{6p7-hN*Yw=9vcrZ)hi0)_
zP}V27AlNB5LEytFN?psGaubBwOW;rcfw5iyxf(VHTth58(peYu9+CMwSh^T+qE$-3^#o*A!Dx1b?cA`n
zDD}uFHGc@+PGIz$OB$qNvB~}pJMAr-((=U!Y+h%b_vI&ghMmkr0PyhDgRfN(3$W^B
zP;MWBnzl!Ft1(ko)@vt#I^JP3+l?2Zl$)^FL=+~R3uV?CDZ_nCeWW)vIZ$5lfY2Vr@~Uta1db{aj+{6N`*HsP
z=os6hSw9;24FSi~@y!TRcIi;i^@||=(9aa6&L7k`v=h$bSqzVC->%riI(iw(Ux4Ai
z8C67a$P&&u|B-eBv3BH8Pz`=mv!6-kxWvK^o_(4IP{!Z|$x{z|(08LHQ*ku&77`ts
zKj@8-C2i;kmQ5(08YkNLu8OY{j~G~n?)g0snol@;M7NoA1Wgr3+p?TV@qqx>>IU!2@DAie3L_=pwEJDm7x7$GF5`mAq=i9w
z253-LJ7iqsO%IB!F1q?cMbMYX3c5o0lqjAQ0l8meOS?NPuB#DWxHxdaH}ZST@E^I0
zn-SIe<#C0fB+%DNpUL?H^gd_Cd)~Ipy1rtCmG7wt2*g8#sVPs2URtR%cUasLMn0Cf
z0`oU%Az%*=KcXOI`eL}1vfFeNftBs3^Zp4o^*jD5KAYZ+XilT*DMz<_kN=P$&N6x@7m67}>~axf
z2J|9Hm|Yor%cpfD^5gVYWCk;I#gRmJuI4!yO6?NWPHsk6uek6i
zB6LcOYIQWI%%)NnGnWsqu2YgpCxQeh%iUvWJD|dLh;Vz4%{WY|8c!(^=zbQ0SQFj1!F1rM0+uu9t4
zKE7kowWVz+R2(Z(7YP4CVXQHF2POAxk`6tC$!i-|VnGW;s~L^QH*Fw)fK*i@oGjhH
zUm5m@48s3VD9B~@yYP8V`PlpBfXM)B8P0nqwf&vU8?IUsDrPWnp5<24p7EDWZQE)N
zkfrB%#kt9z4#zd?Xk@Ay=(h#`JMwE_Ts_tzfkie~{SnVxH7mh_CW}?$3f|gdH7ch<
z%`bq0QKaGkHOvDg$b4+ZiPzc`hD@Er3SZuRaluwVJ*p4H2J$og0vHnNwjhKCLgRa9
z9RMYuewU`t?d>n?C~m2AXr>OH@hz1v0<9({i5IsHxmbDt>VDq#2d$daea#(Ocj^zD
z7Wgh`VQKxufCmtJ;`y?ny}8mh-mz)Xgf|8MWuH4Qa=^XgEwGP~iJV`O-X
zvs<tdSrn)!iapCWu-r_H=D{+iqx)}wF17wVFhT&|wlu-jRlERkVe>hbQL
ziwpIf$Y7G4Og2?d!)19afACJDm-xcn(%9Ci)Y8kOEIWyt%~8iYO`1wJ^m^!iw+RS9H|JSs$_^^_>(iTuvyNCGj4i;e$^5h#rF7X?dbh)3alZ8#18J1^44le@Lny
z4lQPA5-R$HTO=YRdZ2pg4X7!lYQoc|uTIJBA=T%%_}^@H%~anS{{fY0>fEfEn$S>a
z*dOuaH8d!*^Hu6R*{|^M?AK6Iu%h(W8>ZBAok@dtixhd1T+)o&I~1(T#E7#3781Ee
zphpa;%Q6JS$?#h>;Q`g&j~vQFnMjihihPZEeD7Bc9}kV!vGt%8Z1t+Z%c{L7E?qJ`
zOUVu^s(3SIQpK9OgWEnWD6D#L_+HDM38kDz7b0zr^MuMRc$A}#M$qF8m8JQe5AlUo
zKl4+xeA*g%KC%F3+%;WfepYXyV!=UoIMvA0v?sU4`$VP=?^NYB7qyPIEENe!r+AMS
z?-2??!bsz}y$DVG7StEcg(BK;2*=NGQ)xTpW>cMNB}N`e42f>(A=)(QHJ>-2=1PEQ
zOg^M?&(HJmU}pQ17E)}tL_QC0Fw=^z-ZuSl!6{uuHzVtc{fLC%riEPHv|4(9o<>fx
z1<5h^g9)`gU&Y*EuZo>STRvkkg(V+~8IA>-`9C8(z_92O5l?D-x;IM$eO&fwAyE{ntz&YypRZ%i8xJj)_btk)3CRl7
z>CqGg^_hDb{`wLTX+WQ!lI88Rwf4c$IjXEwI!~g@&6PVZrBj10CW={Ja!3xh0un(Q
z(aBS6)-GF(cxh?-m;D5U$&RZbqyn)1znBNfz|8^=JtMdLLKPW<;r?nfXcmZMmJu0>
zOBuT~C|lDRUTsF*`vECxSV%O?q*hyr#WY0NCn)#3rIo0R^ygV0YSHZF^J#fG@2PyKpNxg!p=`6O0*JZy-~bZj|1jn7NjaRz+LnknMX4|y|5$5hJ$Sl1(Iozt
z+X&-(4k96-_btf@#Wt6Y<3ab=I4xv<0t
z24n6w-`4}AJ1=qRRPlc1Sk6t>VxhByCpA~AUKzrXKVrgvjlB=s;1Wpk5C`)0=uPB~
z;abYCmT+<*oLPNRjw7Ra3)<7yK5F<3A0SMCaV(JEw;hFcaIZp9Qt@)9hyW5XdsM_}
zu01Hfk7BhxmgP(OrXSuZwb1sy3ZV=eYI8MC#4`f0NpP
z)Twa;GghPU96eFxB-5<-R6$8l@w{Hriv)>`UCExw-2OFg4oam4IwG&PGDv}m#_bKB
zV75tmVJ`xiA2r4FS!;OMWyih|!qO7ZOONRDW=kS;%@Mlbzj*NmO~}}kU6Y%*O#EwF
z_)cW<_T;|E_4wtjMx)OiiKEOC0VoRWaxh_*1u`5E5}rS~1_)0^SV-zhbbPd~j9kvK
z7^xwIdO=$%w9Q*hKKmra#hUPJyl00t6*46Uzy*95W@Gbx%#?r+@-(NuP;jcPHqFl9
zF%2?`)S3aacn?~>tH;_&gn~&mm$Ly$pbMI*Lw0j7ba!x!
z59hzWTo#)tIcmly?yXGhpHaYjXqDqjDDA)phMGVcAKJdBIaDsm&n4%zNkeG$uDADZ
z0gHW8WQa)$@@qmZkl|*dir5aWLsrlYete%AtqsGTA*gzoyq={;tcn)@&~5*>)&pwW
z0jxwWc0y^OZPyk@9~No94vE|hk(E_)#aRxUFx3hVZVDyH*-h+!2>#DHiw5BaLuv()
zLzxNJKE$vRgyOK&%J%V3xJG>+EwdSc{Ou|ayq~S!@?vr?MLdA`#M244^LA5q)tJ46
z$HH!NubZw~sLpx?G^i-@!=|lQCqbFixRa6}@d*br^QoMU*pmzimYK~>1
zNnz^W%$idKq?Q6kmG(cU`I=A#`$t>M|7|LqKME$?2QRD034YqN8v=}z?ct>4H!^(3bPq*e$Oz<(rk?s2fACgWmG2o-*FEBqZl6}|hu1W_
zq7P;xtz3q@58-^w%*_nJ{~8N`YCJ$EWK)i@{%b9%xP<$GxiAQToSW1%ZPWDwp16bc
zg)L}Lv+29NPVQoooo-@|+41!_sgMC-QM5vQr)`oLkte!Hn8y^?S;Fb!IilM<(xY@=
zVGpR-9J%God|hzABYioXGi^z@L5~iJ6KVMSc^}`aAy^b04i^s_Cae~ma7j;()6o)W
zMl=^69ThAGlCnE4&mx8R8hP{x^~vO>g=bc@qRup(u|lGm`R@iuozaQQ2;8nAjaNQH
znQ{g$h1Bs0CsFURHc+(KS$SA(<@gLphu6>D8oMpDzNF84vxxnj)lO7>YHpG5yvH8O
z#n2Bv#3qwGTYn$!4r)+g>_$aRJ7e{>HRp4y&CmDrNQ@f$J!z4Q&C;=#aBBX3
zbwu&Mbd~R*+bT5J&ixeBEy_z@&wQAS4sr%;W8!T3|Ia~8;U9xi(n@iZCoQkJsozBY
zp>uv=)0v#kG^2tWrtS*HKv07K$n~q2rnZH%qZGW2T}*QErsG(|7l+ZKdYL0FPEOd9
zexT=&R#h9#3ytmDXG*=ETs&-fDI>Kdw7%-@3x%?HUe|^85V=h7x6)a`(fS{8Q|*3Y
z@{%8TYg0>ALT2>qh8E7%__$oPNHjjdAJZ3F3!3|q_W(DWNC9LatxqyFR;Qql_X;M8
zZpCBY0@vdoHM_%2zAox%^hv)GF}k9BHQvAGeYH>6a9kpL3TOzeCX9atKHp1|l7+%S
zd#dJTZ+#Ew|K8vx3+tXdhx>$MQxY-opmDZADG>X{mlA=Jh?H@
z{FWiIEqq?13A}YslXYg{nQ0!YW&4kkp!X;=9{gT=TE3O7?JThQy8NoamS-#2bh6#D@n<|XTA%;x3A08QA8(zVJ05{_
zBAs3)KN~v}m!*o=5_*5nua8HwfTMj8VVmjDoK!Hk!|BiXfof)X#o~Jm^X&K_IB~ey
z+6KJVQ0t?6bg;?(##Kj*p8r>GnQYuHAk|#V2*w%cgHicsb~5Ay!;DnHFJe5nM@Fdh
z+USr?%`=&D-u`_eMj-v@+8*J_Fz1y2i#W~l2~fhUt64SMKR^>+m9|KP?LGHjSeUn%G-O2pL;8op!MbF?%D
z7}j;@t0I*ulR5-FU7Kxk;G1_kVw%3>l^5J-TAX$QsA^Q8tzfRSwZ^CXMpwe6qv?Xp
zCmm-za4EQaj-WkeNS~a4h|`Q-Tcxe3l7UZQGE+&@5G4e68f0>5^I9Kj|M5CXV)ReH
zulBqbTTM-ZDtBbbmN6$66tV$tGpQHxWj6bg;6CEDcHykqzovY0Zdj$AG&RRM*OIn5
zLMQ!)2*?Nd9$LE2@fGd~Mflm2K@XRNt`51J1hN~*+aV)B!xK!fj~64~1Tqwmks?ik
z-iBw$ma%k)G;YdE%h}Q|wn_~iL82^bw4?xrBkYVFm8l^<9iI$h`WQ-*%iO6r4%B5E
zeZa0<@R!UJKkx!Cn|X08=kLSX&eZBtGxv)9-W7=$UX~#fkyEMfi*gG@TJ#QOQ$_iP
zs_%IFTU~K7L*#7!`7l^64FQ*@j?>!{ZfI8t&m`pwu6^_a!v@rC!~c*L&QZ@9)x0!o
zgkSiO7J}QtPtEIn+f#L;wTPCi>yNEe;D14Pe8;ZMK4dIKU}gN<_dnok$g9pdz$_!+
z_FuutybGyG{?@YvOL6wad&YyGMeUhQ-b=g!mcYsk%$y@W_Wure^*<#L<`9eRcGOmD
z$uDrSc^ks))3SX$&Fl+-oVDwhk415cd@sFpI}@%YSVkf_b8KttpT)Z!%LRySGqs
zN1~V*oAid&G&uwd6>lx74Ta4?O!aaCv<{y=T9z5gm)3cb$)vDePpT+vFK5IB3
zCt4PI-EOVxg$%n@NI->5Iq+^-3$c4E`X@H9$v#|HX8u8;>?FQs33mTx?GZV`mH77C
zEa{bzHh$hSC>?+K_}cs8kkTGzQK0G2cX4=~aQCk%Z_NgP+UaeS?u9@|ss+4^HT2f&
z57=WbvOE^dK(Z#A^mkJ9$!DTMGP@4lknZ)UN1240c5Z`zkEQry8HGCA)y=Ep7C$%L
z{=pmmcjn^XD@)e+2lN)vnotsS0#0|Xn17kL7-gcb!9T3Qj4d!b@$-S)vH%n}|FPEW
z8hwF>jo0};&?WM&OWRuGE4i34{ck&FMK3f$vF7$)c2mOmKJr|ts7bO<6ObmZkLfVUIZ6Q7$K5z3ti=0<@l7&Oe@W4mfmZ*KvnEW98`
zh>F&C*dz___cdsNmOhttdik(KKT>5jmp$9OY-XW>J}E_?%wDd~UJiMt>Rs!*z4ugP
zZ{qS~t#a%If)XtCm$
zj$UaX%db3RbL9s7Is}oi8L{TNi*h+kt8to@(ciHP#d4rj#uGTJaSFR@H27*&->NGT
zyt}8Iiroexe8*@V;zoY?{2wjB;ADrXQ5h_4C~0evRTUSySY(TS8r1Dw=nPakv1Ar<1dSRF4w_u|bxzUzjJyOW7?eSa}`e
z3eIPaG`D|@aA%Ji=zbH2fa?4tx6Xob?zb0MW-0QabLpsnu&XJpeIrRI_FD*buJ@kx
z+#H((0TYT*?<9x9RnQWv}6ypWL3d`Bj3@ur+L(K>cJ>Wd<(d}YYe
zY&n;0N{_M6=KqMjA3Elo=$lw}1g8bwXQZRK)44*6=@X*<`5h!rOK^NyycP53SK@yC
z2+fK^PSqZWtv0NUU{P%_$nCU~qI7F!gNcUp}9sum&B3ta`?x6#y$sTds^HD8GTr5_qk
z9m!1BzyOSn>zB!#ayZRup`h)$c5|x{ts_+@zXSn30H)B)O3NrAp?y
zvn2wq++Iipckvps`iVN-ckss{D}yjFgC
zKz072#c~{Z$+LU@fqW$1dEu4iQu)}*JHK~TzlD28KIfx!xqXlTgF5jsmoZb{Xq+mu
zpRF5K`q}cpC_9L;d&0@jGG1DV--eT-^r2N>ONvxk{#@DWgm+v5@&0vuAQDaCBrk_n
zERLXO@DcJ6wMf;8T9+&C%as*#mGlYv%G2C=^zwMc@$WfcwIH6%)J1-e7z2az+?~{s32UTbW{R{
zGLf_hEP1|xKGR7b^z4hKPl{Je3>wi!Dkk=l>CP*C6*TBb9?<7Q$#nUZ79s!TJZHbk
zILn!M)$t%9+E>*DP1S_%cT++u*~v9Y0n3^eF(Il8N%X-DV=JF0YBam&8odfZPrh3v
z%WuxpkIYH`tV&B5^K;)xDIHV{_$G=TwxrEe%$BJx0w62a2?$X7KJhiZf+|PvskKBa
z&FIh@Bk2=D0a-rCu=X$*+v+saNv;j8X2qz6Bq{%CZYi|Jg!qP!=b*tcQzO>gNMmA&
z;L7JB;N9gdi(cXNL8_;Q+W_e9*-^7xoP#hLz=?E68A)9HTEK
zuAnQZik39-it)sz+`+i3D=YRZ-y4>L5*535yT(A^}|5p~c8R+6ZjT>Rw#l3#NW}(X-(LJ0xwF@rjFbwbkT>Po9F#
z^v~plTFd$A#FsYB!_W}3gEeLoa{v|pbvEPa;lb3#%=g`;Xe{fz~vSE83+IJ$DpLp+R<3KR_rR0PLZHR)`-hnJQnhaO-$^$EQF*7@p!r{74cML=Wd
zXtBpk2Up}))K#JtM$b$p$OH`Z+HKch&gdHN48(r@%qI_8D|gc8EEQ=t3-U@2)Jk)B
zLuPwoTE2K+s&uGYsvM=1;6({`_MVJh2TO8bSn&5hJX=@G8=deyN&5-sgfcyrMsUBO
z!uTSu9V0-T<3r3?392t4$O3#k2zV&;0(hyryAF8&_!i!Cmf8ADT1}-=zLe)MgRlhs
zV1y3psn@S#2cfT}8nDHW-be
zhbJ^IV|oFlFsuE%1?5yS(BJ9g`)-@1S8LgvpntD?_gt<$C%xKvA#@$|gKX8cJH)Ny
zoG^<(@20$bKrDL5j)+uUA%JMMC~>O=-&}Fek#O40YY#C%h>HS_1-3uCgM5pe#Hqaa
zDH7dO@5~`B1zL!9&gPKBd9R98ur~Jx2(ODZc&9@&!LLgZ6$X+LpnYylS~$S9Vx-WY
zK(Yi&44@s5@~3d?0D2Oe_a9yhkq2;>y9j~UbYTDSj%HKm_{EzAoTXIt99dv38v!~S
z=ZLUq*#T;TZ+Sm(ugM?kk)@Nr31x0}cQvB_++K5#SpX!>RsiAqqshOe6$MUI
z`TSmljuJ?|dMAo?7=*c+A7aa(s_+D?6)?y55M!-t%vtZ)K3Bp2F2etf3VRf{R8MobC?3pCp
zjR;zRr{`R_Fng{MFJ+@IV-9FM`P`D;#P={^skf
zqIA)=s=KUG!@p$DFiNt(>MBt$tXAz`6I*)BOm!;|S_^hI>_+&0Y95@FFUJODlL3Imz;
zk~tPK`EWM5r}8{w6SNg4cXtg;(lO{B85u`$otYLDOH&PsV;p2!E1E8~&Kl2zMGYV9
z7aB<4cnYqx{8sG&PDG9iAvU*lMveIXedxQtEAsLRiV(Y;YIbcysK>k8l5SsyT1Qd+hcnw~{(cRF!?^#wzHh
zeNTQt9g`*eR`(-~p+wzg*B{V{7qvW|wGq)<_hYuv?yf*I^8Pbn>cSK?h8K+|iHV?A
zTo!B5^hi1nS?bRlgLkpxS9~c|*{zvUD*FkkOC&D|Mk|S+*^wB+DXvBwXs>G@8w&}>
zj+Jv!Qk2BMvLh~B2p#dTxA*mTwI7cp)8}j3h5pw*dF60Pb?M<|gob*uS(O^`R{q=)
z(5)R&>q&PMP^I93LPso93GUkL1?J&uTvvKeiDe
z*05C$e7)&rfp&6Pv0PC`-L2)Nx+{JaY)@0Sq3$X(Mt|m;UeZ$M-~2(@uIbx5@j0nk
zzngn>2WpXK0j`S*?9JdJ_!4hiw6#TQ}GkNoCZf!JeP&T*>V?DtlY`vBL4
zlmnwW(V>^YS8u#Mk0*p(q@?&PFz=is80nqTmA;
zI!s8NUkz|wI2a-VvERo>mQ;x)@kDowHGmo@B$69%9+i~bzL{o`y|g$pJCCC^C3#NJ
ze!c!$lJMKy=tEdW|7L$4faW#u+LQUnL)k&M4iS`o4fkLm_J(V!Uw+5LyGEhpv9VDB
zVMSYv-a^%Ou-A<38Y*8t3RCr%%~XOWKLva-1htbLQQ6oHmhtR@QKaG0s3Jg$24)B(
zH-Q1ciCc6MsPN)9P!8=tz0YWDK2A6ih?yP_`~uzV7W|1f!a0UtfbV$tcwB~7satHA
zvICVh3kv&ulL~yXoR>TTv6J#=%Z<&5eSGHrpTWiGGGS&=zfyHi@M~1yK#4>(*5MBT
z{KuaH9WVHF7HI_}l6=&6W6TEc^lhm3$dQL!6&AR1NcobgE{5Zoa&SbaIw%@^+XSC9
zasBIv8}QC!S+|@qu5u4u0oQHp9-kgJ_s(Nm08m#idL!jGl$FilNtvNga5mWF$w(6X
zsuU&?Y>)qB-+nNho>WFAC-QJ1nnmU^^Chyr_NHCpq9~zPmM43hai(P;mw6(mJ-mFT
zB}<%dcLBJKl`Ap%4ORZxi244Mu#gKn&FnFFRcw63>)2p^XRBI-W_l_>tBr{8GuAFH
z*Q9@p4Lg{(e7}iOQpxB#_NO*%9$4sJmsDl>N5X3lfTI}$M+1e7QTB-w^6b79<^E_C
z3o`!$BPx^X2HMZL4u}yzYW?YSyE{zCB>%j*q0ZjAMpFES>!LeQVu`WWP7)TUb@2%^JsR~=pwZAvx{rNBL~+o
z8$y=!`d;XRuc&a=LilKxxfOC$J3tqeQ5NN9rj}!l&d$TM#o4Qtm<@g|TK>11M*6n`y1==oXFfD67kS2Lo%=#)Pu59FM_4+S7ZrW&&60{j(a9&We-T;pfV
z;HQMmc9$vIWj-V5B83$s9bR6
zGr4mvxn2W&z#(k5ChDOu1iUJQE9T|{ZXIP7p02LrvoRHsb7!JWHxueU3X2|0N{4xg
z;3YUjivFNH=0XT&z6J*%MA^lK(7;R$HB=<*Eejpv6_#~Z(=PIi6o7SFOFd?;B%F?T
z7Pu9V#AO=QQj79u_ZQ*h$zVP}%8UV49)y3b2mbOVIA}gK?Yu20fNYGM3UXMWS_PB`
zrv_US0}vgmu_dmzOJQGswi#jb`y;^s44mbUFq~bCaa`j!b8|}__G^M%2=-@UGVIpT
z*|`S-n1PtaZs-5ppO+Yc4|sZI-;E|g7^I0))P`OliA>(`oHhhLvK|3az&As0n8P(G
z1nu`}>|#qAI{z}Mg+4fO!taiR>3j5Y_Z
z0J_@W|BV_CzDVSsjrzcbc*^kW88z@d-VZ2P2wq58_3rleI?7e@X$bRxYv<;gDVaDT
zkryx<+M&Ic`BMCzuko=jEBb!MAf7m88v@X+4m@fQaX{W*6h>bAu7T_Hp0RVax2J(;
ztE+Kbm-z2+t_PDOB1ulT-bOn{E`-b+^W+KGvPD_Pa$hp3F+mw6jI_mY-3ZTm#(?Q>
zzf?2HaWYc0cjA`2%_=p`%F6G|e0S9mIB;d0B{AHQxwyc1-6F=5%3x#!v1N(kA_lua^!3c@o!H$
zIDCcngQa!EZ?#PLx7hwG5E~U$Ifur|#eoz`gpesN7z@A^kbQhMdT}P1lnUs@1%ArC
zD@o}fnQq=U@SPp-;UDojtmMVae6r;Ugey#LQ2@9_yBWyKa8Q4UOQf$%N&TnSgT$w;
z6ImOjn_eJB(B(4%p2Wjr0ZhsuRX33KOE}B!nzfQ40uH$BBXG_`Ds(6+v{_n|KLZGl
zA4~HewYN1jORY5kq|SR~*?T9&!C@L#*kbAEK%4BRuJI*xEew~q`olNNn7JE>yKJT#d0CpUF>cej!JW!)Rt5a$_XQ33dP-SZM*YSd2{4pY)P0IsWk
z%El`Q_PMx@#>U-b3UU(}=4@rHxirLmFnk3uRCOHEG~AMr?U9Re`r
zeTkQH&v)*pI)llA!_N33D=K6G^UeYZ__(3rlThK>(;OcI|Bp1kRx~I`Gphd)$Tv`J
z0(@ct)PWxOf;>PwW4HZ#kc*B*)bu`Ql_iZ{<@xzH_p|rc{##lC4h&8qsW;FAe~970
zJ5c=T>7WgjS8#}MXSsP3;(J5K0c5Yqbbsx7xVFW}CWJv{IwK7NEc0A
zy=TlqBmI^77
z!ImO9RnEuRvdSSNgd8W#kaLXFV9f7z&2
zujlK1E$`Hh1L%6hGqTe~{dW|Uh-2F)N)r{mnal~l#vG>+5dl!a0P1YnY#TLjiJTp?
zIra6rbvkyJNWA{CtH>??>8A&^9$9&L>tQ40IaO@e-UWDz?NyFks5^~T;h78E4WZD12%K_)GWO5W*i-Q+r59f0D&^p6#)z82v{&RT_rd^QuI3>
zd$iBh+aMSsNDf3-=d(thY^hAoJQ5K9@_eAaIjv9(Z)0O5MWIj(>8IxnDtwNGKnkxj
z_S@_16Ctr7(Kdp!>#f!SqRa5|;*j4z2c%;(DXywL&MH7%ob#=h=&%kTAr6$1mXqV?
zQjQCQPyuBH)Fe~Z=wwHcW<~PBwaWAdj~{3|xx0&Mm~1sv_3j!Z)UPz^I#*4gv6tc2K)H%KcLuu{MrIa2`RZjG(+2E
zpp~5ds9<39j-o{=2aKvDVg>h976=33cBfE_8yUDYwmN>2#j?U9)ugdurVz2jMn#Ai
z5*p4i%J_(~lCrO8glQ5kpF5wz6KP#l&!!owaVVLo3Tq%oAUt#rX9G+a)1CL+01LCX
zaP3@^#1ON?n6v8YwBuVj=KMWv`Cg=4b8@oelxvI7t(pOnd<3MvCgn{3mLjWiI0O5%
z3%Y54z@S~r@8ErK18Wn=%h3q;@043rRU6g!ELA@B;!A{;OGRFuF3(N2)FegoFU(f5ZUsVl@aH(r&L
z3=WGyy1u&vt)obCLr1Y;+iBj>AyqN*I>7u^t@xw&n$R0x4+lX>=X$1^BvZXu?%jQn
z^S0kvh`*!vMt&V_vKq8hP)G+}dSDT}3n0hg{h?i-98@E(XGiF&2I-&ObYvu_iW9+P
zlP3q=buW?3`{__Y^!0_uc`PhCj3Up?95T#xH@Cz(lR7%ZGEQ-0oJlL$r@P+PUwSxm
zi>V&XAgQ;sfVhD`AQVJbb8!eNg84A-D2hPZBw}4tO>CT;+QpDca`16rPY~n0a#81K
zt+TVL$0%t
z-?RaGOf2g*o&}IDlBx_nf-rJps#X1-UvbkQT5HBZb>*yFl;x3K-&<~vuENV+fO=DU
zq1_NPva`1%{C^VYVGuyT>n_<0PgHg|Ls1X7x~`zfG%dg@JVNc0R$;yWfxfzlt%pZj
zY?SjBq~6B}xebtA!y-{MxLbFcvgh=By6Ivu8%QvxOQhHgONlpC{*zi7?7!PRQ6|J#JVc1
zgAZp^K@qyV8;v1>swTai!}O*z2!Is0G*wfR4Upx<9LP?$TsP?=$!<06cHcN=@hzt}
z#Ezd&<7`o-U4}bg1UdUwm9LAA0PIl*(-|Ai9^S7L$k^6>mkZf^wx}m8z!$VMKu|j?
zTL{!eXwDywvg}|qIN6OBWwDIkl|@(A_tu>FL?jS+90_>0ze|3hovIN*>5wg0-hskh
zJaLyky2w9?B^v&q3^6uoGts>Xcy}KEfn`|Yb%)Ccqa+NRVl8^}b=yhD&eIzt`_6^>
zql+UCP5N=;Lp0!EZ`^3^1LM=2nj^%*c25GG4K}s9;~{Eupxr7I8L4WxN=i$!l%ecw^X1?FJ=?nQ@p9G3Gf5y9Ko%{l42(XLf`@MJ_>bap~Z3sn5j}ON@8bKl_!%
zba)2y*NaACeQ(cMH_!k=jez7_9klIsf!ocI
zc-n!KE-~M!9ze;*+nDnZlsr`;uh%CULYwd$*O`%%j89rjxp3MC+g1%h$2OZ;4FArbp0(^w-YsewGC@SUVkZB4i_H|qF
z<2us4BB@uzk|->LI|q;k3FmAT*W94fPviWRTLaS&b+gqi~m~#waWqe*lHi
zT_Lma(_cCT#sjo3P@Hz3S?n
zp&^y9#BEEk&Dv6wmGi_t`I9|BIG_XUGE!1f07)SpfzZP+JVl0X1}rm<_{)$@B{1PX
zcAehPal%J+bz*{y2(%$`#&Y?J2VwCP|X|}=%$_!m8_AdYLeJ|ZD#IRLvIa*0`HdMW@fN%?XT|oH%P>aeq`pR
zV2?M`PK=HQBH1UajwD2Ejk+j0{88HUYrJBLQiYSZLQoZlTJfG@I&niW!YrE)9pQo1
z(9KwY|D4ULwVyW&Z4
zHmc*ch!?lJ!{iKWM0RRYUMHNwNE3u+lV1w_XA39*U}OO*KMdmof^)Og$b$kjBm}z8
z$+c;qG{R`NdlDV3%D+neo$Tr0g_cf#Za{lSz%L)(7UVc_CM~nhZ0ZntSI`E@(Kqkn
zbO7%+*(D~Arf1Z~_N|uCLRl+-+3F2^J;YlQ9T~uK+Qllsd!;m>fgQY9QIpaHCWl7b
z_VMu(Ha1=^qdNb_d&C3lkL>LBXAYatkhIT(cDr_z?gL>GFr}PD5`cv~x0H2UXS%OO
z%>R4lUaU|Q7tNwV2Liyw<8b-dV>1@gU^CJ6^cx}|xOk)R={*<)r_<#SDl;xl0FkwsE5B6eJ8x*jfMtLv5q
zo6eA?n}$!BtT{tqp{(A6bNdQLsjuyw&1f>
z5t4t-Am-O(7yDdGw_;G1i-cggo22$cY`{n@;CPU1iVz0^`27nRtG69;i!2{&y2XXa
zC@C$9C@Ln;o#u#-!0j5OCpQ-vA9H<{Ueh}adM+%wf5IJ?HMCmy7{fM5z>-cH<>tsZEWdjWf8``Xb4{
z`;SN8k_TV#btplUkR+D4Yqe8OM2Q%_Fn7-Y70c!0fch@$3r4Nb)J0$y;9AT|B39Ep
z(`VX;T+m-pEryS-uD%1hpqM(dA&h}|380liU#=O(ry32J3zn+Geblbv@d
zW*`(q=-m8?3lOoZzQ!9Jl_@cm#BdcL11LwqPTps(DGAFaTo8ra1ZoU^E|nB1rYAHK
zt_?-?{g3CUZ)8+=21Sc``9jaH3n_>1l|AB6pWtP45#%WvVlfcE&44Y1s0Hu+x=PBC
z&b?c-T5vosA9D2`sDPmw0MztX)MhN9p3l?L&8UhyMEe`#s)zUKp0}l%c31|cJgcD2
z@&^bFYcpC!G*HUyg49e}RZ}xIw|lh=U-)6q>MktihC{8joiw;X?jph?BZ+k%nph!|
zbSOsMqodjR?)Mm(EGnkc%syw>7qm9q)ODalV_UCH7<+4C%FPl@Sos3Vrr%Su-huJg0r)ufk#*N$x1Y)MDL!bhSB8h1;Hcsc)f&vhA
zS!b94;Lu4?X`aT~(0LSbc5@3|h6mzXlas5F@|PR_rwhD#06R@~l95Gms;9P2<8=LH
z_)v{f3Bp7*Yy=m8al_7Z0|=5Jpg@~6fP9lk3YmM!OEL9n_fz?X7(->%`D@Z%Rni>Px1)hh)VAQynr1S2UM
z7vN!MT~mQCc9D05KHGQy4mDz9UjtLz?W_#@Tb8dhaAgCw%FY`kLE4M(a^A_cpm60<
z&sCZfi$t`Z!~ZzG(+WhM%a8U2DToP&Yt#2ppeLQ43;5|$cJ5$lcUESuzfJBppeKxk
zZN_|O7TL-XZ}s=6F-7cDxF%!BR-jAxT1Zt1&7y3srW=Wopr
z%%6=HYiNSho06as0a5A|ELbd~86+baxvtKg(SCFWY(m$I`HmLzfEZaG-h-(nqfy-_
z^rK$C7J&CANq65o9q&ERogkUr2TK#(=>3YyR43SF!}9{Db+1J0+#SQCuu01aWi
ztCC{?={X&J3MV(Z(FW45W()Ca*XAWA4(Gsw7@!i+`2wI6h&pe=jWJxNxdS3ouVMW5
zpk>;qW7RIhR~@ETXLAIXprnGP5YKvWU7b^yK(4D-QB#qUBrZOjGVz4UT}pVzU$GR3
zlaP)O+VbbpBpB5WZHpl>yp-lm_n{SFNKG7!3vW{b2mmKK2q}z)nrbD9|#Ao*Q-|+UML~5V?yxyCBshdyfAWDPW
zCmtobE8Hs3k()L-t|&-MPp^TvatIK}O|)>I12lk;u%#ul%4!Ls;fvW!{8`Um0(i9K
zypv!
zN1)I?ylokW;9NqCk?jW?2BAm&_niJTQm6CN6$d8BIs$kFEYo(0;T1#OzoP+F0E{Md
zXJ)#Od@^S+_u%4>AIEkh;(vJ%SQ!DJI*^j}t}|6}*Er(05DlNA2Q>NI!-U*WK{Ry^
z3=%=b0&4$=qO9-wq3`*-TObDrfvn|hBTN~rLd`M;FHE8a
z4GfU_&aAk{qz=Yru2;@T*I;jf{|1FslQNoGJpbOuoAC7ahF%*}jZ&*aWy@$6tqOR@
zQI}rB?p;Mv)=%m%XM&itdKuchFW2bjNpfOJMOQ7@4`DJ6+19}s-Ts+Wm%+Zrb%($!
zjkuYqZW-PP4thr#l3k;<=?E_i73f6rnqC~vzW3lhO#`Hh#8sGb)u|(+KJl;^5g3`Z
zg4Spo!ss!Ej_;!?t5NqRh%o=Q+eot=CJ|`&traom(P=|R`w=;@#G3w5ceH)=U-Wgn
zno8KA1Er-ppwn$z47Jy%)NjxX*VH(4W@nKFC&+Y1ONXP9DHK_kUZ
z2y3Z5tW+M&2r!Mi>(P#^K^%6}^35N<;m0%+m{mclJ*YUq$>HYeOihk=
z=hxRY)|&@ldp9e!QsA}j1weFks}WCQzz^IOlpZJEQtRrrL)#9GDPU=JnJdU++e#R>
z31io}o#L5nGcfQ1h9BB*fi+W*NgH4*G}x2wU6J#Mx49a;zq3jSzsVQ2>icZlj7_#+
z#5T+VCe{JBna?srRtDx$s`^Kr&F%Bt(+}df-d63B((Dvd?Hx=&F&1e8|2qw#f{5jlmk;J3MZu3yNQOI~Ic$Hw#q9jGnq`NCg}SkfGQI9I`%FmPFZ~TA(voB2dex
zBv2}Hb8+?rLPG_So4FaHDrCr*K*QdhKQ}KA;|`!D0?W8VN5jufO-c&k23Q;tfuJyu
zog62~DsJXh%T&
zKn7#y820114oq6Y;9V^`8*ye=C{jIxYUvfs?13Bw#=9W3A^xfQC#U-7%u{?E`!FRD
zOI+xB{YmI>8qBpLirky-(@!eJBZLri1Ch~
zQw4g#$iOR7Vj<;niW>|9Wm)fliWd?q2unsr+|O6x+)#PtoT;*o%C=QqyO2_%lD
zY92MmOtOKIto4FazP*L3;RN%Q~1Zh5xxSJtDNmTs%
z?{zJ#DJbKz>UIPbh;F$Eicru^^62Ui+l{>*n{qyD`@^z`MgxE
zf<;2Y9D}(zE?RV`0sPzmK*J0KMCXwS8<_p6(3ww@;tHO!GH)JTJRmJ@YJo4iYfoLjlkl7J1O4I%}g-{L>)9&s|0QzCn+u4I
zSXSTB?iQVF-oa_t!RC0e9{meH2~#C-XBkP*w#`hhX{xJxjx;S{pyEAx5SWGzCV0*1
zmWZvgTSC0Mw>(nqxUsQ0BvtpctTf9F0vH!RU4qix`tW<1HE&!n(F7g_Dls|92UOPx
zt)XpxC_flqU5}X?8bV=cgp~Zwa;sD2?jO|?a*u>!1T{7n_dN{;9H3*;a8>lK98A^P
zry17PWapVf=N6MX10$`VV9azC7bI>uJvE3W0R!;pj@J~cBlGq>?B
zIFQ?~o80B8vZi;dVmQ^4Q3gl!81*{ttqBPYHA0xaJLn|ukk0(yenh9p*q{(rv&Y)^
zJX$oZ2{LFjAROEWYk(sMthXj7lN%c*(q>wA!|b2n2Ha64PhbGbHA3B$tD+`*k;EQ~
znyMP5vVTFoKc77{VQAxIX&G1BJq0H+v3Z4Ph389-UmHe%@iVq9GjbYd
zXQG3Wx;ox-e5mu~%U_WTN<+Bxuyk~k_j=6|>_uP6smTW_m?`V8f#~Z*K0_4PdjP
z*vDMYJ*w}x;^{TovjpN=+rZN%J2kgK+C)D&**RyuzQ4bJXsB08
z*4zGM)+8f7XYIlwBb0^w96oOP8Xx;wQqqq+Imif>)Przg*z3GEX3dQQ+;wgFR2v7^
z045XBM$Dq2D>=1iJ-e66W(D{#Lw%6JuC#^-&|nS}2y09hrGc?`k^l5$_1gQuUKIl(
z0B-uuXY2G$MZhW2fP{f53fSK^Q`=w$Ym}-1qs{w&J$dr}cx9@`)bC;TE99CPHHSuX
zk7jm=kL6~_mg(K9J+lK=F{a6G*nJ&bKY%}$0J&3nhMiGrVruFNcR`)nCDH&@z(~TO
zb5n3+V_Mx=Sy|bn#6&YO*{xg6reF`mWko{mx0-c3=Y+~7B_)|pO%A*kmt(h-X2=Sc
zQoGJ87sjnzxl-20#nO^K_=#Q`8tNCyKJH|{!sjo#e_?s4Ms&p))5uSQsr0DBlXe5I
zO{Z3e+TZaxz^oZ|mg_Cu&m=4-doaiSyEP^Ted5PdN;9sphf6iuiRYNFm-`g^cR!3B
zmy^~CWk<@%jg_)9MkktHe6V!8gmXh@$6y8t9efyi!FZP^#+`XV`%jR!p!(!6_TG##
zMaRn5$+XqEnPo|`BGo>|b#7qpu+^X!iBHSTub3FFjq0~sAz?Ee3^@~YFr9Ryshc|@
z#HnflGLMY<0WJZRdWEYq>wMbe&NVlb{yJuE?hPBijdFzY8v4;I^tVI2mVRYilCe@m
zM9wF@p%?7>nbJ{`pxKnEnc3E6W{0Y8b}d&ygN?a?9r&=4**if8M-sKOJF5zA13s7FoS&jE*t$IKK%oHP
zd>>QbbpFv?*Ye{EO$@`9NS_ak<}DYaMBv!NNrx4&TQeHsp%7pp0*r<81`W}1jf0m$
zhVFR$-Es8}d-u$$A&i5PLQ&QZ(R?*LUR7P)b+AlPWAfA9G)6?k=@et=H@wLKJ3Q;=
zmZc=)4O9Jvx!O5RH>q1Tn_YVrUh?fBRcHO4n+lA?QYYo(`q!JljBXr=9yCM^sw2Up
zd$2zt_vloF+=H)e7)62YLo5D3U$`GYEzRiw^5Ro%aNJz;2V3<PddUzfs(m!;ka57OSZaDhVyK)7+;_hZ^@jaC2@kaGKV=E+
z`SOXPY_#z@xpM{UUgr9vN&W>7-~fnGYB|JKuv3$a30iS5K_Gt
z`7XtY?(V#?aTG$TTY`@4)2SMTa>rp@T0;RCj^!=}Oo<>E*T)Re7v)MK+t_0@xCxGVzD5-IuzRw6Z!>
z6)fcLfZirK5v8BUy6@F}#Ir#n`3PofKuramQP!t|Q6w)+P4vh2I=EiGSJn=ve!)a?
zO^r#Ocde4pn^?3r$~S>xH}r5_l<-14^d{H!vYpTG0BOxcgQQV7-E-7z>UeBmMj<;Y
z&!gd)%)$Nwp|S58%@P<{Te>}0)$Hi_vqezr_5#iHepj2_xOtuH#&6lbQ0!bAw&O&>
z4$m`-ZvDA&)5X(a?Z2e#yS78>+Jgf&{U6QOG504tE{ae|`IRu`z3ivITN>h@JNG_0
z^S6&-lAmyg-eA0uT$H-n_(%pd-(z%_ci-=&_t(%46((X5JIXIgpcx#~Sd(#yR0n75
z%CW|h*&oSl@hM7xcc-7msAITH$GDTRkU(e29i3~8V#z4*mZ8q7=qNU`HrYIHY--eJ
zz4yo8OHXbs3cFWcgnyK0idHlZ=WYwAF`r5nF^l!Q2TOh0Ui-A!PYsh9R95fn05KRN
zbDU79v3sRJT-9(#T4VHMMH{nf0s^CmT%^}T1J#|?UPy>yc~1U4KlA`h8C3ivM!Q~9
z=@PE-i)4`xTBl#o+$x}!Rh|cQPuxAZgOia1-Q*&JCa9-JHUtutD
z)%(Qb8A;}O4(kMzcmX$r^&gFMhWBk87(mad>f`pf=S0t!^N(FN4B
z2^Hcr%8uZgbe_<>x}g^rB&_Jz--jnus+
zM#`eKgu!DcTMJWalP&(%!#flkTq4C~Iyp^B5VkxgS*oMW(CgBoL!Rc7;x|+-k!T*&
z2?`-9Z=y$}*=sb1ig%wJ>uXXmN-}l(^g&Zq$!}X@=DD`Mrkp76@y-H2Ve?$qsgD(`
z=x9Gj4@SG?q{IBsa+5%Xb?pkonj<7s`7Zx_@J~tc$Q?yttPcWIHpNAK!nfaZq}QJ8
z!5XiIo77;^$azG1NmGtX+j?bBRk&L{y}#ky)vnS|xtlk|%6dDiUV|%4^`w+YNjfY7
z&w0Ns)+8{vY4rKC%NrsFMJ+qJ;n5ayXurQQp9rW$MNO{8XNgaJ60snAGuzAJ?OBrz
z{obP@H^nr(hd#FCdAKCQnV*byovT9PrMuFzMn1f_l*Q8Di1
zYB6eIvkLVT9IoEcq!{Elr3n&4Bp!--|1zvBS%#gNAfn~{RzGsAXC>{2{${W4LVrSe
zMsHzhhkbLdGOS=Bqhqw6Ix$qG2VXk)sU=#)r4J5xbn1J(%;kMBv&yNrK8+b1G8kUh
z=rc(B$Yc-{?VB?lJ4PB|r5#86BE(rAMcVvUWi~>q)}S%FUWoWaVX52ATPmIVOYC#7
zll_w%-ZI<n4a7BC59QR>3S(bTz
zids`EIsA68)3A%0?Zg=AJr1{YSL1AXqecVy>M4z
ze)D(Sh5?uxB)ve#2hx+MGT$M{K-gVfqX`Uj@{^y2E3>QclE7#mm8|Ha6QT2%@v-HS
z-)I1siPt!|XIHiV>i}^Ce%7TB0u;BES2$0d2_8S&;4e~cL3ZEiVxI5%?2w)L)NhPI
z0;DjHksa0cDvYl!?3Pr9W}QvRUUWO%0!%QiEM!+3^EtyoL17d=#~dtFRgimV@v$!b
z$3(ln9m>k?J8Q}A5lJb`By)14bo!{6ZOJY=<4)Asd`i647(|R6VOJQ*+kAopIjwC^HsVeN6$*0ygSh!1saFl{NE6jP5N=ew~^
z%hq}7M82`FXm?QvwQEkY4JQX@*i+BObXeH*o$anX9^rkJsdLP`qD*e|tYc{PK^H=Q;i8R7n$9rPnj%rk6jiHkzsxe`>lDuO@w=mPu8{XOo5BVP(0({
zpuv2SNd9nQhGV##O2$xrnOx%=a?>G-C8IX$gm*D4cCuNHdw0!^ZpEage8=wg#I-K%
z$qnBSrrs6mN$$QcNQ|jZj3>Y$+aKu%>vO6yYU{jF&U%%;a`M60!`g&rr2d{wMa5q1
z392@s50>3+?Bfe?X>qU}pWtrQunE{RWq0BZ;eHA`$7i&n&5!XW2DTO=48)W2opB1}
zEnZ$@4vs<0O^yV8&;Gu~gFC@R^T6eUB?HLb#gnZnjGg_>ISLuk(OOnhlqQvw^J%?r
z3VJ`a6SOoe;1U%HBk{oj$JMla~>Z<-~rGcf5f)A>98cTsPf9(RS*F
zk6NPvh1%8L>6|3PI(_(Ly2(9d?UBBp=I+o
z_+~Pmy-ShROM}g`b3Uk;|0cp5Zf)&pmPGIBh~5D8
z@CgXFO^pW~+qxRVBo$x0Ne`A)a!E3yKHOQGWY#o!EZ@MVqh`{{(WN#mDx6AVHr=Hb
zF!et5vsq*jjX!YRXh_OlfVXn}Q-=c}mzW%8u_CQmPy;t{f
zdkaVyV*}BzwF1OcbA8w^SON_{v`?`-r0yTwx?f$YG^`=mwi#B%h22*Xtn;2h>ui@y
zvpwC{q^4Z|gK~-iseXHIczsijaJul%VHWviuDjYryw|ON~
zHaaNFmf8%ozEf99*g>PrG}is!Mit#EdpA{tHB2@fJV&|Jeb&yiPAD|lF~`N8@#Gzq
zwxi+k=_wY4$}n2=Hr+MyP;K2ql?E9CbM+ai{<+%pOY2V
z%%?%!AGgN-IiK@0Rrpa(VL)RI@+C?>Zh`t@tq||;
zzN_W@kX>0A{`dnyXY+BUV(2rkzbqlSu2IGd(1I!YX3h)iZ);L+(-j@Hs*p9(evg9w
zy+29rluD1=tQ?giC4iF+i?S_bpFzIt!z3N@@TAO6;qNaThU*)sZtNIqHS!*=_1+jU=f*ea6lfZ>uX-EW8wmBjP%8n#SG+QRqLpv!yU
z@IrjmxB^jGpLAPRrsQIQZ7;QAfa((eeEcObzkaZFS)Tk3xN#fwA)`O~9H3l1Lh?LT
zBWhd
zHB7n^<`CEAscyXWW2axJQ8>H+WarF+r4C*v6)YuE?4(kOX#@4Ij?~p$s~uN^81ol;
z`p92Dj!ax99SHe0q~`lSBB#VcFFsEge`1OJxu@+fA-=WkC9S#Q=HDk7A_D~6=T*ue
z&@Mmnbgi5+mZ95b7#%fIu{bX48w~|NR4e7OO+pyBQ6A%;P-r4(p1tKNM>B=$_AKvw2sc-D)xhFyBpYnwtHCZ@XP^
z{?nGJ@bv`}y6X~PXr+GIf%>m-NMwmIE5b7IPpfGUCr@{NgT#~_y+@Fz09pC&L3mlg
zM;T3uPx0mLxDTQekDgDj2=ixdedS4R0qdk;f!ck~lS=Lu%+*fFPMLUoo5|+CVrzCDAqV6vt
zIeq=fY2iE=nf+-M~l$3W5>a|agFxZM?xa}cYP^<1f`oBbSJDkX?Nk&7WNu%i}P
z;{L+_l=I0LPM9b`?YZT$3NggvTu96Jv`MSY$@6d&t$JUmx2TQRQhd*+owr06&;hkL
z5Ud6tG8Dp*f>W1(G>Oa%Co`#Mz2r*|8fo+04DDv*?jh?m(kRR1S<~Z@n+?K1m7_OZ4xn7BN}>g*#v?2G`