diff --git a/.github/workflows/javase-cef-ffmpeg-smoke.yml b/.github/workflows/javase-cef-ffmpeg-smoke.yml
index ce43520cdcb..0a44edee2bb 100644
--- a/.github/workflows/javase-cef-ffmpeg-smoke.yml
+++ b/.github/workflows/javase-cef-ffmpeg-smoke.yml
@@ -70,7 +70,27 @@ jobs:
- name: Install ffmpeg on Windows
if: runner.os == 'Windows'
shell: powershell
- run: choco install ffmpeg -y
+ run: |
+ # Retried, and then VERIFIED. The community feed answers 504 often enough to
+ # matter, and chocolatey can report success after failing to fetch -- so the
+ # first sign of trouble was the smoke test dying three steps later with
+ # "FileNotFoundError: [WinError 2] The system cannot find the file specified",
+ # which names neither ffmpeg nor the feed. An install step that does not
+ # confirm the tool it installed is how a transient upstream outage becomes an
+ # unreadable failure in somebody else's test.
+ $ok = $false
+ foreach ($delay in 0, 30, 90) {
+ if ($delay -gt 0) {
+ Write-Host "choco install ffmpeg failed; retrying in $delay s..."
+ Start-Sleep -Seconds $delay
+ }
+ choco install ffmpeg -y --no-progress
+ if (Get-Command ffmpeg -ErrorAction SilentlyContinue) { $ok = $true; break }
+ }
+ if (-not $ok) {
+ throw "ffmpeg is not on PATH after installing it. The chocolatey feed is the usual reason -- look for a 504 above."
+ }
+ ffmpeg -version
- name: Run JavaSE CEF/FFmpeg smoke test
env:
diff --git a/.github/workflows/parparvm-tests-windows.yml b/.github/workflows/parparvm-tests-windows.yml
index 921efa9cd66..aec07be46a2 100644
--- a/.github/workflows/parparvm-tests-windows.yml
+++ b/.github/workflows/parparvm-tests-windows.yml
@@ -192,10 +192,49 @@ jobs:
working-directory: vm
shell: pwsh
run: |
- mvn -B clean package -pl JavaAPI -am -DskipTests
- # Single-quote the -D args: PowerShell otherwise mangles the dotted
- # property name (splitting it at the '.').
- mvn -B test -pl tests -am '-Dtest=CleanTargetIntegrationTest' '-Dsurefire.failIfNoSpecifiedTests=false'
+ # Retried, in the same spirit as the Ninja install above: Maven Central
+ # answers 429 Too Many Requests under load and the build dies during
+ # dependency RESOLUTION, before a line of this project is compiled. Nothing
+ # about the failure involves the code under test, and a fresh runner IP is
+ # not something a re-run can be relied on to produce. Backoff is in tens of
+ # seconds rather than the 5 used for the pip blip, because a rate limit needs
+ # waiting out rather than retrying through.
+ # Written in the same plain loop shape as the Ninja install above rather
+ # than with a function and splatting, because that shape is already proven
+ # on these runners and this is not the place to find out about a quoting
+ # difference.
+ $ok = $false
+ foreach ($delay in 0, 30, 90) {
+ if ($delay -gt 0) {
+ Write-Host "mvn failed; retrying in $delay s..."
+ Start-Sleep -Seconds $delay
+ }
+ mvn -B clean package -pl JavaAPI -am -DskipTests
+ if ($LASTEXITCODE -eq 0) { $ok = $true; break }
+ }
+ if (-not $ok) { throw "mvn clean package failed after all retries" }
+ # This one RUNS TESTS, so the retry is restricted to the failure shape it
+ # exists for. A blanket loop lets an intermittent product regression pass on
+ # attempt two and turns a blocking gate green -- which is the opposite of what
+ # a gate is for, and worse than the flake it was hiding.
+ $resolutionFailure = 'status: (403|429|50[0-9])|Could not transfer artifact|Unresolveable build extension|Non-resolvable import POM|Could not resolve dependencies'
+ $ok = $false
+ foreach ($delay in 0, 30, 90) {
+ if ($delay -gt 0) {
+ Write-Host "mvn failed on a dependency-resolution error; retrying in $delay s..."
+ Start-Sleep -Seconds $delay
+ }
+ # Single-quote the -D args: PowerShell otherwise mangles the dotted
+ # property name (splitting it at the '.').
+ # Tee-Object keeps the log on the console AND gives us something to match.
+ mvn -B test -pl tests -am '-Dtest=CleanTargetIntegrationTest' '-Dsurefire.failIfNoSpecifiedTests=false' 2>&1 |
+ Tee-Object -Variable mvnOutput
+ if ($LASTEXITCODE -eq 0) { $ok = $true; break }
+ if (-not ($mvnOutput -match $resolutionFailure)) {
+ throw "mvn test failed for a reason that is not a transient dependency-resolution error; not retrying"
+ }
+ }
+ if (-not $ok) { throw "mvn test failed after all retries" }
env:
JDK_8_HOME: ${{ env.JDK_8_HOME }}
JDK_11_HOME: ${{ env.JDK_11_HOME }}
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 35632d23824..955a23b06ae 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -90,6 +90,14 @@ jobs:
with:
node-version: '20'
- name: Run Unit Tests
+ # Maven Central answers 429 from the runner CDN edge often enough to kill
+ # this step outright -- it dies reading the root POM, before a line is
+ # compiled, so nothing about the branch is being tested when it happens.
+ # Retried through the shared helper, but ONLY for that failure shape:
+ # RETRY_ONLY_MATCHING keeps a failing test failing on the first attempt
+ # rather than letting a re-run launder a flake into a pass.
+ env:
+ RETRY_ONLY_MATCHING: 'status: (403|429|50[0-9])|Could not transfer artifact|Unresolveable build extension|Non-resolvable import POM'
run: |
MVN_GOAL="verify"
MVN_ARGS=""
@@ -98,7 +106,7 @@ jobs:
MVN_ARGS="-Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true -Djacoco.skip=true"
fi
cd maven
- mvn clean "$MVN_GOAL" -DunitTests=true -pl core-unittests -am -Dmaven.javadoc.skip=true -Plocal-dev-javase $MVN_ARGS
+ bash ../scripts/ci/retry.sh mvn clean "$MVN_GOAL" -DunitTests=true -pl core-unittests -am -Dmaven.javadoc.skip=true -Plocal-dev-javase $MVN_ARGS
cd ..
- name: Run push service-worker contract
if: ${{ matrix.java-version == 8 }}
diff --git a/.github/workflows/windows-cross-compile.yml b/.github/workflows/windows-cross-compile.yml
index 71f15dd1474..8c82797c94c 100644
--- a/.github/workflows/windows-cross-compile.yml
+++ b/.github/workflows/windows-cross-compile.yml
@@ -106,8 +106,44 @@ 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.
+ # Retried ONLY for that failure shape. A blanket loop lets an intermittent
+ # compiler, generator or packaging regression pass on a later attempt and turn
+ # a blocking gate green, which is worse than the flake it hides -- and the
+ # retries reuse the previous attempt's target directories, so a partial output
+ # can decide the result. Matching the output keeps a real failure terminal on
+ # its first occurrence, and the retry starts from clean.
+ resolution='status: (403|429|50[0-9])|Could not transfer artifact|Unresolveable build extension|Non-resolvable import POM|Could not resolve dependencies'
+ goal=install
+ for delay in 30 120 300 0; do
+ # PIPESTATUS, not the pipeline's status: tee succeeds even when mvn does not,
+ # and this step does not set pipefail. Reading the wrong one would make every
+ # attempt look successful, which is the opposite failure to the one being
+ # fixed and would hide everything.
+ JAVA_HOME="$JDK_8_HOME" mvn -B -pl windows -am -DskipTests \
+ '-Dmaven.javadoc.skip=true' '-Plocal-dev-javase' $goal 2>&1 \
+ | tee /tmp/windows-cross-build.log
+ status=${PIPESTATUS[0]}
+ if [ "$status" -eq 0 ]; then
+ break
+ fi
+ if ! grep -Eq "$resolution" /tmp/windows-cross-build.log; then
+ echo "core + Windows port build failed for a reason that is not a transient"
+ echo "dependency-resolution error; not retrying"
+ exit 1
+ fi
+ if [ "$delay" = "0" ]; then
+ echo "core + Windows port build failed after all retries"
+ exit 1
+ fi
+ echo "Maven Central looks flaky; retrying in ${delay}s from clean"
+ goal="clean install"
+ 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/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
index f0dae775985..9f3e98c5cc0 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
@@ -11261,6 +11283,36 @@ 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() {
+ }
+
+ /// 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(String keyId) {
+ }
+
+ /// 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..49a70903bc0 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,135 @@ 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;
+ }
+ // 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(key);
+ } else {
+ requestHeaders.put(key, new String[] {name, value});
+ }
+ }
+
+ /// 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;
+ }
+
+ /// 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 key = (String) keys.nextElement();
+ String[] pair = (String[]) requestHeaders.get(key);
+ String name = pair[0];
+ String value = pair[1];
+ // The NAME is checked for being a legal field-name token before anything is
+ // decided about it. Screening only for CR and LF let "Sec-WebSocket-Extensions "
+ // -- one trailing space -- past the reserved-name comparison, and a lenient
+ // server trims that and negotiates the extension anyway. These readers do not
+ // process RSV1 or inflate payloads, so a compressed frame arrives as garbage:
+ // the reserved list exists precisely to stop that being negotiable, and a
+ // comparison that any non-token character walks around is not a list.
+ if (!isFieldNameToken(name)) {
+ continue;
+ }
+ if (isReservedHandshakeHeader(name)) {
+ continue;
+ }
+ if (containsCrLf(value)) {
+ continue;
+ }
+ req.append(name).append(": ").append(value).append("\r\n");
+ }
+ }
+
+ /// Whether this is a legal HTTP field name -- RFC 9110 token, so no spaces, no
+ /// separators, nothing outside printable ASCII.
+ ///
+ /// Rejecting rather than trimming: a caller that wrote a trailing space meant one
+ /// header and a lenient server would read another, and quietly repairing the
+ /// difference is how the two ends stop agreeing about what was sent.
+ private static boolean isFieldNameToken(String name) {
+ if (name == null || name.length() == 0) {
+ return false;
+ }
+ for (int i = 0; i < name.length(); i++) {
+ char c = name.charAt(i);
+ if (c <= 0x20 || c >= 0x7f) {
+ return false;
+ }
+ if (c == '(' || c == ')' || c == '<' || c == '>' || c == '@' || c == ','
+ || c == ';' || c == ':' || c == '\\' || c == '"' || c == '/'
+ || c == '[' || c == ']' || c == '?' || c == '=' || c == '{'
+ || c == '}') {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean isReservedHandshakeHeader(String name) {
+ // 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)
+ // Extensions negotiate what the FRAMES mean, and no reader here
+ // implements one. Emitting it let a caller ask for permessage-deflate;
+ // a compliant server then agrees, sets RSV1 and sends compressed
+ // payloads -- and the readers mask off the opcode and pass the bytes
+ // straight through, so text arrives as mojibake and binary arrives
+ // compressed. The failure appears at the application, far from the one
+ // header that caused it, and only against servers that happen to offer
+ // the extension. Ignored until a port can actually inflate.
+ || "sec-websocket-extensions".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;
+ }
+
/// 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/impl/health/EdtResult.java b/CodenameOne/src/com/codename1/impl/health/EdtResult.java
index 780a1d2c79e..720ff5b2786 100644
--- a/CodenameOne/src/com/codename1/impl/health/EdtResult.java
+++ b/CodenameOne/src/com/codename1/impl/health/EdtResult.java
@@ -22,6 +22,10 @@
*/
package com.codename1.impl.health;
+import com.codename1.util.AsyncResource;
+import com.codename1.util.AsyncResult;
+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 +51,83 @@
/// completes another resource does not queue a runnable per link.
public final class EdtResult extends OneShot {
+ /// EVERY off-EDT registration is marshalled, not only one that finds the resource
+ /// already settled.
+ ///
+ /// `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.
+ ///
+ /// Testing `isDone()` first reproduced that in miniature: a background caller that
+ /// found the resource unfinished fell through to the synchronous path, and a
+ /// completion landing in the gap before it registered made `AsyncResource.ready`
+ /// deliver on the background thread after all. The window is small and the failure it
+ /// produces -- a health callback touching a form off the EDT -- is a repaint glitch or
+ /// a corrupted layout that nobody traces back to here. Hopping unconditionally has no
+ /// such gap: registration and completion then both happen on the EDT, so they are
+ /// ordered by the EDT rather than by a check.
+ ///
+ /// Already on the EDT still registers inline, which is what keeps a callback chain
+ /// from queueing a runnable per link.
+ ///
+ /// 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 (!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);
+ }
+
+ /// The combined registration is marshalled whole, error branch included.
+ ///
+ /// `AsyncResource.onResult` is `ready(...)` followed by `except(...)`, and only the
+ /// first of those is overridden here -- so a worker thread registering on a resource
+ /// that had already failed ran the error half immediately, on that worker. This is the
+ /// application-facing form of the callback, so the failure it produces is an app
+ /// handling an error by touching a form off the EDT: the exact thing the class exists
+ /// to prevent, reached through the other half of the same method.
+ ///
+ /// `except` on its own stays synchronous, which is what the exemption above is about.
+ /// Reading the error out of an already-failed resource by registering a callback and
+ /// looking at what it captured depends on it, and introspecting a failure is not the
+ /// same act as handling one.
+ @Override
+ public void onResult(AsyncResult onResult) {
+ if (!isCancelled() && Display.isInitialized()
+ && !Display.getInstance().isEdt()) {
+ final AsyncResult target = onResult;
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ EdtResult.super.onResult(target);
+ }
+ });
+ return;
+ }
+ super.onResult(onResult);
+ }
+
@Override
public void complete(T value) {
if (Display.getInstance().isEdt()) {
diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java
index f310829981b..d3726c4bcbb 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,
@@ -641,6 +651,127 @@ 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 (key == null) {
+ return;
+ }
+ if ("content-type".equalsIgnoreCase(key)) {
+ // addRequestHeader routes this one to a dedicated field rather than the
+ // header map, so scanning the map alone left it set and initConnection went
+ // on emitting it -- a removal that removed nothing, which is worse than an
+ // unsupported one because the caller has been told otherwise. Back to the
+ // default value and the not-explicitly-set flag, which is the state a request
+ // that never mentioned it is in.
+ contentType = "application/x-www-form-urlencoded; charset=UTF-8";
+ contentTypeSetExplicitly = false;
+ return;
+ }
+ if (userHeaders == 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));
+ }
+ }
+ }
+
+ /// Removes a header previously added with [#addRequestHeader(String, String)], but only
+ /// while it still holds `value`.
+ ///
+ /// A layer that decorates a request it does not own -- an interceptor attaching a
+ /// credential -- has to take its header back off before the request is reused for
+ /// somewhere else. By name alone that removes whatever is under the name by then, and
+ /// the app itself gets a turn in between: [#onRedirect(String)] runs between the
+ /// response and the retry, and it is exactly where an app installs the headers the
+ /// redirect target needs. If the two pick the same name, removing by name deletes the
+ /// app's credential rather than the decorator's. The value is what tells them apart.
+ ///
+ /// #### Parameters
+ ///
+ /// - `key`: the header key, matched without regard to case as HTTP requires
+ ///
+ /// - `value`: the value the header must still have; anything else is left alone
+ public void removeRequestHeaderIfUnchanged(String key, String value) {
+ if (key == null || value == null) {
+ return;
+ }
+ if ("content-type".equalsIgnoreCase(key)) {
+ // addRequestHeader routes this one to a field rather than the header map, so
+ // it has to be compared and reset there or the removal removes nothing.
+ if (contentTypeSetExplicitly && value.equals(contentType)) {
+ contentType = "application/x-www-form-urlencoded; charset=UTF-8";
+ contentTypeSetExplicitly = false;
+ }
+ return;
+ }
+ if (userHeaders == null) {
+ return;
+ }
+ // Every spelling of the name, for the reason removeRequestHeader gives: a value
+ // set as "X-Api-Key" and removed as "x-api-key" would otherwise go out anyway.
+ 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)
+ && value.equals(userHeaders.get(existing))) {
+ 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
/// is already set to something else
///
@@ -881,21 +1012,80 @@ 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();
+ 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
+ // it has always seen, the guard gets the enriched one when the platform
+ // 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);
+ 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) {
+ // 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.
+ /// 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
+ // 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 checkSSLCertificates;
+ }
+
/// Performs the actual network request on behalf of the network manager
void performOperation() throws IOException {
performOperationComplete();
@@ -910,6 +1100,19 @@ boolean performOperationComplete() throws IOException {
if (shouldStop()) {
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;
+ // 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 {
@@ -927,6 +1130,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;
@@ -980,17 +1197,41 @@ 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);
- 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);
+ certGuard.checkCertificates(this,
+ forGuard == null ? sslCertificates : forGuard);
+ }
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);
@@ -1091,6 +1332,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;
}
}
@@ -1104,6 +1350,7 @@ boolean performOperationComplete() throws IOException {
Preferences.set("cn1Etag" + createRequestURL(), etag);
}
readHeaders(connection);
+ captureGuardHeaders(connection);
contentLength = impl.getContentLength(connection);
timeSinceLastUpdate = System.currentTimeMillis();
@@ -1139,6 +1386,21 @@ 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;
+ // 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 {
// always cleanup connections/streams even in case of an exception
impl.cleanup(output);
@@ -1236,6 +1498,48 @@ 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;
+
+ /// 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;
+ }
+ 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;
+ }
+
+ /// 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
@@ -1550,8 +1854,86 @@ 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) {
+ 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];
+ }
+ Vector out = new Vector();
+ SSLCertificate current = null;
+ int index = 0;
+ for (String entry : entries) {
+ 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;
+ }
+
+ /// 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 null;
+ }
+ 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.
+ ///
+ /// 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 {
- String[] sslCerts = Util.getImplementation().getSSLCertificates(connection, url);
+ CodenameOneImplementation impl = Util.getImplementation();
+ String[] sslCerts = impl.getSSLCertificates(connection, url);
SSLCertificate[] out = new SSLCertificate[sslCerts.length];
int i = 0;
for (String sslCertStr : sslCerts) {
@@ -3032,6 +3414,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 +3432,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..630f023399d
--- /dev/null
+++ b/CodenameOne/src/com/codename1/io/NetworkGuard.java
@@ -0,0 +1,87 @@
+/*
+ * 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;
+
+ /// 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.
+ /// @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 92e750854b4..1bf744f59a7 100644
--- a/CodenameOne/src/com/codename1/io/NetworkManager.java
+++ b/CodenameOne/src/com/codename1/io/NetworkManager.java
@@ -207,6 +207,46 @@ public static NetworkManager getInstance() {
return INSTANCE;
}
+ /// 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].
+ ///
+ /// 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 synchronized 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 +1172,16 @@ private boolean runCurrentRequest(@Async.Execute ConnectionRequest req) {
if (requestWasCompleted) {
req.complete = true;
}
+ NetworkGuard guard = getNetworkGuard();
+ if (guard != null && req.hasGuardResponse()) {
+ try {
+ guard.afterResponse(req, req.getResponseCode(), req.getGuardHeaders());
+ } 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/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java
index 5e715ae6fea..2f296b82105 100644
--- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java
+++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java
@@ -118,6 +118,26 @@ 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;
+ }
+ // 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) {
@@ -134,6 +154,18 @@ public void open() throws IOException {
}
}
+ /// Test hook: makes this transport the one a freshly constructed [Connection] binds
+ /// to, without binding a real socket.
+ ///
+ /// The binding is what a test needs to reach -- `open()` requires loopback server
+ /// sockets, which not every environment running these tests has -- and the field it
+ /// sets is the same one `open()` sets.
+ static void setActiveForTesting(MCPLoopbackSocketTransport t) {
+ synchronized (MCPLoopbackSocketTransport.class) {
+ active = t;
+ }
+ }
+
/// Releases the process-wide registration, but only when it is still this transport's.
private void clearActiveIfOurs() {
synchronized (MCPLoopbackSocketTransport.class) {
@@ -351,6 +383,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;
@@ -388,6 +428,26 @@ public static final class Connection extends SocketConnection {
/// instead of once per retry. Only ever touched from the listener thread.
private static String lastReportedError;
+ /// The transport whose listener accepted THIS connection.
+ ///
+ /// Captured when the socket API constructs the callback, which happens on the
+ /// listener thread as the connection is accepted -- not when the callback body
+ /// later runs, which is where it used to be read. Between those two moments a
+ /// transport can close and another can take the process-wide slot, and the
+ /// already-accepted streams were then attached to the new one: a client of the
+ /// listener that has stopped replaces the session of the server that has just
+ /// started. Binding the callback to its own acceptor makes that impossible to
+ /// express.
+ private final MCPLoopbackSocketTransport acceptedBy;
+
+ /// Public and no-argument because [Socket#listenLoopback] constructs this
+ /// reflectively; the binding above is what the constructor exists for.
+ public Connection() {
+ synchronized (MCPLoopbackSocketTransport.class) {
+ acceptedBy = active;
+ }
+ }
+
@Override
public void connectionError(int errorCode, String message) {
// Without this the failure is silent: startSocketServer returns, the server
@@ -410,13 +470,23 @@ public void connectionError(int errorCode, String message) {
@Override
public void connectionEstablished(InputStream is, OutputStream os) {
- MCPLoopbackSocketTransport transport;
- synchronized (MCPLoopbackSocketTransport.class) {
- transport = active;
- }
+ MCPLoopbackSocketTransport transport = acceptedBy;
if (transport == null) {
+ closeQuietly(is);
+ closeQuietly(os);
return;
}
+ // Closed between accepting this connection and running this callback. The
+ // streams belong to a session nobody is serving, so they are closed rather
+ // than handed to whichever transport is open now -- which would let a client
+ // of the stopped listener take over the new server's session.
+ synchronized (transport.lock) {
+ if (transport.closed) {
+ closeQuietly(is);
+ closeQuietly(os);
+ return;
+ }
+ }
transport.attach(is, os);
// Hold this callback thread for the life of the session: the socket API closes
// the streams as soon as it returns, and the server reads them from its own
diff --git a/CodenameOne/src/com/codename1/mcp/MCPServer.java b/CodenameOne/src/com/codename1/mcp/MCPServer.java
index 386568ba327..932d22bf424 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;
@@ -128,7 +131,7 @@ public void setScreenshotEnabled(boolean screenshotEnabled) {
this.screenshotEnabled = screenshotEnabled;
}
- public boolean isRunning() {
+ public synchronized boolean isRunning() {
return running;
}
@@ -139,10 +142,17 @@ 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
+ // actually given was never closed.
+ final MCPTransport mine = transport;
Thread readerThread = new Thread(new Runnable() {
@Override
public void run() {
- runLoop();
+ runLoop(mine, generation);
}
}, "cn1-mcp-server");
readerThread.start();
@@ -156,29 +166,327 @@ 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, 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 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.
+ ///
+ /// 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;
+ 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();
+ }
+ }
+
+ /// 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 open lock, 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();
+ }
+
+ /// Opens `t` for this generation, serialized against any other generation opening the
+ /// same transport. Returns false when this thread is done and must not read.
+ ///
+ /// Serialized per TRANSPORT, 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 lock first let the
+ /// replacement acquire it and call open() while this thread's now-stale listener was
+ /// still registered -- and a transport refuses a second listener, so the replacement
+ /// took an IOException and stopped the server it had just started.
+ ///
+ /// 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. Not the
+ /// server monitor either: open() blocks, and holding that across it would make stop()
+ /// wait on what it is stopping.
+ ///
+ /// And deliberately NOT the transport's own monitor, which is what this used to be.
+ /// [MCPTransport] is a public interface: an implementation is entitled to write
+ /// `synchronized void close()`, and with such a transport the two locks were taken in
+ /// opposite orders -- this thread held the transport and waited for the server monitor
+ /// inside `isCurrent`, while `stop()` held the server monitor and waited for the
+ /// transport inside `close()`. Both threads park forever. The same ownership also
+ /// blocked the one call that can end a legitimately blocking `open()`: `close()` could
+ /// not run until `open()` returned, and `open()` was waiting for `close()`. A lock the
+ /// server owns and no transport can name has neither problem, and the ordering below
+ /// has one direction only -- open lock, then server monitor, then whatever the
+ /// transport locks internally.
+ private boolean openSerialized(MCPTransport t, int generation) {
+ Object openLock = acquireOpenLock(t);
try {
- transport.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);
+ synchronized (openLock) {
+ if (!isCurrent(t, generation)) {
+ releaseAndCloseIfCurrent(t, generation);
+ return false;
+ }
+ try {
+ 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 false;
+ }
+ 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 false;
+ }
+ return true;
}
- running = false;
+ } finally {
+ releaseOpenLock(t);
+ }
+ }
+
+ /// The per-transport open locks, and how many threads are currently holding a
+ /// reference to each.
+ ///
+ /// A plain map would grow one entry per transport instance the process ever serves.
+ /// The count is what makes removal safe: dropping an entry while a thread is parked on
+ /// that monitor would let the next generation mint a second lock for the same
+ /// transport, and two generations serializing on different objects are not serialized
+ /// at all.
+ private final List
*/
public final class IOSBiometrics extends Biometrics {
+ /**
+ * 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 dceGuard;
+
static {
// Prevents the iOS VM optimizer from eliding these callbacks.
+ //
+ // 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
@@ -123,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;
@@ -139,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 541137161d2..515048cb2e1 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,87 +36,1647 @@
* 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:
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 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 {
+ /**
+ * True only while the retention calls below are running, so each callback returns
+ * before it touches anything.
+ *
+ *
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 dceGuard;
+
static {
- // Prevents the iOS VM optimizer from eliding these native callbacks.
- nativeAttestSuccess(-1, null);
- nativeAttestError(-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.
+ //
+ // 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;
}
- 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 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";
+ /**
+ * 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";
+
+ /**
+ * The key Apple has ACCEPTED an attestation for, when the state write that should
+ * have recorded that failed.
+ *
+ *
Distinct from {@link #KEY_ATTEST_STARTED}, which means "submitted, outcome
+ * unknown", and from {@link #attestAnsweredForKey}, which means "Apple answered with
+ * an error that did not consume the key". This one means the opposite of both: the
+ * one-time attestation is spent and it succeeded.
+ *
+ *
Written as a separate item precisely because the state write has just been
+ * refused -- a keychain can fail one item and take another, and the alternative is
+ * relying on process memory for a fact that costs a rate-limited hardware key every
+ * time it is forgotten. A restart with only the in-memory copy read an accepted key
+ * as an interrupted attempt and discarded it.
+ */
+ private static final String KEY_ATTEST_ACCEPTED = "cn1.appattest.attestAccepted";
+
+ /**
+ * The key whose attestation was produced and never handed to anyone.
+ *
+ *
The attestation object itself is only worth keeping for this process: it is made
+ * over one challenge, and a later launch asks for a new one, so the retained copy
+ * cannot be used across a restart even if it were stored. What must survive is the
+ * FACT -- that this key's one-time attestation went nowhere, so the backend has never
+ * seen it. Without that, the next launch finds a pending key, waits out the grace
+ * window, promotes it, and asserts against a key nobody registered: rejected every
+ * time, then a reset, which costs another rate-limited key on top of the one already
+ * wasted.
+ */
+ /**
+ * The state of a key whose attestation has been produced but not handed to anyone.
+ *
+ *
A VALUE of the state item rather than an item of its own, which is the whole
+ * point: the two facts -- Apple attested this key, and nobody has received the
+ * object -- move together or not at all. Written separately, a keychain that took the
+ * state and refused the marker left a key that reads as ordinary pending, so the next
+ * launch waited out the grace window and promoted a key the backend has never seen.
+ * One write cannot half-succeed.
+ *
+ *
Promoted to {@link #STATE_PENDING} the moment a caller takes delivery.
+ */
+ private static final String STATE_PENDING_UNDELIVERED = "pendingUndelivered";
+
+ 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,
+ // 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;
+
+ 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;
+ /**
+ * When this key's attestation was accepted, held here as well as in the keychain.
+ *
+ *
The keychain copy is what survives a restart; this one is what survives the
+ * keychain refusing the write. Without it an accepted key looked unregistered, and
+ * every route out of that state cost a rate-limited hardware key.
+ */
+ private long pendingSinceInMemory;
+ /**
+ * The key whose state this process holds in memory, when the keychain refused to
+ * hold it.
+ *
+ *
Apple accepts an attestation once. If the write recording that fact fails, the
+ * persisted state still describes a key that has never been submitted -- and acting
+ * on it re-submits an already-consumed one-time key, which Apple answers with
+ * invalidKey, which spends the one-shot recovery and mints a replacement. Every
+ * route out of a failed state write costs a rate-limited hardware key unless
+ * something remembers what actually happened.
+ */
+ private String inMemoryStateKeyId;
+ /** The state {@link #inMemoryStateKeyId} is really in. */
+ private String inMemoryState;
+ /**
+ * 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;
+ /// 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;
+
+ /**
+ * The one-time attestation nobody took delivery of, and the request it was made for.
+ *
+ *
A caller can cancel the {@code AsyncResource} while Apple is working. The
+ * callback still records the key as pending, but the attestation object -- the one
+ * thing here that cannot be produced twice -- was on its way to a caller that is no
+ * longer listening, and was simply dropped. The grace window then promoted a key the
+ * backend had never seen, so every assertion was rejected, which reset and spent
+ * another rate-limited key.
+ *
+ *
Held so a retry can still take delivery. The nonce is part of it because an
+ * attestation is made over one challenge: handing it to a request carrying a
+ * different nonce would produce a token the backend rejects, which is the failure
+ * this exists to avoid rather than a way out of it.
+ */
+ /**
+ * The key this process knows was delivered, when the keychain refused to record it.
+ *
+ *
Delivery promotes {@link #STATE_PENDING_UNDELIVERED} to {@link #STATE_PENDING}.
+ * If that write is refused, the persisted state still says nobody received the
+ * attestation -- and the next request would discard a key the backend may already
+ * have registered, on the strength of a fact this process knows to be out of date.
+ * Same shape as every other in-memory override here: the keychain copy survives a
+ * restart, this one survives the keychain saying no.
+ */
+ private String deliveredAttestKeyId;
+
+ private String undeliveredAttestKeyId;
+ private String undeliveredAttestNonce;
+ private String undeliveredAttestToken;
+
+ /**
+ * The resource the retained attestation was produced FOR, while its delivery is still
+ * undecided.
+ *
+ *
The retained copy is meant for the caller that walked away, and "walked away"
+ * has to be a decided fact. Delivery is queued onto the EDT, so between the callback
+ * and that runnable the token is retained and the original caller may still get it --
+ * a second request arriving in that window used to take the copy and hand it to
+ * itself, and both callers then submitted the same replay-protected attestation. The
+ * duplicate is rejected, and an app that treats a rejection as a bad key resets one
+ * the backend had just registered.
+ */
+ private OneShotResource undeliveredAttestResult;
+
+ /**
+ * 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;
+ /**
+ * 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();
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() {
+ // 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) {
+ 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);
+ }
+ }
+ }
+
+ /// 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
+ // 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 idGone = store.remove(KEY_ID);
+ // 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
+ // 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.
+ boolean markerGone = true;
+ if (idGone && stateGone) {
+ store.remove(KEY_ATTEST_STARTED);
+ // The acceptance belonged to the identity that has just gone with it. Left
+ // behind, it would name a key that no longer exists -- harmless while the
+ // identifier is absent, and wrong the moment a replacement is generated.
+ store.remove(KEY_ATTEST_ACCEPTED);
+ attestAnsweredForKey = null;
+ // The identity is gone, so anything this process remembered about it is too.
+ undeliveredAttestKeyId = null;
+ undeliveredAttestNonce = null;
+ undeliveredAttestToken = null;
+ undeliveredAttestResult = null;
+ deliveredAttestKeyId = null;
+ inMemoryStateKeyId = null;
+ inMemoryState = null;
+ pendingSinceInMemory = 0L;
+ discardFailed = false;
+ if (!keepSpentMarker) {
+ // 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
+ // 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
+ // 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");
+ }
+ currentBackoff = MIN_BACKOFF_MILLIS;
+ retryAfterFallback = 0L;
+ 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
+ * 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(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
+ // STATE_ATTESTED over a STATE_NEW key Apple has not attested yet -- and the
+ // next request would assert against it.
+ SecureStorage store = SecureStorage.getInstance();
+ // 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. The in-memory identity counts as much as the
+ // persisted one: both are only ever set for the key this process is actually
+ // holding, and a reset clears both together.
+ boolean pendingInMemory = keyId.equals(inMemoryStateKeyId)
+ && (STATE_PENDING.equals(inMemoryState)
+ || STATE_PENDING_UNDELIVERED.equals(inMemoryState));
+ if (!keyId.equals(store.get(KEY_ID)) && !pendingInMemory) {
+ return;
+ }
+ // What this process knows wins over what the keychain managed to store, the
+ // same rule the request path follows.
+ //
+ // When the write recording the attestation was refused, storage still says
+ // "new" -- so reading it alone dropped the backend's acknowledgement of a key
+ // Apple had already attested, and dropped it permanently: the acceptance is
+ // never re-sent. The key then sat until the grace window promoted it, or, if
+ // the app restarted first, was read as an interrupted attestation (the start
+ // marker is still there, because the path that clears it never ran) and
+ // discarded for another rate-limited key. Honouring the in-memory state costs
+ // nothing when the keychain is healthy and is the whole point of holding it.
+ // Either spelling. A backend confirming the key is proof somebody took
+ // delivery, whatever this device managed to record about it.
+ String persisted = store.get(KEY_STATE);
+ if (!pendingInMemory && !STATE_PENDING.equals(persisted)
+ && !STATE_PENDING_UNDELIVERED.equals(persisted)) {
+ return;
+ }
+ promoteToAttested(store, keyId, pendingInMemory);
+ }
+ }
+
+ /**
+ * Records that {@code keyId} is registered, and finishes everything that follows
+ * from it.
+ *
+ *
One method because there are two ways to arrive: the backend acknowledging the
+ * attestation, and the grace window expiring without an acknowledgement -- which is a
+ * documented, supported outcome, not an error path. They had drifted apart, and every
+ * step missing from one of them costs a rate-limited hardware key:
+ *
+ *
+ *
the in-memory copy, because the request path prefers what this process knows
+ * over what the keychain holds -- so a leftover {@code STATE_PENDING} outvotes a
+ * write that has just succeeded and keeps answering "registration in progress" on
+ * a key that is registered;
+ *
the pending deadline, or {@code registrationGraceRemaining()} reports a window
+ * that has already been resolved;
+ *
the start marker, or the next launch reads an attested key as an interrupted
+ * attestation and discards it for a replacement;
+ *
the recovery marker, or the next time iOS legitimately invalidates this key the
+ * one-shot replacement is refused as already spent, and every assertion fails
+ * until the app resets attestation by hand.
+ *
+ *
+ *
Harmless on the healthy path: it removes entries that are already gone.
+ */
+ private void promoteToAttested(SecureStorage store, String keyId, boolean holdInMemory) {
+ if (!store.set(KEY_STATE, STATE_ATTESTED) || holdInMemory) {
+ inMemoryStateKeyId = keyId;
+ inMemoryState = STATE_ATTESTED;
+ }
+ pendingSinceInMemory = 0L;
+ store.remove(KEY_PENDING_SINCE);
+ store.remove(KEY_ATTEST_STARTED);
+ store.remove(KEY_ATTEST_ACCEPTED);
+ // Registered, so whatever happened to the delivery no longer matters.
+ deliveredAttestKeyId = null;
+ undeliveredAttestKeyId = null;
+ undeliveredAttestNonce = null;
+ undeliveredAttestToken = null;
+ undeliveredAttestResult = null;
+ attestAnsweredForKey = null;
+ recoverySpentInMemory = !store.remove(KEY_RECOVERY_SPENT);
+ }
+
+ 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();
+ OneShotResource r = new OneShotResource();
if (!nativeInstance.isAppAttestSupported()) {
r.error(new UnsupportedOperationException(
"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) {
+ 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; "
+ + "retry in " + ((retryAfter - System.currentTimeMillis()) / 1000)
+ + "s"));
+ return r;
+ }
+ SecureStorage store = SecureStorage.getInstance();
+ String keyId = store.get(KEY_ID);
+ String state = store.get(KEY_STATE);
+ // What this process knows wins over what the keychain managed to store. The
+ // keychain copy is what survives a restart; this one is what survives the
+ // keychain refusing the write, and without it an accepted key reads as one
+ // that was never submitted.
+ if (keyId != null && keyId.equals(inMemoryStateKeyId) && inMemoryState != null) {
+ state = inMemoryState;
+ }
+ // And the durable record of an acceptance whose state write was refused. This
+ // is what makes the in-memory copy above survive a restart: without it the
+ // next launch finds STATE_NEW plus a start marker, reads an accepted key as
+ // an interrupted attempt, and discards it for another rate-limited one --
+ // every launch, for as long as the keychain stays unwilling.
+ //
+ // It rehydrates as UNDELIVERED, not as pending. This marker is written in
+ // exactly one place: the branch where the state write was refused, which is
+ // before anyone could have taken delivery. Reading it back as ordinary
+ // pending asserted the opposite -- and with the pending deadline also lost
+ // with the process, the grace window was already over, so the next request
+ // promoted the key immediately and started asserting against something the
+ // backend has never seen. A persisted STATE_PENDING wins over it, because
+ // that value is only ever written once a caller has the attestation.
+ if (keyId != null && !STATE_ATTESTED.equals(state)
+ && !STATE_PENDING.equals(state)
+ && keyId.equals(store.get(KEY_ATTEST_ACCEPTED))) {
+ state = STATE_PENDING_UNDELIVERED;
+ }
+ // Delivery that the keychain refused to record still happened. Without this
+ // the state says nobody received the attestation and the branch below
+ // discards a key the backend may already have registered.
+ if (STATE_PENDING_UNDELIVERED.equals(state) && keyId != null
+ && keyId.equals(deliveredAttestKeyId)) {
+ state = STATE_PENDING;
+ }
+ if (STATE_PENDING_UNDELIVERED.equals(state) && keyId != null
+ && keyId.length() > 0) {
+ // An attestation was produced for this key and nobody took it -- the
+ // caller cancelled while Apple was working. It cannot be produced again,
+ // so it is handed to the retry that asks for the same challenge.
+ if (undeliveredAttestToken != null
+ && nonce.equals(undeliveredAttestNonce)) {
+ // Only once the delivery it was produced for has actually lost.
+ //
+ // Retaining and delivering are not simultaneous: the callback retains
+ // the copy and queues the handover onto the EDT, so for the length of
+ // that hop the token is retained AND still reachable by its original
+ // caller. A request arriving in that window took the copy, and both
+ // callers then submitted the same attestation -- which is
+ // replay-protected, so the second submission is rejected, and an app
+ // that reads a rejection as a bad key resets one the backend had just
+ // registered. Asking the resource itself makes this a fact about a
+ // decided claim rather than a guess about an undecided one.
+ if (undeliveredAttestResult != null
+ && !undeliveredAttestResult.lostItsClaim()) {
+ r.error(new RuntimeException("an App Attest attestation for this "
+ + "challenge is being handed to another caller; retry "
+ + "once it has finished"));
+ return r;
+ }
+ // Through the one-shot claim, like the scheduled delivery. Calling
+ // the inherited complete() left the claim unspent, so a caller that
+ // cancelled the resource it had just been handed still won -- while
+ // this branch had already cleared the only retained copy and promoted
+ // the key. Nobody registers it, and the grace window then starts
+ // asserting against a key the backend has never seen.
+ if (!r.deliver(undeliveredAttestToken)) {
+ return r;
+ }
+ undeliveredAttestKeyId = null;
+ undeliveredAttestNonce = null;
+ undeliveredAttestToken = null;
+ undeliveredAttestResult = null;
+ // The same promotion the scheduled delivery does, including what to
+ // do when the keychain refuses it: this caller is walking away with
+ // the attestation, so a persisted state still saying nobody received
+ // it would have the NEXT request discard a key the backend may
+ // already have registered. Two paths hand this object over, and both
+ // have to record that they did.
+ if (!store.set(KEY_STATE, STATE_PENDING)) {
+ deliveredAttestKeyId = keyId;
+ }
+ if (STATE_PENDING_UNDELIVERED.equals(inMemoryState)) {
+ inMemoryState = STATE_PENDING;
+ }
+ return r;
+ }
+ // A different challenge, or a restart that lost the object entirely. Either
+ // way the retained copy cannot help: an attestation is made over one nonce
+ // and the backend checks it. What must NOT happen is the grace window
+ // promoting this key -- the backend has never seen it, so every assertion
+ // would be rejected, and the reset that follows costs a rate-limited key
+ // anyway on top of the failures. Discard it here instead: one key, once,
+ // deterministically, with the next lines generating its replacement.
+ undeliveredAttestKeyId = null;
+ undeliveredAttestNonce = null;
+ undeliveredAttestToken = null;
+ undeliveredAttestResult = null;
+ resetLocked();
+ keyId = null;
+ state = null;
+ }
+ 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.
+ // The same promotion confirmAttestation() performs, through the same
+ // method. Written out here, it kept only the state and the deadline --
+ // so a replacement key that reached this branch (its pending metadata
+ // write having failed, and no backend acknowledging) was promoted with
+ // the recovery marker from the attempt that produced it still set. The
+ // next time iOS legitimately invalidated that key, its replacement was
+ // refused as already spent and every assertion failed until the app
+ // reset attestation by hand.
+ promoteToAttested(store, keyId, keyId.equals(inMemoryStateKeyId));
+ 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
+ // 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) {
+ // 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;
+ if (keyId != null && keyId.length() > 0
+ && (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.
+ attestKey(r, nonce, keyId);
+ } else if (keyId != null && keyId.length() > 0
+ && (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 --
+ // the exact loop the spent marker exists to stop. Report instead.
+ bootstrapInFlight = false;
+ 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
+ // 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.
+ // Ordered like resetLocked, and for the same reason: the markers
+ // that make a surviving key terminal go only once the key itself is
+ // confirmed gone. Removing KEY_ATTEST_STARTED while the keychain
+ // refused KEY_ID left an outcome-unknown key looking like one that
+ // was never submitted, so the next request handed it to attestKey
+ // again -- and if Apple had already consumed the original submission
+ // that burns another rate-limited attempt and drops the device into
+ // invalid-key recovery for a reason that was never true.
+ if (!store.remove(KEY_ID)) {
+ bootstrapInFlight = false;
+ failResource(r, "App Attest could not discard the key whose "
+ + "attestation was interrupted; the keychain refused the "
+ + "deletion");
+ return r;
+ }
+ store.remove(KEY_STATE);
+ store.remove(KEY_ATTEST_STARTED);
+ store.remove(KEY_ATTEST_ACCEPTED);
+ // 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);
+ nativeInstance.appAttestGenerateKey(rid);
+ } else {
+ int rid = register(pending);
+ nativeInstance.appAttestGenerateKey(rid);
+ }
+ return r;
+ }
+ assertWithKey(r, nonce, keyId);
}
- nativeInstance.requestAppAttestToken(rid, 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) {
+ // The keychain refused the timestamp, and this process remembers when the
+ // key was accepted. Falling through to 0 here is what made the accepted key
+ // look unregistered and sent it back through attestation.
+ IOSDeviceIntegrity live = instance;
+ if (live != null && live.pendingSinceInMemory > 0) {
+ long left = REGISTRATION_GRACE_MILLIS
+ - (System.currentTimeMillis() - live.pendingSinceInMemory);
+ return left > 0 ? left : 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(OneShotResource r, String nonce, String keyId) {
+ attestKey(r, nonce, keyId, false);
+ }
+
+ private void attestKey(OneShotResource 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)));
+ // Recorded BEFORE the call, so a crash between here and the result is
+ // 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");
+ 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);
+ pending.retried = retried;
+ int rid = register(pending);
+ nativeInstance.appAttestAttestKey(rid, keyId, hash);
+ }
+
+ private void assertWithKey(OneShotResource 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) + "\"}";
+ }
+
+ /**
+ * 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) {
+ // 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);
+ }
+ }
+ 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) ----------------
- /** 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) {
+ /**
+ * 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;
}
- Display.getInstance().callSerially(new Runnable() {
- @Override
- public void run() {
- if (!r.isDone()) {
- r.complete(token);
+ 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) {
+ if (dceGuard) {
+ return;
+ }
+ PendingRequest pending = take(requestId);
+ if (pending == null || instance == null) {
+ return;
+ }
+ if (keyId == null || keyId.length() == 0) {
+ failBootstrapAttempt(pending,
+ "App Attest key generation returned no identifier");
+ return;
+ }
+ // The staleness check and the writes it guards happen under the same lock a
+ // reset takes. Split apart, a reset landing between them would delete the key
+ // and bump the generation, and these writes would then put the deleted key
+ // back -- registered under the new generation, so every later staleness check
+ // would accept the bootstrap that was supposed to have been abandoned.
+ // fail() only schedules onto the EDT, so it is safe to call while holding this.
+ synchronized (instance.flowLock) {
+ if (isStale(pending)) {
+ fail(pending, "App Attest state was reset while this request was in flight");
+ return;
+ }
+ SecureStorage store = SecureStorage.getInstance();
+ if (!store.set(KEY_ID, keyId) || !store.set(KEY_STATE, STATE_NEW)) {
+ // The keychain refused the write. Attesting anyway would burn a
+ // rate-limited attestation on a key the next request cannot find, so it
+ // would generate another, and another. Give up on this attempt instead
+ // and leave nothing half-written behind.
+ 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(
+ "App Attest could not store its key identifier");
+ return;
+ }
+ // 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);
+ }
+ }
+
+ /** 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;
+ }
+ if (attestationB64 == null) {
+ // 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) {
+ return;
+ }
+ // 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.
+ // Same lock as the reset, for the same reason as nativeKeyGenerated: the
+ // staleness check and the state it writes have to move together.
+ // Labelled so the storage-failure branches can leave the critical section and
+ // still hand the caller its attestation, which is the one thing in here that
+ // cannot be produced a second time.
+ String attestToken = TOKEN_PREFIX + ":attest:" + base64(bytes(pending.keyId))
+ + ":" + attestationB64;
+ shieldAttestState:
+ synchronized (instance.flowLock) {
+ if (isStale(pending)) {
+ fail(pending, "App Attest state was reset while this request was in flight");
+ return;
+ }
+ SecureStorage store = SecureStorage.getInstance();
+ // Retained in the SAME critical section that publishes the undelivered state,
+ // and before it. Split across two, a request arriving in the gap read
+ // "attested, nobody has it" with no object to hand over -- and answered the
+ // only way that state allows, by discarding a key Apple had just accepted.
+ // The two facts are one fact, so anything that can observe either has to
+ // observe both.
+ instance.undeliveredAttestKeyId = pending.keyId;
+ instance.undeliveredAttestNonce = pending.nonce;
+ instance.undeliveredAttestToken = attestToken;
+ // And who it is for, until that is decided. Delivery is queued onto the EDT,
+ // so the copy is retained for a stretch during which the original caller can
+ // still receive it.
+ instance.undeliveredAttestResult = pending.result;
+ if (!store.set(KEY_STATE, STATE_PENDING_UNDELIVERED)) {
+ // The key is attested with Apple and the keychain will not record it.
+ //
+ // Reporting success would leave the next request attesting the same key
+ // again -- but so did simply failing, because the persisted state still
+ // says "new" with a start marker, which is read as an interrupted
+ // attestation and discards the key for another rate-limited one. Every
+ // route out of here spent a key until something remembered that Apple
+ // had already answered. So the state is held in memory for this process,
+ // exactly as the deadline below is, and this request is failed so the
+ // caller retries into a state that now describes the key correctly.
+ instance.inMemoryStateKeyId = pending.keyId;
+ instance.inMemoryState = STATE_PENDING_UNDELIVERED;
+ instance.pendingSinceInMemory = System.currentTimeMillis();
+ // And durably, in a DIFFERENT item, because process memory does not
+ // survive the restart this is most likely to be followed by: storage
+ // still says "new" with a start marker, which the next launch reads as an
+ // interrupted attestation and answers by discarding the key -- spending
+ // another rate-limited one on a key Apple has already accepted. A
+ // keychain that refused the state write can still take an item it has
+ // never seen; if it refuses this too, the in-memory copy is what is left
+ // and the loss is bounded to one key rather than one per launch.
+ store.set(KEY_ATTEST_ACCEPTED, pending.keyId);
+ instance.bootstrapInFlight = false;
+ instance.failBootstrapWaiters("App Attest is completing first-run "
+ + "registration for this device; retry shortly");
+ // The caller still gets the attestation, because it is the ONE thing
+ // here that cannot be produced again. Apple attests a key once; failing
+ // this request threw that object away, so the backend never received the
+ // key to register -- and after the grace window the client promotes it
+ // locally and starts asserting against a key the backend has never seen,
+ // which is rejected, which resets, which spends another rate-limited
+ // key. Discarding an irreplaceable result to report a storage problem
+ // costs strictly more than reporting nothing.
+ //
+ // Falls through to the same succeed() the normal path uses. The state is
+ // pending in memory, so later callers take the registration-in-progress
+ // path exactly as they would have.
+ break shieldAttestState;
+ }
+ long pendingSince = System.currentTimeMillis();
+ // Held in memory whatever the keychain does, and set BEFORE the write is
+ // attempted so the fallback is already in place if it fails.
+ instance.pendingSinceInMemory = pendingSince;
+ if (!store.set(KEY_PENDING_SINCE, Long.toString(pendingSince))) {
+ // The key is KEPT, in the pending state it is already in. Nothing is
+ // rolled back and nothing is discarded.
+ //
+ // Two earlier attempts here were both wrong in the same direction --
+ // they sent an accepted key back through attestation. Rolling the state
+ // to new while KEY_ATTEST_STARTED was present made it read as an
+ // interrupted attestation, which discards the key and mints another;
+ // clearing that marker first only moved the failure, because a key in
+ // STATE_NEW is submitted to attestKey again and Apple answers invalidKey
+ // for a one-time key it has already consumed -- which triggers recovery
+ // and burns a replacement anyway. Apple accepted this attestation. The
+ // key is good. The only thing missing is a timestamp.
+ //
+ // So the timestamp lives in memory for this process (set above, before
+ // the write was attempted) and registrationGraceRemaining() falls back
+ // to it. Across a restart the key is found PENDING with no deadline,
+ // which the request path already handles: it promotes to attested and
+ // uses it, the same fallback applied when a consumer never acknowledges.
+ // That is right here too -- the key IS attested with Apple; only the
+ // backend's confirmation is unknown -- and it costs no rate-limited key.
+ store.remove(KEY_ATTEST_STARTED);
+ instance.bootstrapInFlight = false;
+ instance.failBootstrapWaiters("App Attest is completing first-run "
+ + "registration for this device; retry shortly");
+ // Same reasoning as the state write above: the attestation object is
+ // one-time, so it goes to the caller rather than being discarded to
+ // report that a timestamp could not be stored.
+ break shieldAttestState;
+ }
+ // 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);
+ // 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
+ // 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
+ // 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");
+ }
+ // The retention above is what a concurrent request needs; the durable half is the
+ // state the block wrote. Only the fact is persisted, never the object -- an
+ // attestation covers one challenge and a later launch asks for a new one, so a
+ // stored copy could not be used across a restart anyway, whereas knowing nobody
+ // received it is what stops the next launch promoting a key the backend has never
+ // seen.
+ succeed(pending, attestToken, true);
+ }
+
+ /**
+ * The resource handed to a caller, where cancelling and delivering cannot both win.
+ *
+ *
{@link AsyncResource#complete} does not refuse an already-cancelled resource --
+ * it writes the value and fires the success callback -- and {@code cancel()} does not
+ * refuse an already-completed one either. Checking one before doing the other narrows
+ * the window without closing it, and what falls into it is the one object here that
+ * cannot be produced again: the application may have received and submitted an
+ * attestation while this class concluded delivery had lost and kept it for a retry,
+ * or the reverse. A claim taken once decides it.
+ *
+ *
The claim and its outcome are one word, not a claim followed by a verdict. Two
+ * fields are two publications, and between them the resource reads as
+ * claimed-but-not-delivered -- which is the state that tells the retained-attestation
+ * branch delivery has lost. Whoever asks has to see the answer or see nothing.
+ *
+ *
Only this class's own results are of this type, so nothing outside it can be
+ * affected by the stricter cancel().
+ */
+ static final class OneShotResource extends AsyncResource {
+
+ /** Nobody has taken the claim; every outcome is still possible. */
+ private static final int OPEN = 0;
+
+ /** The claim was spent by handing the value to this resource's caller. */
+ private static final int DELIVERED = 1;
+
+ /** The claim was spent by a cancellation or a failure, so delivery lost. */
+ private static final int LOST = 2;
+
+ /**
+ * The claim AND what it was spent on, in one word.
+ *
+ *
Two fields could not express this, however carefully they were ordered.
+ * Taking the claim and recording the outcome were separate publications, so
+ * between them the resource read as claimed-but-not-delivered -- which is exactly
+ * how {@code lostItsClaim()} spells "delivery lost". A same-nonce retry landing in
+ * that window took the retained attestation while the delivery it had just
+ * misread went on to complete, so two callers held one attestation. It is
+ * replay-protected, so the second submission is rejected, and an app that reads a
+ * rejection as a bad key resets one the backend had just registered. Deciding the
+ * outcome in the same compare-and-set that takes the claim leaves no interval to
+ * observe.
+ */
+ private final java.util.concurrent.atomic.AtomicInteger state =
+ new java.util.concurrent.atomic.AtomicInteger(OPEN);
+
+ /**
+ * True once this resource can never receive the attestation: its claim is spent
+ * and it was not spent by handing the value over.
+ *
+ *
What the retained-attestation branch needs to know. An attestation cannot be
+ * produced twice, so the retained copy exists for the caller that cancelled --
+ * but "cancelled" has to mean the delivery LOST, not merely that a delivery is
+ * still queued or in progress. Asking this way makes the answer a fact about a
+ * claim that has already been decided rather than a guess about one that has
+ * not.
+ */
+ boolean lostItsClaim() {
+ return state.get() == LOST;
+ }
+
+ @Override
+ public boolean cancel(boolean mayInterruptIfRunning) {
+ if (!state.compareAndSet(OPEN, LOST)) {
+ // Delivery got here first. Reporting the cancellation as refused is the
+ // truth -- the caller's success callback has run or is about to.
+ return false;
+ }
+ return super.cancel(mayInterruptIfRunning);
+ }
+
+ /** True when this call is the one that delivered. */
+ boolean deliver(String value) {
+ if (!state.compareAndSet(OPEN, DELIVERED)) {
+ return false;
+ }
+ super.complete(value);
+ return true;
+ }
+
+ /** True when this call is the one that failed it. */
+ boolean fail(Throwable t) {
+ if (!state.compareAndSet(OPEN, LOST)) {
+ return false;
+ }
+ super.error(t);
+ return true;
+ }
+ }
+
+ /** 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;
+ }
+ 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
+ // 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 what it already rejected.
+ fail(pending,
+ "App Attest state was reset while this request was in flight");
+ return;
}
+ // 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)));
}
- /** 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) {
+ /** 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;
}
+ if (instance != null) {
+ // 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) {
+ if (isStale(pending)) {
+ fail(pending,
+ "App Attest state was reset while this request was in flight");
+ return;
+ }
+ 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
+ // 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 {
+ // 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.
+ 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;
+ // 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. 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(deadline));
+ instance.currentBackoff = Math.min(backoff * 2, MAX_BACKOFF_MILLIS);
+ }
+ 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
+ // 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.
+ //
+ // 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
+ // 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;
+ fail(pending, message);
+ }
+
+ /** 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 ---------------------------------------------------------
+
+ 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) {
+ if (instance != null) {
+ pending.generation = instance.generation;
+ }
+ synchronized (REQUESTS) {
+ int rid = nextRequestId++;
+ REQUESTS.put(Integer.valueOf(rid), pending);
+ return rid;
+ }
+ }
+
+ /**
+ * 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));
+ }
+ }
+
+ /**
+ * 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) {
+ succeed(pending, token, false);
+ }
+
+ /// @param attestation true when `token` carries the one-time attestation object, so
+ /// the retained copy can be released once a caller has actually taken it. A
+ /// caller that cancelled never does, and the copy is what lets the next
+ /// request finish the registration instead of promoting a key the backend has
+ /// never seen.
+ private static void succeed(final PendingRequest pending, final String token,
+ final boolean attestation) {
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()) {
+ return;
+ }
+ if (instance == null) {
+ pending.result.deliver(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.fail(new RuntimeException("App Attest state was "
+ + "reset while this request was in flight"));
+ return;
+ }
+ }
+ // One claim decides it. A check followed by a completion left a window
+ // where a cancellation could win the check and the completion could
+ // happen anyway -- AsyncResource.complete() does not refuse a cancelled
+ // resource -- so the application received the attestation while this
+ // class concluded delivery had lost and kept it for a retry.
+ if (!pending.result.deliver(token)) {
+ return;
+ }
+ if (attestation) {
+ synchronized (instance.flowLock) {
+ if (token.equals(instance.undeliveredAttestToken)) {
+ String keyId = instance.undeliveredAttestKeyId;
+ instance.undeliveredAttestKeyId = null;
+ instance.undeliveredAttestNonce = null;
+ instance.undeliveredAttestToken = null;
+ instance.undeliveredAttestResult = null;
+ // Somebody has it, so the key is pending registration rather
+ // than pending delivery. A refused write leaves the persisted
+ // state saying nobody received it, which would have the next
+ // request discard a key the backend may already know -- so
+ // this process remembers what it did.
+ // Attempted whatever the stored state currently says, short
+ // of already-attested. Conditioning it on finding
+ // pendingUndelivered there meant the case where that write
+ // had ALSO failed -- storage still on "new", the acceptance
+ // recorded in its own item -- skipped the promotion and the
+ // in-memory note both. A restart then rehydrated the
+ // acceptance as undelivered with no retained object left, and
+ // discarded a key the backend may have registered.
+ SecureStorage store = SecureStorage.getInstance();
+ if (!STATE_ATTESTED.equals(store.get(KEY_STATE))
+ && !store.set(KEY_STATE, STATE_PENDING)) {
+ instance.deliveredAttestKeyId = keyId;
+ }
+ if (STATE_PENDING_UNDELIVERED.equals(instance.inMemoryState)) {
+ instance.inMemoryState = STATE_PENDING;
+ }
+ }
+ }
+ }
+ return;
}
});
}
- private static AsyncResource take(int requestId) {
- synchronized (REQUESTS) {
- return REQUESTS.remove(Integer.valueOf(requestId));
+ /**
+ * 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 OneShotResource r, final String msg) {
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ r.fail(new RuntimeException(msg));
+ }
+ });
+ }
+
+ private static void fail(final PendingRequest pending, final String msg) {
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ pending.result.fail(new RuntimeException(msg));
+ }
+ });
+ }
+
+ private static byte[] bytes(String s) {
+ if (s == null) {
+ return new byte[0];
+ }
+ try {
+ return s.getBytes("UTF-8");
+ } catch (java.io.UnsupportedEncodingException e) {
+ // 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);
+ }
+ }
+
+ 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 OneShotResource result;
+ final String nonce;
+ final int op;
+ 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(OneShotResource 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 4a5af9111fc..20f741f3698 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
@@ -4572,10 +4572,33 @@ public boolean isAttestationSupported() {
@Override
public com.codename1.util.AsyncResource requestIntegrityToken(String nonce) {
+ return deviceIntegrity().requestToken(nonce);
+ }
+
+ @Override
+ public void resetAttestation() {
+ deviceIntegrity().resetAttestation();
+ }
+
+ @Override
+ public void confirmAttestation(String keyId) {
+ deviceIntegrity().confirmAttestation(keyId);
+ }
+
+ /**
+ * 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);
}
- return deviceIntegrity.requestToken(nonce);
+ return deviceIntegrity;
}
@Override
@@ -9879,7 +9902,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
@@ -9887,6 +9939,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.
@@ -12574,10 +12631,88 @@ 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() {
- Boolean b = canExecute("cydia://package/com.example.package");
- return b != null && b.booleanValue();
+ String[] reasons = getCompromiseReasons();
+ for (int i = 0; i < reasons.length; i++) {
+ if ("jailbreak".equals(reasons[i])) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * 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;
+ }
+ }
+
+ /**
+ * 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();
+ 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");
+ }
+ }
+ // 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()]);
+ }
+
+ @Override
+ public boolean isDeviceCompromised() {
+ return getCompromiseReasons().length > 0;
}
@Override
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
index 1bd0ea7d7dc..682c8db1fa3 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
@@ -1033,14 +1033,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/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
index 68d474a7d26..2df03651e5a 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
@@ -434,6 +434,48 @@ private int getDeploymentTargetInt(BuildRequest request) {
}
+ /**
+ * The Facebook SDK pods, at whatever version the request asked for.
+ *
+ *
Its own method so the validation below has a caller a test can reach.
+ */
+ String facebookPods(BuildRequest request) {
+ String v = podVersionRequirement(
+ request.getArg("ios.facebook.version", "~>5.6.0"), "~>5.6.0");
+ return "FBSDKCoreKit " + v + ",FBSDKLoginKit " + v + ",FBSDKShareKit " + v;
+ }
+
+ /**
+ * A CocoaPods version requirement, or the default when the hint is not one.
+ *
+ *
This value is appended to the pod list and interpolated into the generated
+ * Podfile unescaped, and a Podfile is Ruby that {@code pod install} executes -- so a
+ * hint carrying a quote and a newline is code running in the build workspace, which
+ * holds signing material. Version requirements are a tiny language ("~> 5.6.0",
+ * ">= 5.0", "5.6.0"), so anything outside it is refused rather than escaped:
+ * escaping invites the next value that needs a different escape.
+ */
+ private String podVersionRequirement(String hint, String fallback) {
+ if (hint == null || hint.length() == 0) {
+ return fallback;
+ }
+ for (int i = 0; i < hint.length(); i++) {
+ char c = hint.charAt(i);
+ boolean ok = (c >= '0' && c <= '9')
+ || (c >= 'a' && c <= 'z')
+ || (c >= 'A' && c <= 'Z')
+ || c == '.' || c == '-' || c == '+'
+ || c == '~' || c == '>' || c == '<' || c == '=' || c == ' ';
+ if (!ok) {
+ log("ios.facebook.version '" + hint + "' is not a CocoaPods version "
+ + "requirement; using " + fallback);
+ return fallback;
+ }
+ }
+ return hint;
+ }
+
+
@Override
public boolean build(File sourceZip, BuildRequest request) throws BuildException {
@@ -564,9 +606,8 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException
String facebookAppId = request.getArg("facebook.appId", null);
boolean usePodsForFacebook = !request.getArg("ios.facebook.usePods", "true").equals("false") && facebookAppId != null && facebookAppId.length() > 0;
if (usePodsForFacebook) {
- String fbPodsVersion = request.getArg("ios.facebook.version", "~>5.6.0");
addMinDeploymentTarget("10.0");
- iosPods += (((iosPods.length() > 0) ? ",":"") + "FBSDKCoreKit "+fbPodsVersion+",FBSDKLoginKit "+fbPodsVersion+",FBSDKShareKit "+fbPodsVersion);
+ iosPods += (((iosPods.length() > 0) ? ",":"") + facebookPods(request));
}
String googleAdUnitId = request.getArg("ios.googleAdUnitId", request.getArg("google.adUnitId", null));
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..122084712ef 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,101 @@ 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");
+ }
+
+ /// And a listener attached after a FAILURE arrives on the EDT too.
+ ///
+ /// `onResult` is `ready` followed by `except`, and only the first was marshalled --
+ /// so this exact sequence, a worker registering on a resource that had already
+ /// failed, ran the error half synchronously on that worker. An app handling a health
+ /// error by showing a dialog or updating a label was then touching the UI off the
+ /// EDT, which is what this class exists to prevent, reached through the other half of
+ /// the same method.
+ @Test
+ void aListenerAttachedAfterAFailureAlsoArrivesOnTheEdt() {
+ final FakeHealthStore store = new FakeHealthStore();
+ store.holdRead = true;
+ final Landing> landing =
+ new Landing>();
+ CN.invokeAndBlock(new Runnable() {
+ public void run() {
+ assertFalse(CN.isEdt(), "the operation must start off the EDT");
+ AsyncResource> failed = store.readSamples(
+ new SampleQuery()
+ .addType(HealthDataType.STEPS)
+ .setTimeRange(HealthTimeRange.between(0L, 1000L)));
+ store.heldRead.error(new IllegalStateException("the backend said no"));
+ long deadline = System.currentTimeMillis() + 10_000L;
+ while (!failed.isDone() && System.currentTimeMillis() < deadline) {
+ try {
+ Thread.sleep(10L);
+ } catch (InterruptedException ex) {
+ return;
+ }
+ }
+ assertTrue(failed.isDone(), "the fixture needs a settled failure");
+ failed.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.failed.get(), "and it must be the error half");
+ assertTrue(landing.onEdt.get(),
+ "an error delivered to a late listener is still a callback an app "
+ + "handles by touching the UI, so it belongs on the EDT");
+ }
+
@Test
void aDeleteDeliversOnTheEdt() {
final FakeHealthStore store = new FakeHealthStore();
diff --git a/maven/core-unittests/src/test/java/com/codename1/io/NetworkGuardTestAccess.java b/maven/core-unittests/src/test/java/com/codename1/io/NetworkGuardTestAccess.java
new file mode 100644
index 00000000000..48a79abf8aa
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/io/NetworkGuardTestAccess.java
@@ -0,0 +1,42 @@
+/*
+ * 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;
+
+/**
+ * Reaches {@code NetworkManager}'s package-private test hook from a test that lives in
+ * another package.
+ *
+ *
The guard slot seals on first install, deliberately, so a test that installs one has
+ * to be able to put the slot back or it decides the behaviour of every test that runs
+ * after it in the same JVM.
+ */
+public final class NetworkGuardTestAccess {
+
+ private NetworkGuardTestAccess() {
+ }
+
+ /** Drops the installed guard and unseals the slot. */
+ public static void reset() {
+ NetworkManager.resetNetworkGuardForTesting();
+ }
+}
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/io/WebSocketTest.java b/maven/core-unittests/src/test/java/com/codename1/io/WebSocketTest.java
index 95a53fdb7d4..5d7a63f47e6 100644
--- a/maven/core-unittests/src/test/java/com/codename1/io/WebSocketTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/io/WebSocketTest.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.io;
import com.codename1.impl.WebSocketEventSink;
@@ -14,6 +36,7 @@
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
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;
@@ -190,6 +213,74 @@ void isSupportedDelegatesToImplementation() {
assertEquals(true, WebSocket.isSupported());
}
+ /**
+ * Extension negotiation is ignored, like the other handshake-control headers.
+ *
+ *
Extensions change what the FRAMES mean, and no reader in this repo implements
+ * one. Emitting the header let a caller ask for permessage-deflate; a compliant
+ * server then agrees, sets RSV1 and sends compressed payloads -- and the readers mask
+ * off the opcode and pass the bytes through, so text arrives as mojibake and binary
+ * arrives compressed. The symptom shows up at the application, nowhere near the
+ * header that caused it, and only against servers that happen to offer the
+ * extension.
+ */
+ @Test
+ void extensionNegotiationIsNotEmittedInTheHandshake() {
+ MockImpl impl = new MockImpl("wss://example.com/socket");
+ impl.setRequestHeader("Sec-WebSocket-Extensions", "permessage-deflate");
+ impl.setRequestHeader("SEC-WEBSOCKET-EXTENSIONS", "permessage-deflate");
+ impl.setRequestHeader("X-Mine", "kept");
+
+ String emitted = impl.testHandshakeHeaders();
+
+ assertFalse(emitted.toLowerCase().contains("sec-websocket-extensions"),
+ "an extension this client cannot decode must not be negotiated: " + emitted);
+ assertTrue(emitted.contains("X-Mine: kept"),
+ "and an ordinary header still goes out: " + emitted);
+ }
+
+ /**
+ * A reserved header cannot be smuggled past the list by adding a space.
+ *
+ *
The comparison screened names only for CR and LF, so
+ * {@code "Sec-WebSocket-Extensions "} did not match the reserved name and was
+ * emitted. Lenient servers trim that and negotiate the extension -- and these readers
+ * neither look at RSV1 nor inflate anything, so every compressed frame afterwards is
+ * garbage. A list that any non-token character walks around is not a list.
+ */
+ @Test
+ void aReservedHeaderWithTrailingSpaceIsNotEmitted() {
+ MockImpl impl = new MockImpl("wss://example.com/socket");
+ impl.setRequestHeader("Sec-WebSocket-Extensions ", "permessage-deflate");
+ impl.setRequestHeader("Sec-WebSocket-Extensions\t", "permessage-deflate");
+ impl.setRequestHeader("X-Fine", "yes");
+
+ String emitted = impl.testHandshakeHeaders();
+
+ assertFalse(emitted.toLowerCase().contains("permessage-deflate"),
+ "a compression extension these readers cannot decode must not reach the "
+ + "wire, whatever the name was padded with: " + emitted);
+ assertTrue(emitted.contains("X-Fine: yes"),
+ "and an ordinary header is unaffected: " + emitted);
+ }
+
+ /** Names that are not field tokens are refused rather than repaired. */
+ @Test
+ void aHeaderNameThatIsNotATokenIsRefused() {
+ MockImpl impl = new MockImpl("wss://example.com/socket");
+ impl.setRequestHeader("X Bad", "1");
+ impl.setRequestHeader("X:Bad", "2");
+ impl.setRequestHeader("X\u00e9", "3");
+ impl.setRequestHeader("X-Good", "4");
+
+ String emitted = impl.testHandshakeHeaders();
+
+ assertFalse(emitted.contains("X Bad"), emitted);
+ assertFalse(emitted.contains("X:Bad"), emitted);
+ assertFalse(emitted.contains("3"), emitted);
+ assertTrue(emitted.contains("X-Good: 4"), emitted);
+ }
+
private final class WebSocketingImpl extends TestCodenameOneImplementation {
boolean supported = true;
@@ -260,6 +351,13 @@ String[] testRequestedSubprotocols() {
return super.requestedSubprotocols();
}
+ /// Test-only view of what the handshake would actually emit.
+ String testHandshakeHeaders() {
+ StringBuilder sb = new StringBuilder();
+ super.appendRequestHeaders(sb);
+ return sb.toString();
+ }
+
void testSelect(String protocol) {
super.setSelectedSubprotocol(protocol);
}
diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackSocketTransportTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackSocketTransportTest.java
index 9726b9a35e1..69b53439ef7 100644
--- a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackSocketTransportTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackSocketTransportTest.java
@@ -34,6 +34,7 @@
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -407,4 +408,70 @@ void aPipedClientRoundTrips() throws Exception {
clientWrites.flush();
assertEquals("{\"id\":42}", transport.readMessage());
}
+
+ /**
+ * A connection belongs to the listener that accepted it, not to whichever transport
+ * is open when its callback runs.
+ *
+ *
The callback used to resolve the process-wide active transport at execution
+ * time. A transport that accepted a client just before closing therefore handed those
+ * already-accepted streams to whatever opened next -- a client of the stopped
+ * listener taking over the new server's session, which is the one thing the
+ * single-agent rule exists to prevent.
+ */
+ @Test
+ void aConnectionAcceptedByOneTransportIsNotHandedToTheNext() throws Exception {
+ MCPLoopbackSocketTransport accepting = new MCPLoopbackSocketTransport(47811);
+ MCPLoopbackSocketTransport.setActiveForTesting(accepting);
+ // Constructed while `accepting` is the listener, which is when the socket API
+ // creates it: as the connection is accepted.
+ MCPLoopbackSocketTransport.Connection connection =
+ new MCPLoopbackSocketTransport.Connection();
+
+ accepting.close();
+ transport = new MCPLoopbackSocketTransport(47811);
+ MCPLoopbackSocketTransport.setActiveForTesting(transport);
+ // The replacement's own client, already talking.
+ transport.attach(new ByteArrayInputStream("{\"id\":42}\n".getBytes("UTF-8")),
+ new ByteArrayOutputStream());
+
+ final boolean[] orphanClosed = {false};
+ final InputStream orphanIn = new InputStream() {
+ private final byte[] data = "{\"id\":99}\n".getBytes("UTF-8");
+ private int pos;
+
+ @Override
+ public int read() {
+ return pos < data.length ? data[pos++] & 0xff : -1;
+ }
+
+ @Override
+ public void close() {
+ orphanClosed[0] = true;
+ }
+ };
+ // On its own thread with a deadline. The callback deliberately parks for the life
+ // of a session it accepts, so under the old behaviour -- where it adopted whatever
+ // transport was open -- this call never returns. A regression has to fail the test
+ // rather than hang the suite.
+ final MCPLoopbackSocketTransport.Connection handoff = connection;
+ Thread callback = new Thread(new Runnable() {
+ public void run() {
+ handoff.connectionEstablished(orphanIn, new ByteArrayOutputStream());
+ }
+ }, "mcp-orphan-connection");
+ callback.setDaemon(true);
+ callback.start();
+ callback.join(5000L);
+ assertFalse(callback.isAlive(),
+ "a connection whose listener has closed is released, not parked as if it "
+ + "were serving a session");
+
+ assertEquals("{\"id\":42}", transport.readMessage(),
+ "the replacement must still be serving ITS client -- an attach from the "
+ + "previous listener's connection would have dropped it");
+ assertTrue(orphanClosed[0],
+ "and the orphaned connection is closed rather than left half-attached");
+ MCPLoopbackSocketTransport.setActiveForTesting(null);
+ }
}
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..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
@@ -29,7 +29,10 @@
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -98,4 +101,399 @@ 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();
+ }
+ }
+ }
+
+ /// 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 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
+ // 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);
+ }
+
+ /// 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
+ // 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/maven/core-unittests/src/test/java/com/codename1/mcp/MCPRestartReaderFenceTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPRestartReaderFenceTest.java
new file mode 100644
index 00000000000..66c365e96b5
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPRestartReaderFenceTest.java
@@ -0,0 +1,140 @@
+/*
+ * 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.mcp;
+
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+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.assertTrue;
+
+/**
+ * A restart over the same transport instance must not leave two readers on one stream.
+ *
+ *
Both production transports clear their closed flag in {@code open()}, so a reader
+ * still parked in {@code readMessage()} from the previous generation is looking at a live
+ * stream again the moment the replacement opens. It can then take a frame the new client
+ * sent -- handled by a loop belonging to a stopped server, or dropped entirely, which
+ * presents as a client whose first request never gets an answer.
+ *
+ *
The transport here is deliberately the awkward-but-legal shape: {@code readMessage()}
+ * parks until {@code close()} releases it, and reopening makes it readable again.
+ */
+class MCPRestartReaderFenceTest {
+
+ /** Long enough that a slow machine cannot fail it; short enough to notice. */
+ private static final long TIMEOUT_MS = 10000L;
+
+ @Test
+ void theSecondGenerationWaitsForTheFirstReaderToLeave() throws Exception {
+ SlowReaderTransport transport = new SlowReaderTransport();
+ MCPServer server = new MCPServer();
+ server.start(transport);
+
+ assertTrue(transport.reading.await(TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "the first generation should be inside readMessage()");
+
+ // A read that does not unwind the instant close() is called. Real ones usually
+ // do -- a closed socket ends the read -- but "usually" is the whole problem: the
+ // fence exists for the interval between close() and the read noticing, and this
+ // fixture is that interval held open so it can be observed.
+ server.stop();
+ server.start(transport);
+ Thread.sleep(300L);
+
+ assertEquals(1, transport.opensSeen.get(),
+ "the replacement must not open the transport while a reader from the "
+ + "previous generation is still inside it -- open() clears the closed "
+ + "flag, so that reader is looking at a live stream again and can take "
+ + "the new client's first frame");
+ assertFalse(transport.openedWithReaderInside,
+ "and if it ever does, the two generations are sharing one stream");
+
+ transport.letTheStaleReadFinish();
+ assertTrue(transport.reopened.await(TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "once the stale reader leaves, the replacement gets its own open()");
+
+ transport.letTheStaleReadFinish();
+ server.stop();
+ assertFalse(server.isRunning());
+ }
+
+ /**
+ * Parks in {@code readMessage()} until the test says otherwise, and becomes readable
+ * again on reopen -- which is exactly what makes a stale reader dangerous.
+ */
+ private static final class SlowReaderTransport implements MCPTransport {
+
+ private final CountDownLatch reading = new CountDownLatch(1);
+ private final CountDownLatch reopened = new CountDownLatch(1);
+ private final AtomicInteger opensSeen = new AtomicInteger();
+ private final AtomicInteger readersInside = new AtomicInteger();
+ private volatile boolean openedWithReaderInside;
+ private volatile CountDownLatch release = new CountDownLatch(1);
+
+ void letTheStaleReadFinish() {
+ release.countDown();
+ }
+
+ @Override
+ public void open() throws IOException {
+ if (readersInside.get() > 0) {
+ openedWithReaderInside = true;
+ }
+ // A fresh gate per open, like a transport clearing its closed flag.
+ release = new CountDownLatch(1);
+ if (opensSeen.incrementAndGet() >= 2) {
+ reopened.countDown();
+ }
+ }
+
+ @Override
+ public synchronized void close() {
+ // Deliberately does NOT end the parked read. See the test.
+ }
+
+ @Override
+ public String readMessage() throws IOException {
+ CountDownLatch gate = release;
+ readersInside.incrementAndGet();
+ reading.countDown();
+ try {
+ gate.await(TIMEOUT_MS * 3, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("interrupted");
+ } finally {
+ readersInside.decrementAndGet();
+ }
+ return null;
+ }
+
+ @Override
+ public void writeMessage(String message) throws IOException {
+ }
+ }
+}
diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerLockOrderTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerLockOrderTest.java
new file mode 100644
index 00000000000..c7b472534c6
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerLockOrderTest.java
@@ -0,0 +1,118 @@
+/*
+ * 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.mcp;
+
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Stopping a server whose transport is still inside a blocking {@code open()}.
+ *
+ *
{@link MCPTransport} is a public interface, so an implementation is entitled to write
+ * {@code synchronized void close()} -- and a transport that waits for a client inside
+ * {@code open()} is the normal shape, since that is what {@code close()} exists to
+ * interrupt. Those two facts together used to hang the process: the reader thread held the
+ * transport's own monitor across {@code open()} and wanted the server monitor inside
+ * {@code isCurrent()}, while {@code stop()} held the server monitor and wanted the
+ * transport's monitor inside {@code close()}. Neither ever moves again, and the one call
+ * that could have ended the blocking {@code open()} is the one that is stuck.
+ *
+ *
Written with a timeout rather than a plain join, so a regression fails the suite
+ * instead of hanging it.
+ */
+class MCPServerLockOrderTest {
+
+ /** Long enough that a slow machine cannot fail it; short enough to notice. */
+ private static final long TIMEOUT_MS = 10000L;
+
+ @Test
+ void stopEndsABlockingOpenOnATransportThatSynchronizesItself() throws Exception {
+ BlockingTransport transport = new BlockingTransport();
+ MCPServer server = new MCPServer();
+ server.start(transport);
+
+ assertTrue(transport.opening.await(TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "the reader thread should have reached open()");
+
+ final CountDownLatch stopped = new CountDownLatch(1);
+ Thread stopper = new Thread(new Runnable() {
+ public void run() {
+ server.stop();
+ stopped.countDown();
+ }
+ }, "mcp-lock-order-stop");
+ stopper.setDaemon(true);
+ stopper.start();
+
+ assertTrue(stopped.await(TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "stop() must not wait on a lock the blocked open() is holding");
+ assertTrue(transport.closed.await(TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "and it has to actually reach close(), which is what ends the open()");
+ assertFalse(server.isRunning());
+ }
+
+ /**
+ * The shape the interface allows and the fix has to tolerate: {@code open()} parks
+ * until {@code close()} says otherwise, and {@code close()} is synchronized on the
+ * transport.
+ */
+ private static final class BlockingTransport implements MCPTransport {
+
+ private final CountDownLatch opening = new CountDownLatch(1);
+ private final CountDownLatch closed = new CountDownLatch(1);
+ private final CountDownLatch release = new CountDownLatch(1);
+
+ @Override
+ public void open() throws IOException {
+ opening.countDown();
+ try {
+ // Waits for a client, as a real listening transport does. Bounded only so
+ // a failing run ends rather than parking a thread forever.
+ release.await(TIMEOUT_MS * 3, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("interrupted");
+ }
+ }
+
+ @Override
+ public synchronized void close() {
+ release.countDown();
+ closed.countDown();
+ }
+
+ @Override
+ public String readMessage() throws IOException {
+ return null;
+ }
+
+ @Override
+ public void writeMessage(String message) throws IOException {
+ }
+ }
+}
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..d7f40343007
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldApiTest.java
@@ -0,0 +1,640 @@
+/*
+ * 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 com.codename1.io.NetworkGuard;
+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.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;
+
+/**
+ * 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));
+ }
+
+ /**
+ * 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
+ 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, 60000, null,
+ System.nanoTime() - 120_000_000_000L).isValid());
+ }
+
+ @Test
+ void expiredTokenReportsZeroRatherThanNegativeRemainingTime() {
+ 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, 10000, null,
+ System.nanoTime() - 1_000_000_000L).shouldRefresh(50));
+ // 60% used -> refresh.
+ assertTrue(new ShieldToken("t", ShieldStatus.OK, now, 10000, null,
+ System.nanoTime() - 6_000_000_000L).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"));
+ }
+
+ /**
+ * 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();
+ assertEquals(FailureMode.OPEN, c.getDefaultFailureMode());
+ assertEquals(ShieldConfig.DEFAULT_TOKEN_HEADER, c.getTokenHeader());
+ assertFalse(c.hasProtectedHosts());
+ assertTrue(c.isCollectSignals());
+ 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) {
+ 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"));
+ }
+
+ /**
+ * 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
+ 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());
+ }
+
+ /// Re-reporting an identical observation notifies nobody.
+ ///
+ /// AppShield.getSignals() re-adds everything collectSignals() returns, so a listener
+ /// that refreshes its view by calling getSignals() notified itself -- forever. And
+ /// a detector polling on a timer queued a runnable per poll per signal onto the EDT,
+ /// which is an unbounded queue behind a bus whose selling point is that it is
+ /// bounded. A repeat carries no information; a change does.
+ @Test
+ void anUnchangedSignalDoesNotNotifyAgain() {
+ final int[] notified = {0};
+ ShieldListener listener = new ShieldListener() {
+ public void signalRaised(ShieldSignal signal) {
+ notified[0]++;
+ }
+ public void tokenRefreshed(ShieldToken token) {
+ }
+ public void statusChanged(ShieldStatus status) {
+ }
+ };
+ AppShield.addListener(listener);
+ try {
+ String id = "test.repeat." + System.nanoTime();
+ ShieldSignals.add(id, 50, "same");
+ int afterFirst = notified[0];
+ assertTrue(afterFirst >= 1, "the first observation has to be reported");
+
+ ShieldSignals.add(id, 50, "same");
+ ShieldSignals.add(id, 50, "same");
+ assertEquals(afterFirst, notified[0],
+ "an identical repeat says nothing new and must not notify");
+
+ // A changed severity or detail IS a new observation.
+ ShieldSignals.add(id, 90, "same");
+ assertEquals(afterFirst + 1, notified[0], "a raised severity is news");
+ ShieldSignals.add(id, 90, "different");
+ assertEquals(afterFirst + 2, notified[0], "and so is a changed detail");
+ } finally {
+ AppShield.removeListener(listener);
+ }
+ }
+
+ @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());
+ }
+
+ /// A name that is not a header name is refused before anything asks what it means.
+ ///
+ /// Folding case does not make a malformed name comparable: `Cookie ` normalizes to
+ /// `cookie `, matches nothing on any reserved list, and walks past the checks written
+ /// for exactly that name. It then goes on the wire -- `HttpURLConnection` accepts a
+ /// trailing space -- where an intermediary may drop the malformed field or read it as
+ /// transport metadata, so a fail-closed host gets attach() reporting success and a
+ /// backend that received no token.
+ @Test
+ void aNameThatIsNotAnHttpTokenIsRefused() {
+ String[] refused = {
+ // The two that slip past the reserved lists by a single character.
+ "Cookie ", "Content-Length ",
+ // And the shapes that are not field names at all.
+ "X Attest", "X-Attest:", "X-Attest\r\nInjected", "X-Attest\u00e9",
+ "(comment)", "X-Attest=1", "X@Attest",
+ };
+ for (String name : refused) {
+ assertThrows(IllegalArgumentException.class,
+ () -> new ShieldConfig().tokenHeader(name),
+ name + " is not a legal header name and must not reach the wire");
+ }
+
+ // The punctuation RFC 9110 does allow in a token stays allowed.
+ assertEquals("X-CN1_Attest.1",
+ new ShieldConfig().tokenHeader("X-CN1_Attest.1").getTokenHeader());
+ }
+
+ /// Headers the transport owns are refused too.
+ ///
+ /// Framing and routing values are computed by the platform from the request it is
+ /// about to send, and hop-by-hop names are defined to be consumed by the next hop
+ /// and not forwarded. A token in any of them is overwritten, refused, or stripped in
+ /// transit -- and none of that is visible on the client, where attach() returns
+ /// having set the header. The developer sees a working call and a backend insisting
+ /// they are unauthenticated, which is a long way from the cause.
+ @Test
+ void transportOwnedHeadersAreRefusedAsTheTokenHeader() {
+ String[] refused = {
+ "Host", "Content-Length", "Transfer-Encoding", "Connection",
+ "Keep-Alive", "Proxy-Connection", "TE", "Trailer", "Upgrade",
+ // Hop-by-hop like the rest, and the two that do not look like plumbing: a
+ // forward proxy consumes them as its own credentials, so the origin never
+ // sees the token -- and only for users behind such a proxy, which is a
+ // network nobody testing this was on.
+ "Proxy-Authorization", "Proxy-Authenticate",
+ // Written by a port on the way out: Android clears Accept-Encoding on HEAD
+ // around a platform bug, and sets X-HTTP-Method-Override for its PATCH
+ // fallback. Conditional on the method and the platform, so a token in one of
+ // them survives every test that does not use that exact shape.
+ "Accept-Encoding", "X-HTTP-Method-Override",
+ // JavaSEPort rewrites User-Agent to a fixed BlackBerry string for any URL
+ // containing facebook.com, which makes the loss conditional on the host.
+ "User-Agent",
+ // Not framing, but overwritten by the request itself: ConnectionRequest
+ // emits userHeaders and then sets Cookie from its own cookie store.
+ "Cookie"
+ };
+ for (int i = 0; i < refused.length; i++) {
+ final String name = refused[i];
+ assertThrows(IllegalArgumentException.class,
+ () -> new ShieldConfig().tokenHeader(name),
+ name + " is transport metadata and cannot carry the token");
+ assertThrows(IllegalArgumentException.class,
+ () -> new ShieldConfig().tokenHeader(name.toLowerCase()),
+ "and the check is case-insensitive, like the header itself");
+ }
+ // A name that merely looks similar is not refused: the list is exact, not a
+ // prefix match, or an app's own X-Connection-Id would be rejected for no reason.
+ assertEquals("X-Connection-Id",
+ new ShieldConfig().tokenHeader("X-Connection-Id").getTokenHeader());
+ }
+
+ /**
+ * The cookie header's NAME is a runtime setting, so the refusal cannot be a constant.
+ *
+ *
{@code ConnectionRequest.setCookieHeader} renames it app-wide. An app that
+ * renames it and then picks the same name for the token loses the token to its own
+ * cookie string, which is written after the request's headers -- the same way the
+ * default name loses it, and just as silently.
+ */
+ @Test
+ void aRenamedCookieHeaderIsRefusedTooAndTheDefaultIsRestored() {
+ String original = com.codename1.io.ConnectionRequest.getCookieHeader();
+ try {
+ com.codename1.io.ConnectionRequest.setCookieHeader("X-App-Session");
+ assertThrows(IllegalArgumentException.class,
+ () -> new ShieldConfig().tokenHeader("X-App-Session"),
+ "the configured cookie header cannot carry the token either");
+ assertThrows(IllegalArgumentException.class,
+ () -> new ShieldConfig().tokenHeader("x-app-session"),
+ "and the comparison is case-insensitive like the header");
+ // The literal Cookie header stays refused whatever the app renamed its own
+ // to: it is a header with its own meaning, and the static entry covers it.
+ // The dynamic check is in addition to that list, not instead of it.
+ assertThrows(IllegalArgumentException.class,
+ () -> new ShieldConfig().tokenHeader("Cookie"));
+ } finally {
+ com.codename1.io.ConnectionRequest.setCookieHeader(original);
+ }
+ }
+
+ // --- 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);
+ }
+
+ /**
+ * An identical repeat refreshes the stored observation even though it notifies
+ * nobody.
+ *
+ *
The bus documents itself as holding the most recent observation of each signal,
+ * and a persistent one -- a root, a hooking framework -- is re-reported on every
+ * poll. Keeping the first object meant the timestamp the engine and the server were
+ * shown stayed at the first sighting, hours after the fact, while the device was
+ * still compromised. "When did this device last look compromised" is a question the
+ * answer is used for.
+ */
+ @Test
+ void anIdenticalRepeatRefreshesTheStoredObservationWithoutNotifying() {
+ ShieldSignals.clear();
+ final int[] notifications = {0};
+ ShieldListener counter = new ShieldListener() {
+ public void signalRaised(ShieldSignal signal) {
+ notifications[0]++;
+ }
+
+ public void tokenRefreshed(ShieldToken token) {
+ }
+
+ public void statusChanged(ShieldStatus status) {
+ }
+ };
+ ShieldSignals.addListener(counter);
+ try {
+ ShieldSignal first = new ShieldSignal(ShieldSignal.ROOT, 70, "su");
+ ShieldSignals.add(first);
+ assertEquals(1, notifications[0]);
+
+ ShieldSignal later = new ShieldSignal(ShieldSignal.ROOT, 70, "su");
+ ShieldSignals.add(later);
+ assertEquals(1, notifications[0],
+ "an identical repeat must not notify again");
+
+ ShieldSignal[] snapshot = ShieldSignals.snapshot();
+ assertEquals(1, snapshot.length);
+ // Identity rather than the timestamp value: two observations a moment apart
+ // can carry the same millisecond, and what matters is which object the bus
+ // is holding.
+ assertSame(later, snapshot[0],
+ "the snapshot has to hold the most recent observation");
+ } finally {
+ ShieldSignals.removeListener(counter);
+ ShieldSignals.clear();
+ }
+ }
+
+ @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();
+ }
+
+}
diff --git a/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldInitOrderTest.java b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldInitOrderTest.java
new file mode 100644
index 00000000000..07400e8430c
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldInitOrderTest.java
@@ -0,0 +1,692 @@
+/*
+ * 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 com.codename1.io.ConnectionRequest;
+import com.codename1.util.AsyncResource;
+import com.codename1.junit.UITestBase;
+import com.codename1.io.NetworkGuard;
+import com.codename1.io.NetworkGuardTestAccess;
+import com.codename1.io.NetworkManager;
+import com.codename1.security.shield.spi.EngineContext;
+import com.codename1.security.shield.spi.ShieldEngine;
+import com.codename1.security.shield.spi.ShieldEngineRegistry;
+import com.codename1.security.shield.spi.ShieldEngineTestAccess;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+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;
+
+/**
+ * What a request that starts while {@code AppShield.init()} is still running gets.
+ *
+ *
The answer has to be "the shield, a moment later" and never "no shield at all". A
+ * cold start is the one moment when an app fires its first protected request and when
+ * initialization takes longest, so the window is not theoretical -- it is the common
+ * case. Both bugs this covers were invisible in normal use: everything succeeded, the
+ * request simply left without a token and without a pin check.
+ */
+class ShieldInitOrderTest extends UITestBase {
+
+ /** Long enough that a real regression cannot pass by being fast. */
+ private static final long GENEROUS_TIMEOUT_MS = 10000L;
+
+ /** Short: it bounds how long a *correct* implementation is observed blocking. */
+ private static final long BLOCKED_OBSERVATION_MS = 300L;
+
+ private SlowEngine engine;
+
+ @BeforeEach
+ void setUpShield() {
+ NetworkGuardTestAccess.reset();
+ ShieldEngineTestAccess.reset();
+ AppShield.resetForTesting();
+ engine = new SlowEngine();
+ }
+
+ @AfterEach
+ void tearDownShield() {
+ engine.release();
+ NetworkGuardTestAccess.reset();
+ ShieldEngineTestAccess.reset();
+ AppShield.resetForTesting();
+ }
+
+ /**
+ * The guard exists before the engine is given a chance to run.
+ *
+ *
Publishing an "initialization in progress" flag is not enough on its own. A
+ * concurrent request only reaches the code that waits on that flag if something routes
+ * it there, and the only thing that does is the network guard. Installed after
+ * {@code engine.initialize()}, the guard did not exist yet, so
+ * {@code ConnectionRequest.performOperationComplete()} found none and opened the
+ * connection directly -- no token, no pin check, for as long as the engine took to
+ * start.
+ */
+ @Test
+ void aRequestStartingDuringInitializationFindsAGuardRatherThanNone() throws Exception {
+ Thread init = initOnAnotherThread();
+ assertTrue(engine.entered.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "the engine should have been asked to initialize");
+
+ assertNotNull(NetworkManager.getNetworkGuard(),
+ "a request starting now would find no guard and skip the shield entirely");
+
+ engine.release();
+ init.join(GENEROUS_TIMEOUT_MS);
+ assertFalse(init.isAlive(), "init() should have finished");
+ }
+
+ /**
+ * And going through that guard waits for initialization rather than sailing past it.
+ *
+ *
Installing the guard early is only safe because every path through it waits: the
+ * alternative -- a guard that answers while the engine is half-built -- would attach
+ * nothing and report success, which is the same hole one layer down.
+ */
+ @Test
+ void thatGuardBlocksTheRequestUntilInitializationFinishes() throws Exception {
+ Thread init = initOnAnotherThread();
+ assertTrue(engine.entered.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "the engine should have been asked to initialize");
+
+ NetworkGuard guard = NetworkManager.getNetworkGuard();
+ assertNotNull(guard);
+
+ final RecordingRequest request = new RecordingRequest();
+ request.setUrl("https://api.example.com/secure");
+ final CountDownLatch done = new CountDownLatch(1);
+ final AtomicReference failure = new AtomicReference();
+ final NetworkGuard target = guard;
+ Thread caller = new Thread(new Runnable() {
+ public void run() {
+ try {
+ target.beforeRequest(request);
+ } catch (Throwable t) {
+ failure.set(t);
+ }
+ done.countDown();
+ }
+ }, "shield-init-order-request");
+ caller.setDaemon(true);
+ caller.start();
+
+ assertFalse(done.await(BLOCKED_OBSERVATION_MS, TimeUnit.MILLISECONDS),
+ "the request must wait for initialization, not proceed without a token");
+ assertNull(request.attached(),
+ "and nothing should have been attached while the engine was still starting");
+
+ engine.release();
+ assertTrue(done.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "the wait has to end when initialization does");
+ assertNull(failure.get(), "the request should have succeeded: " + failure.get());
+ assertEquals("token-from-a-fully-initialized-engine", request.attached(),
+ "once initialization finished the token belongs on the request");
+
+ init.join(GENEROUS_TIMEOUT_MS);
+ assertFalse(init.isAlive(), "init() should have finished");
+ }
+
+ /**
+ * An interrupt while waiting for startup does not turn a fail-closed host into an
+ * open one.
+ *
+ *
The wait used to return quietly on interrupt, which is indistinguishable from
+ * "initialization finished" -- the caller then saw the shield as uninitialized, took
+ * its early return, and the request went out with no token and no pin check.
+ * {@code ConnectionRequest} does not consult the interrupt flag either, so nothing
+ * further down stopped it. The one request that must never leave unprotected is
+ * exactly this one.
+ */
+ @Test
+ void anInterruptedWaitStillRefusesAFailClosedRequest() throws Exception {
+ Thread init = initOnAnotherThread();
+ assertTrue(engine.entered.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "the engine should have been asked to initialize");
+
+ final NetworkGuard guard = NetworkManager.getNetworkGuard();
+ final RecordingRequest request = new RecordingRequest();
+ request.setUrl("https://api.example.com/secure");
+ final CountDownLatch done = new CountDownLatch(1);
+ final AtomicReference outcome = new AtomicReference();
+ Thread caller = new Thread(new Runnable() {
+ public void run() {
+ try {
+ guard.beforeRequest(request);
+ } catch (Throwable t) {
+ outcome.set(t);
+ }
+ done.countDown();
+ }
+ }, "shield-interrupted-request");
+ caller.setDaemon(true);
+ caller.start();
+
+ // Let it park in the wait, then interrupt it there.
+ assertFalse(done.await(BLOCKED_OBSERVATION_MS, TimeUnit.MILLISECONDS));
+ caller.interrupt();
+
+ assertTrue(done.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "the interrupt has to end the wait");
+ assertNull(request.attached(),
+ "nothing can be attached without an initialized shield");
+ assertTrue(outcome.get() instanceof ShieldException,
+ "a fail-closed host must refuse the request rather than let it go out "
+ + "unprotected, got: " + outcome.get());
+ assertEquals(ShieldStatus.NOT_INITIALIZED,
+ ((ShieldException) outcome.get()).getStatus());
+
+ engine.release();
+ init.join(GENEROUS_TIMEOUT_MS);
+ }
+
+ /**
+ * Renaming the token header does not leave the previous one on a reused request.
+ *
+ *
{@code ShieldConfig} is mutable and {@code getConfig()} hands out the live
+ * instance, so the name that has to be cleared is the one the header was SET under,
+ * which may no longer be the configured one. Clearing only the current name left a
+ * bearer token in the request under its old name -- and a redirect to an unprotected
+ * host carried it there, which is precisely what the clearing exists to prevent.
+ */
+ @Test
+ void renamingTheTokenHeaderStillClearsTheOneAlreadyAttached() throws Exception {
+ Thread init = initOnAnotherThread();
+ engine.release();
+ init.join(GENEROUS_TIMEOUT_MS);
+ assertFalse(init.isAlive());
+
+ RecordingRequest request = new RecordingRequest();
+ request.setUrl("https://api.example.com/secure");
+ AppShield.attach(request);
+ assertEquals("token-from-a-fully-initialized-engine", request.attached(),
+ "the fixture must actually attach something, or this proves nothing");
+
+ // The app renames its header between attempts, then the request is redirected to
+ // a host the shield does not protect.
+ AppShield.getConfig().tokenHeader("X-Other-Attest");
+ request.setUrl("https://unprotected.example.com/elsewhere");
+ AppShield.attach(request);
+
+ assertNull(request.headerValue("X-CN1-Attest"),
+ "the token attached under the old name must not survive to another host");
+ assertNull(request.headerValue("X-Other-Attest"));
+ }
+
+ /**
+ * The asynchronous fetch does not become synchronous during startup.
+ *
+ *
{@code fetchToken()} is documented asynchronous, and it waited for
+ * initialization before returning its {@code AsyncResource} -- so a caller on the EDT
+ * froze for the length of a cold start, and an engine whose own initialization needs
+ * anything dispatched to the EDT deadlocked outright: the EDT parked waiting for the
+ * initialization that is waiting for the EDT.
+ */
+ @Test
+ void fetchTokenReturnsWhileInitializationIsStillRunning() throws Exception {
+ Thread init = initOnAnotherThread();
+ assertTrue(engine.entered.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "the engine should have been asked to initialize");
+
+ final AsyncResource[] handle = new AsyncResource[1];
+ final CountDownLatch returned = new CountDownLatch(1);
+ Thread caller = new Thread(new Runnable() {
+ public void run() {
+ handle[0] = AppShield.fetchToken();
+ returned.countDown();
+ }
+ }, "shield-fetch-during-init");
+ caller.setDaemon(true);
+ caller.start();
+
+ assertTrue(returned.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "fetchToken must hand back its resource without waiting for startup");
+ assertNotNull(handle[0]);
+ assertFalse(handle[0].isDone(),
+ "and it cannot be finished yet -- the engine has not started");
+
+ engine.release();
+ init.join(GENEROUS_TIMEOUT_MS);
+ long deadline = System.currentTimeMillis() + GENEROUS_TIMEOUT_MS;
+ while (!handle[0].isDone() && System.currentTimeMillis() < deadline) {
+ Thread.sleep(10L);
+ }
+ assertTrue(handle[0].isDone(), "the resource has to settle once startup finishes");
+ assertEquals("token-from-a-fully-initialized-engine", handle[0].get().getValue());
+ }
+
+ /**
+ * A cancelled fetch stays cancelled, whatever the engine answers afterwards.
+ *
+ *
{@code AsyncResource.complete()} does not consult the cancelled flag -- it stores
+ * the value, marks the resource done and runs the success callback regardless -- so a
+ * caller that gave up while the attestation round trip was in flight still had its
+ * {@code ready} callback invoked when the answer arrived. Cancelling means the caller
+ * has stopped listening, and this is the one place in the shield where the late
+ * callback lands on a screen that has gone.
+ */
+ @Test
+ void aCancelledTokenFetchNeverDeliversItsResult() throws Exception {
+ Thread init = initOnAnotherThread();
+ assertTrue(engine.entered.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "the engine should have been asked to initialize");
+
+ AsyncResource handle = AppShield.fetchToken();
+ final CountDownLatch delivered = new CountDownLatch(1);
+ handle.ready(new com.codename1.util.SuccessCallback() {
+ public void onSucess(ShieldToken value) {
+ delivered.countDown();
+ }
+ });
+ handle.except(new com.codename1.util.SuccessCallback() {
+ public void onSucess(Throwable value) {
+ delivered.countDown();
+ }
+ });
+
+ assertTrue(handle.cancel(true), "the caller gives up before the engine answers");
+
+ // And now the engine answers, which is the whole point: the token arrives, and
+ // nobody is told.
+ engine.release();
+ init.join(GENEROUS_TIMEOUT_MS);
+ assertFalse(delivered.await(500L, TimeUnit.MILLISECONDS),
+ "a cancelled fetch must deliver neither a value nor an error");
+ assertTrue(handle.isCancelled(), "and it stays cancelled rather than completing");
+ }
+
+ /**
+ * The cleanup only touches the request the shield actually decorated.
+ *
+ *
The remembered names were process-global, so every request lost them -- and an
+ * app whose token header is also one an unprotected service legitimately expects
+ * ({@code X-API-Key} being the obvious case) found the shield quietly deleting that
+ * service's header on a request the shield has nothing to do with. Removing a header
+ * an app set itself is a bug that presents as the other service rejecting the call.
+ */
+ @Test
+ void anotherRequestKeepsAHeaderTheAppSetItself() throws Exception {
+ Thread init = initOnAnotherThread();
+ engine.release();
+ init.join(GENEROUS_TIMEOUT_MS);
+
+ RecordingRequest protectedRequest = new RecordingRequest();
+ protectedRequest.setUrl("https://api.example.com/secure");
+ AppShield.attach(protectedRequest);
+ assertEquals("token-from-a-fully-initialized-engine", protectedRequest.attached());
+
+ // A different request, to a host the shield does not protect, carrying a header
+ // of the app's own that happens to share the token header's name.
+ RecordingRequest ownRequest = new RecordingRequest();
+ ownRequest.setUrl("https://elsewhere.example.com/thing");
+ ownRequest.addRequestHeader("X-CN1-Attest", "the-app-put-this-here");
+ AppShield.attach(ownRequest);
+
+ assertEquals("the-app-put-this-here", ownRequest.headerValue("X-CN1-Attest"),
+ "the shield must not strip a header it did not attach to this request");
+ }
+
+ /**
+ * And a header the app installed for the redirect TARGET is equally not the shield's.
+ *
+ *
Same request, so scoping the cleanup to the request does not separate them. The
+ * request is reused across a redirect and {@code performOperationComplete()} calls
+ * {@code onRedirect()} in between -- the hook where an app sets up what the new target
+ * needs. An app whose target expects its own key under the name the shield uses for its
+ * token had that key deleted on the way out, because the record held the request and
+ * the name and not the value that says whose header it is.
+ */
+ @Test
+ void aKeyTheRedirectHookInstalledUnderTheSameNameIsNotTheShieldsToRemove()
+ throws Exception {
+ Thread init = initOnAnotherThread();
+ engine.release();
+ init.join(GENEROUS_TIMEOUT_MS);
+
+ RecordingRequest request = new RecordingRequest();
+ request.setUrl("https://api.example.com/secure");
+ AppShield.attach(request);
+ assertEquals("token-from-a-fully-initialized-engine", request.attached(),
+ "the fixture must actually attach something, or this proves nothing");
+
+ // The redirect, and the app's hook putting the target's own credential under the
+ // same name -- which replaces the token, so there is nothing left to strip.
+ request.setUrl("https://unprotected.example.com/elsewhere");
+ request.addRequestHeader("X-CN1-Attest", "the-targets-own-api-key");
+ AppShield.attach(request);
+
+ assertEquals("the-targets-own-api-key", request.headerValue("X-CN1-Attest"),
+ "the shield's token is already gone -- what is left is the app's, and "
+ + "removing it breaks the call the redirect was for");
+ }
+
+ /**
+ * A differently-cased copy of the token header does not survive beside the real one.
+ *
+ *
Header names are case-insensitive, so adding ours next to an app's existing
+ * {@code x-cn1-attest} leaves two fields on the wire and lets the backend or an
+ * intermediary pick the stale one -- while {@code attach()} reports success and a
+ * fail-closed host is satisfied.
+ */
+ @Test
+ void aDifferentlyCasedTokenHeaderIsReplacedRatherThanDuplicated() throws Exception {
+ Thread init = initOnAnotherThread();
+ engine.release();
+ init.join(GENEROUS_TIMEOUT_MS);
+
+ RecordingRequest request = new RecordingRequest();
+ request.setUrl("https://api.example.com/secure");
+ request.addRequestHeader("x-cn1-attest", "a-stale-value-the-app-set");
+
+ AppShield.attach(request);
+
+ assertNull(request.headerValue("x-cn1-attest"),
+ "the app's spelling must not survive beside the shield's");
+ assertEquals("token-from-a-fully-initialized-engine", request.attached());
+ }
+
+ /**
+ * A wildcard registered at runtime protects its subdomains, like a configured one.
+ *
+ *
The runtime table was looked up by exact name, so
+ * {@code addProtectedHost("*.discovered.example")} covered nothing: a request to
+ * {@code api.discovered.example} missed it, fell through to a configuration that had
+ * never heard of the host, and went out with no token and no certificate check. And
+ * {@code protectedHosts()} published the wildcard to the engine, so the pin set was
+ * built for a host the request path had already decided was unprotected -- the host the
+ * app went out of its way to register being the one host unprotected.
+ */
+ @Test
+ void aWildcardRegisteredAtRuntimeCoversItsSubdomains() throws Exception {
+ Thread init = initOnAnotherThread();
+ engine.release();
+ init.join(GENEROUS_TIMEOUT_MS);
+
+ AppShield.addProtectedHost("*.discovered.example", HostPolicy.ENFORCED);
+ assertSame(HostPolicy.ENFORCED, AppShield.policyFor("api.discovered.example"),
+ "a runtime wildcard has to resolve the way a configured one does");
+ assertSame(HostPolicy.UNPROTECTED, AppShield.policyFor("discovered.example"),
+ "the apex is not a subdomain, here as in ShieldConfig");
+ assertSame(HostPolicy.UNPROTECTED,
+ AppShield.policyFor("discovered.example.evil.test"),
+ "and a name that merely contains it is somebody else's host");
+
+ // The consequence, through the path that matters rather than the lookup alone.
+ RecordingRequest request = new RecordingRequest();
+ request.setUrl("https://api.discovered.example/thing");
+ AppShield.attach(request);
+ assertEquals("token-from-a-fully-initialized-engine", request.attached(),
+ "the request to the registered host has to carry a token");
+ }
+
+ /**
+ * And specificity decides between the two tables, rather than one table winning whole.
+ *
+ *
A runtime registration is the later statement of intent, so it wins for the same
+ * pattern -- but a configured exact host is more specific than a runtime wildcard, and
+ * quietly relaxing it from ENFORCED to something weaker is a downgrade nobody asked
+ * for.
+ */
+ @Test
+ void aRuntimeWildcardDoesNotRelaxAMoreSpecificConfiguredHost() throws Exception {
+ Thread init = initOnAnotherThread();
+ engine.release();
+ init.join(GENEROUS_TIMEOUT_MS);
+
+ AppShield.addProtectedHost("*.example.com", HostPolicy.PROTECTED);
+
+ assertSame(HostPolicy.ENFORCED, AppShield.policyFor("api.example.com"),
+ "api.example.com is configured ENFORCED, which is the more specific rule");
+ assertSame(HostPolicy.PROTECTED, AppShield.policyFor("other.example.com"),
+ "and the wildcard still covers everything the configuration does not name");
+ }
+
+ private Thread initOnAnotherThread() {
+ ShieldEngineRegistry.setEngine(engine);
+ Thread t = new Thread(new Runnable() {
+ public void run() {
+ AppShield.init(new ShieldConfig()
+ .protect("api.example.com", HostPolicy.ENFORCED));
+ }
+ }, "shield-init-order-init");
+ t.setDaemon(true);
+ t.start();
+ return t;
+ }
+
+ /**
+ * Records what the shield attached. {@code ConnectionRequest} has no header getter, and
+ * adding one to the public API to serve a test would be the wrong direction.
+ */
+ private static final class RecordingRequest extends ConnectionRequest {
+
+ private final java.util.Map headers =
+ new java.util.LinkedHashMap();
+
+ @Override
+ public void addRequestHeader(String key, String value) {
+ super.addRequestHeader(key, value);
+ headers.put(key, value);
+ }
+
+ @Override
+ public void removeRequestHeader(String key) {
+ super.removeRequestHeader(key);
+ // Case-insensitively, like the real one. A double that only removes the exact
+ // spelling reports a header still present that the request no longer holds,
+ // which is a test failing for a property the code has.
+ java.util.Iterator it = headers.keySet().iterator();
+ while (it.hasNext()) {
+ String existing = it.next();
+ if (existing != null && existing.equalsIgnoreCase(key)) {
+ it.remove();
+ }
+ }
+ }
+
+ @Override
+ public void removeRequestHeaderIfUnchanged(String key, String value) {
+ super.removeRequestHeaderIfUnchanged(key, value);
+ java.util.Iterator it = headers.keySet().iterator();
+ while (it.hasNext()) {
+ String existing = it.next();
+ if (existing != null && existing.equalsIgnoreCase(key)
+ && value != null && value.equals(headers.get(existing))) {
+ it.remove();
+ }
+ }
+ }
+
+ String attached() {
+ return headers.get("X-CN1-Attest");
+ }
+
+ String headerValue(String name) {
+ return headers.get(name);
+ }
+ }
+
+ /** An engine whose {@code initialize()} is held open, which is the whole window. */
+ private static final class SlowEngine implements ShieldEngine {
+
+ private final CountDownLatch entered = new CountDownLatch(1);
+ private final CountDownLatch proceed = new CountDownLatch(1);
+
+ void release() {
+ proceed.countDown();
+ }
+
+ public String getName() {
+ return "slow-test-engine";
+ }
+
+ public boolean isAvailable() {
+ return true;
+ }
+
+ public void initialize(EngineContext ctx, ShieldConfig config) {
+ entered.countDown();
+ try {
+ proceed.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ public ShieldToken fetchToken(String bindingData) {
+ return new ShieldToken("token-from-a-fully-initialized-engine",
+ ShieldStatus.OK, System.currentTimeMillis(), 300000L, bindingData);
+ }
+
+ 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() {
+ return new ShieldSignal[0];
+ }
+
+ public void invalidate() {
+ }
+
+ public void shutdown() {
+ }
+ }
+
+ /**
+ * Listeners never finish on a status the shield has already replaced.
+ *
+ *
Two network threads could interleave as: A stores, B stores and enqueues, A
+ * enqueues -- and listeners then held A while {@link AppShield#getStatus()} answered
+ * B, with nothing later guaranteed to correct them.
+ *
+ *
Fixed at delivery rather than by holding the lock across the enqueue, because
+ * {@code Display.callSerially} runs the task inline before the EDT is up: dispatching
+ * under the monitor would run application listeners while holding it, and a listener
+ * that touches the shield -- or waits on a thread that does -- deadlocks against
+ * {@code attach()}, which waits on that same monitor. So the dispatch checks on
+ * arrival whether it still describes the current status.
+ *
+ *
Staged through the listener monitor, which the transition takes after storing
+ * the status and before queueing anything. That parks the first transition where its
+ * notification is prepared, so the second overtakes it deterministically -- racing two
+ * threads at a window this narrow is not something a test can rely on.
+ */
+ @Test
+ void aSupersededStatusIsNeverAnnounced() throws Exception {
+ final java.util.List seen =
+ java.util.Collections.synchronizedList(new java.util.ArrayList());
+ AppShield.addListener(new RecordingListener(seen));
+ java.lang.reflect.Field listenersField =
+ AppShield.class.getDeclaredField("listeners");
+ listenersField.setAccessible(true);
+ Object listenerLock = listenersField.get(null);
+
+ final CountDownLatch firstIn = new CountDownLatch(1);
+ final CountDownLatch secondIn = new CountDownLatch(1);
+ Thread first;
+ Thread second;
+ synchronized (listenerLock) {
+ first = new Thread(new Runnable() {
+ public void run() {
+ firstIn.countDown();
+ AppShield.setStatusForTesting(ShieldStatus.SERVICE_DOWN);
+ }
+ }, "shield-status-first");
+ first.setDaemon(true);
+ first.start();
+ assertTrue(firstIn.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS));
+ Thread.sleep(BLOCKED_OBSERVATION_MS);
+
+ second = new Thread(new Runnable() {
+ public void run() {
+ secondIn.countDown();
+ AppShield.setStatusForTesting(ShieldStatus.OK);
+ }
+ }, "shield-status-second");
+ second.setDaemon(true);
+ second.start();
+ assertTrue(secondIn.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS));
+ Thread.sleep(BLOCKED_OBSERVATION_MS);
+ }
+ first.join(GENEROUS_TIMEOUT_MS);
+ second.join(GENEROUS_TIMEOUT_MS);
+
+ final CountDownLatch drained = new CountDownLatch(1);
+ com.codename1.ui.Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ drained.countDown();
+ }
+ });
+ assertTrue(drained.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "the dispatch queue has to drain for this to mean anything");
+
+ assertEquals(ShieldStatus.OK, AppShield.getStatus());
+ synchronized (seen) {
+ assertFalse(seen.isEmpty(), "the surviving transition is announced");
+ assertEquals(ShieldStatus.OK, seen.get(seen.size() - 1),
+ "and what a listener is left holding is what getStatus reports: " + seen);
+ assertFalse(seen.contains(ShieldStatus.SERVICE_DOWN),
+ "a superseded status was already wrong when it was queued, so it is "
+ + "not announced at all: " + seen);
+ }
+ }
+
+ /** Records what listeners were actually told, in order. */
+ private static final class RecordingListener implements ShieldListener {
+
+ private final java.util.List seen;
+
+ RecordingListener(java.util.List seen) {
+ this.seen = seen;
+ }
+
+ public void signalRaised(ShieldSignal signal) {
+ }
+
+ public void tokenRefreshed(ShieldToken token) {
+ }
+
+ public void statusChanged(ShieldStatus status) {
+ seen.add(status);
+ }
+ }
+}
diff --git a/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldSignalOrderTest.java b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldSignalOrderTest.java
new file mode 100644
index 00000000000..cde93ec9a11
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/security/shield/ShieldSignalOrderTest.java
@@ -0,0 +1,228 @@
+/*
+ * 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 com.codename1.junit.UITestBase;
+import com.codename1.ui.Display;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+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.assertTrue;
+
+/**
+ * What a listener is holding when the dust settles has to be what the bus holds.
+ *
+ *
Storing an observation and enqueueing its notification are separate steps, and they
+ * cannot be merged: {@code Display.callSerially} runs the task inline before the EDT is
+ * up, so notifying under the signal monitor would run application listeners while holding
+ * it. Two workers reporting different observations of one signal could therefore
+ * interleave as A stores, B stores over it, B enqueues, A enqueues -- and listeners
+ * finished on A while {@code snapshot()} already answered B, with nothing later
+ * guaranteed to correct them. A device that reported a hooking framework and then a clean
+ * state, in that order, would leave an app looking at the compromise indefinitely; the
+ * reverse leaves it looking at the clean one.
+ */
+class ShieldSignalOrderTest extends UITestBase {
+
+ private static final long GENEROUS_TIMEOUT_MS = 10000L;
+
+ private final List seen = new ArrayList();
+ private ShieldListener listener;
+
+ @BeforeEach
+ void attach() {
+ ShieldSignals.clear();
+ listener = new ShieldListener() {
+ public void signalRaised(ShieldSignal signal) {
+ synchronized (seen) {
+ seen.add(signal);
+ }
+ }
+
+ public void tokenRefreshed(ShieldToken token) {
+ }
+
+ public void statusChanged(ShieldStatus status) {
+ }
+ };
+ ShieldSignals.addListener(listener);
+ }
+
+ @AfterEach
+ void detach() {
+ ShieldSignals.removeListener(listener);
+ ShieldSignals.clear();
+ }
+
+ /**
+ * A superseded observation is never announced, whichever order the two notifications
+ * were queued in.
+ *
+ *
Staged through the listener monitor, which the notification path takes after the
+ * signal has already been stored. That parks the first report exactly where its
+ * notification is enqueued, so the second can overtake it deterministically -- racing
+ * two threads at a window this narrow is not something a test can rely on.
+ */
+ @Test
+ void aListenerNeverEndsOnAnObservationTheBusHasReplaced() throws Exception {
+ java.lang.reflect.Field listenersField =
+ ShieldSignals.class.getDeclaredField("listeners");
+ listenersField.setAccessible(true);
+ Object listenerLock = listenersField.get(null);
+
+ final CountDownLatch firstIn = new CountDownLatch(1);
+ final CountDownLatch secondIn = new CountDownLatch(1);
+ Thread first;
+ Thread second;
+ synchronized (listenerLock) {
+ first = new Thread(new Runnable() {
+ public void run() {
+ firstIn.countDown();
+ ShieldSignals.add(ShieldSignal.HOOK, 90, "frida");
+ }
+ }, "shield-signal-first");
+ first.setDaemon(true);
+ first.start();
+ assertTrue(firstIn.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS));
+ Thread.sleep(200L);
+
+ second = new Thread(new Runnable() {
+ public void run() {
+ secondIn.countDown();
+ ShieldSignals.add(ShieldSignal.HOOK, 10, "gone");
+ }
+ }, "shield-signal-second");
+ second.setDaemon(true);
+ second.start();
+ assertTrue(secondIn.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS));
+ Thread.sleep(200L);
+ }
+ first.join(GENEROUS_TIMEOUT_MS);
+ second.join(GENEROUS_TIMEOUT_MS);
+ drainTheEventQueue();
+
+ ShieldSignal current = null;
+ for (ShieldSignal s : ShieldSignals.snapshot()) {
+ if (ShieldSignal.HOOK.equals(s.getId())) {
+ current = s;
+ }
+ }
+ assertEquals(10, current == null ? -1 : current.getSeverity(),
+ "the fixture needs the second report to be the one the bus holds");
+
+ synchronized (seen) {
+ assertEquals(1, countFor(ShieldSignal.HOOK),
+ "the superseded report was already wrong when it was queued, so it "
+ + "is not announced: " + seen);
+ assertEquals(10, lastFor(ShieldSignal.HOOK),
+ "and what the listener is left holding is what snapshot() answers");
+ }
+ }
+
+ /**
+ * And an identical repeat arriving mid-flight does not swallow the first notification.
+ *
+ *
The two rules meet here. An identical repeat replaces the stored entry -- so the
+ * timestamp is the latest sighting -- and deliberately queues nothing, because a
+ * detector polling on a timer would otherwise put a runnable on the EDT per poll. The
+ * pending notification for the first report then failed a currency test asking about
+ * object identity, and dropped itself: nobody was told, while the signal sat in
+ * {@code snapshot()}. A detector on a timer is the normal case, and the sighting that
+ * went unannounced is the FIRST one, which is the whole reason a listener is
+ * attached.
+ */
+ @Test
+ void anIdenticalRepeatDoesNotSwallowTheNotificationAlreadyInFlight() throws Exception {
+ java.lang.reflect.Field listenersField =
+ ShieldSignals.class.getDeclaredField("listeners");
+ listenersField.setAccessible(true);
+ Object listenerLock = listenersField.get(null);
+
+ final CountDownLatch firstIn = new CountDownLatch(1);
+ Thread first;
+ synchronized (listenerLock) {
+ first = new Thread(new Runnable() {
+ public void run() {
+ firstIn.countDown();
+ ShieldSignals.add(ShieldSignal.ROOT, 70, "su");
+ }
+ }, "shield-signal-first-sighting");
+ first.setDaemon(true);
+ first.start();
+ assertTrue(firstIn.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS));
+ // Parked at the notification, with the signal already stored.
+ Thread.sleep(200L);
+
+ // The next poll of the same detector, on this thread: identical, so it replaces
+ // the entry and returns without ever reaching the listener lock.
+ ShieldSignals.add(ShieldSignal.ROOT, 70, "su");
+ }
+ first.join(GENEROUS_TIMEOUT_MS);
+ drainTheEventQueue();
+
+ synchronized (seen) {
+ assertEquals(1, countFor(ShieldSignal.ROOT),
+ "the device is rooted and the bus knows it -- a listener that is "
+ + "never told is the bug: " + seen);
+ assertEquals(70, lastFor(ShieldSignal.ROOT));
+ }
+ }
+
+ private int countFor(String id) {
+ int n = 0;
+ for (ShieldSignal s : seen) {
+ if (id.equals(s.getId())) {
+ n++;
+ }
+ }
+ return n;
+ }
+
+ private int lastFor(String id) {
+ int severity = -1;
+ for (ShieldSignal s : seen) {
+ if (id.equals(s.getId())) {
+ severity = s.getSeverity();
+ }
+ }
+ return severity;
+ }
+
+ /** Waits for everything queued so far to have run, without blocking the EDT. */
+ private void drainTheEventQueue() throws Exception {
+ final CountDownLatch drained = new CountDownLatch(1);
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ drained.countDown();
+ }
+ });
+ assertTrue(drained.await(GENEROUS_TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ "the dispatch queue has to drain for this to mean anything");
+ }
+}
diff --git a/maven/core-unittests/src/test/java/com/codename1/security/shield/spi/ShieldEngineTestAccess.java b/maven/core-unittests/src/test/java/com/codename1/security/shield/spi/ShieldEngineTestAccess.java
new file mode 100644
index 00000000000..21b71fbf3f1
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/security/shield/spi/ShieldEngineTestAccess.java
@@ -0,0 +1,42 @@
+/*
+ * 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.spi;
+
+/**
+ * Reaches {@code ShieldEngineRegistry}'s package-private test hook from a test in another
+ * package.
+ *
+ *
The registry seals on first registration, deliberately, so a test that registers an
+ * engine has to be able to unseal it or it decides what every later test in the same JVM
+ * sees.
+ */
+public final class ShieldEngineTestAccess {
+
+ private ShieldEngineTestAccess() {
+ }
+
+ /** Drops the registered engine and unseals the registry. */
+ public static void reset() {
+ ShieldEngineRegistry.resetForTesting();
+ }
+}
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..1a84567c9bf
--- /dev/null
+++ b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEShieldEngineTest.java
@@ -0,0 +1,211 @@
+/*
+ * 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.AppShield;
+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 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 --
+ // 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");
+ // 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");
+ }
+
+ /// 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
+ // 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");
+ }
+}
diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/MCPSocketTransportReopenTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/MCPSocketTransportReopenTest.java
new file mode 100644
index 00000000000..e73222b3fdf
--- /dev/null
+++ b/maven/javase/src/test/java/com/codename1/impl/javase/MCPSocketTransportReopenTest.java
@@ -0,0 +1,119 @@
+/*
+ * 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 java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Restarting an MCP server over one transport instance.
+ *
+ *
{@code MCPServer} supports {@code stop()} followed by {@code start(t)}, and a caller
+ * that holds a single transport around a restart is the ordinary way to use it. This
+ * transport recorded {@code closed} permanently: the second {@code open()} bound a socket,
+ * saw the retained flag, closed that socket and threw -- so the restarted server stopped on
+ * its first breath, with a message about being closed before it could listen. The failure
+ * is silent in the sense that matters: the restart reports success and the port is dead.
+ */
+class MCPSocketTransportReopenTest {
+
+ @Test
+ void aTransportThatWasClosedCanBeOpenedAgain() throws Exception {
+ int port = freePort();
+ MCPSocketTransport transport = new MCPSocketTransport(port);
+ transport.open();
+ transport.close();
+
+ transport.open();
+ try {
+ // Bound and listening: an agent can attach, and what it sends arrives. Before
+ // the fix this open() threw, so there was nothing to connect to.
+ Socket client = new Socket(InetAddress.getLoopbackAddress(), port);
+ try {
+ Writer w = new OutputStreamWriter(client.getOutputStream(), "UTF-8");
+ w.write("{\"jsonrpc\":\"2.0\"}\n");
+ w.flush();
+ assertEquals("{\"jsonrpc\":\"2.0\"}", transport.readMessage(),
+ "a reopened transport has to serve a new session");
+ } finally {
+ client.close();
+ }
+ } finally {
+ transport.close();
+ }
+ }
+
+ /**
+ * A second open on a live transport is refused rather than stranding the listener.
+ *
+ *
The listening socket is reachable only through the transport's own field, so
+ * overwriting it leaves a listener nothing can close -- and it survives {@code close()}
+ * holding the port, which the next start then cannot bind.
+ */
+ @Test
+ void openingATransportThatIsAlreadyListeningIsRefused() throws Exception {
+ int port = freePort();
+ MCPSocketTransport transport = new MCPSocketTransport(port);
+ transport.open();
+ try {
+ IOException refused = assertThrows(IOException.class, transport::open);
+ assertTrue(refused.getMessage().contains("already listening"),
+ "the refusal should say what is wrong, got: " + refused.getMessage());
+ } finally {
+ transport.close();
+ }
+ // And the port is genuinely free afterwards, which is what proves nothing was
+ // stranded.
+ ServerSocket rebind = new ServerSocket(port, 1, InetAddress.getLoopbackAddress());
+ rebind.close();
+ }
+
+ /** A closed transport that is NOT reopened still refuses to read. */
+ @Test
+ void aClosedTransportStillReadsNull() throws Exception {
+ MCPSocketTransport transport = new MCPSocketTransport(freePort());
+ transport.open();
+ transport.close();
+
+ assertEquals(null, transport.readMessage(),
+ "clearing the flag on open must not make close() stop meaning anything");
+ }
+
+ private static int freePort() throws IOException {
+ ServerSocket probe = new ServerSocket(0, 1, InetAddress.getLoopbackAddress());
+ try {
+ return probe.getLocalPort();
+ } finally {
+ probe.close();
+ }
+ }
+}
diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh
index 3dda2eb77d6..e51fa1fcc87 100755
--- a/scripts/build-android-app.sh
+++ b/scripts/build-android-app.sh
@@ -150,6 +150,29 @@ grep -q '^android.useAndroidX=' "$GRADLE_PROPS" 2>/dev/null || echo 'android.use
grep -q '^android.enableJetifier=' "$GRADLE_PROPS" 2>/dev/null || echo 'android.enableJetifier=true' >> "$GRADLE_PROPS"
grep -q '^android.suppressUnsupportedCompileSdk=' "$GRADLE_PROPS" 2>/dev/null || echo 'android.suppressUnsupportedCompileSdk=36' >> "$GRADLE_PROPS"
+# More heap than the builder's generated default, for this script only.
+#
+# The generated project asks for -Xmx2048m, which was written for a build that
+# forks its heavy work out. This script runs --no-daemon, so resource merging,
+# dexing and packaging all happen in one single-use JVM, and packageDebug reads
+# each entry into a byte[] as it writes it -- the last allocation in the
+# sequence, on a heap the earlier steps have already filled and fragmented.
+# That is why the failure appeared as an intermittent OutOfMemoryError inside
+# PackageAndroidArtifact rather than a build that never worked: identical
+# inputs, decided by GC timing. Nothing here caps the JVM below the runner's
+# memory, so the headroom is free.
+#
+# Overwritten rather than appended: java.util.Properties would take the last
+# occurrence, but a file listing the same key twice with different values is a
+# trap for whoever reads it next.
+if grep -q '^org.gradle.jvmargs=' "$GRADLE_PROPS" 2>/dev/null; then
+ sed -i.bak 's/^org.gradle.jvmargs=.*/org.gradle.jvmargs=-Xmx4096m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8/' "$GRADLE_PROPS"
+ rm -f "$GRADLE_PROPS.bak"
+else
+ echo 'org.gradle.jvmargs=-Xmx4096m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8' >> "$GRADLE_PROPS"
+fi
+ba_log "Gradle JVM args: $(grep '^org.gradle.jvmargs=' "$GRADLE_PROPS")"
+
APP_BUILD_GRADLE="$GRADLE_PROJECT_DIR/app/build.gradle"
ROOT_BUILD_GRADLE="$GRADLE_PROJECT_DIR/build.gradle"
PATCH_GRADLE_SOURCE_PATH="$SCRIPT_DIR/android/lib"
@@ -192,7 +215,13 @@ export JAVA_HOME="${JDK_HOME:-$JAVA17_HOME}"
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" "platforms;android-36" "build-tools;36.0.0" >/dev/null 2>&1 || ba_log "Warning: unable to install Android SDK 36 components"
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true
fi
- ./gradlew --no-daemon assembleDebug
+ # --stacktrace, always. A packaging failure inside
+ # PackageAndroidArtifact$IncrementalSplitterRunnable reports only "a failure
+ # occurred while executing" without it, so the CI log names the task and nothing
+ # else -- and the workspace is gone by the time anyone reads it. It costs nothing
+ # on a successful build and is the difference between diagnosing the next
+ # occurrence and guessing at it.
+ ./gradlew --no-daemon --stacktrace assembleDebug
)
export JAVA_HOME="$ORIGINAL_JAVA_HOME"
diff --git a/scripts/ci/retry.sh b/scripts/ci/retry.sh
index f941e91a50f..d5e43beeced 100755
--- a/scripts/ci/retry.sh
+++ b/scripts/ci/retry.sh
@@ -13,8 +13,16 @@ set -uo pipefail
# RETRY_ATTEMPTS (default 3) and RETRY_DELAY_SECONDS (default 30) override the
# bounds; the command is always attempted at least once and the last attempt's
# exit status is what this script returns.
+#
+# RETRY_ONLY_MATCHING is an extended regex that narrows retrying to failures
+# whose output matches it -- for steps that RUN TESTS, where a blanket retry
+# would quietly convert a flaky test into a pass and hide exactly the kind of
+# race this repo requires to be root-caused. With it set, a failure that does not
+# match is returned on the first attempt, so only the transient-resolution case
+# gets a second chance. Leave it unset for steps that merely download and build.
attempts="${RETRY_ATTEMPTS:-3}"
delay="${RETRY_DELAY_SECONDS:-30}"
+only_matching="${RETRY_ONLY_MATCHING:-}"
if [ "$#" -eq 0 ]; then
echo "retry.sh: no command given" >&2
@@ -35,9 +43,30 @@ esac
# degenerate ranges, and this loop body must run exactly `attempts` times.
status=0
attempt=1
+log=""
+if [ -n "$only_matching" ]; then
+ log="$(mktemp)"
+ trap 'rm -f "$log"' EXIT
+fi
while [ "$attempt" -le "$attempts" ]; do
- "$@" && exit 0
- status=$?
+ if [ -n "$only_matching" ]; then
+ # Streamed as well as captured, so the step's log reads exactly as it would
+ # without the wrapper.
+ "$@" 2>&1 | tee "$log"
+ status=$?
+ else
+ "$@" && exit 0
+ status=$?
+ fi
+ if [ "$status" -eq 0 ]; then
+ exit 0
+ fi
+ if [ -n "$only_matching" ] && ! grep -Eq "$only_matching" "$log"; then
+ echo "retry.sh: attempt ${attempt}/${attempts} failed with status ${status} and the" \
+ "output does not match RETRY_ONLY_MATCHING, so this is a real failure, not a" \
+ "transient one -- not retrying" >&2
+ exit "$status"
+ fi
if [ "$attempt" -lt "$attempts" ]; then
echo "retry.sh: attempt ${attempt}/${attempts} failed with status ${status};" \
"retrying in ${delay}s (possible transient Maven Central 403/429/5xx)" >&2
diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java
index 23918b1b5ae..7b41de7db0c 100644
--- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java
+++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java
@@ -172,6 +172,17 @@ private boolean containsRenderedBrowserContent(Image screen) {
int bandWidth = right - left;
int bandHeight = bottom - top;
+ // Both colours, not either one.
+ //
+ // The fixture is white and cyan text on a near-black background, and this
+ // originally looked only for the bright pixels because the case it was written
+ // for was iOS, where a peer that has not reached the Metal surface is BLACK --
+ // brightness alone separates the two there. On Android an unpainted WebView is
+ // WHITE, which passes a brightness test on its own the very first time it is
+ // asked, so the harness emitted a blank white frame and the run failed as a
+ // screenshot mismatch rather than waiting the further quarter-second the peer
+ // needed. Requiring the dark background as well describes the fixture instead of
+ // one platform's failure colour, and neither blank state can satisfy it.
if (visualBand == null || visualBand.getWidth() != bandWidth
|| visualBand.getHeight() != bandHeight) {
visualBand = new RGBImage(new int[bandWidth * bandHeight], bandWidth, bandHeight);
@@ -179,7 +190,12 @@ private boolean containsRenderedBrowserContent(Image screen) {
screen.toRGB(visualBand, 0, 0, left, top, bandWidth, bandHeight);
int[] rgb = visualBand.getRGB();
int requiredBrightPixels = Math.max(32, bandWidth / 20);
+ // The text occupies a small part of the band and the background the rest, so a
+ // quarter of the pixels is a comfortable floor for a genuinely rendered page and
+ // unreachable for a white one.
+ int requiredDarkPixels = Math.max(32, (bandWidth * bandHeight) / 4);
int brightPixels = 0;
+ int darkPixels = 0;
for (int y = 0; y < bandHeight; y++) {
int rowOffset = y * bandWidth;
for (int x = 0; x < bandWidth; x++) {
@@ -188,12 +204,16 @@ private boolean containsRenderedBrowserContent(Image screen) {
int g = (color >> 8) & 0xff;
int b = color & 0xff;
// The local fixture contains white and cyan text on a dark
- // background. The black uncomposited peer contains neither.
+ // background. The black uncomposited peer has the dark and none of
+ // the text; the white unpainted one has neither.
if ((r > 160 && g > 160 && b > 160)
|| (g > 120 && b > 160 && b > r + 30)) {
- if (++brightPixels >= requiredBrightPixels) {
- return true;
- }
+ brightPixels++;
+ } else if (r < 48 && g < 48 && b < 48) {
+ darkPixels++;
+ }
+ if (brightPixels >= requiredBrightPixels && darkPixels >= requiredDarkPixels) {
+ return true;
}
}
}
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):
diff --git a/scripts/setup-workspace.sh b/scripts/setup-workspace.sh
index 0e51853980b..eedbdff6068 100755
--- a/scripts/setup-workspace.sh
+++ b/scripts/setup-workspace.sh
@@ -225,13 +225,41 @@ 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"
+
+# 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 -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 \
- -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 \