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 openLocks = new ArrayList(); + + /// Transports with a reader still inside their loop, and which generation it belongs + /// to. + /// + /// A restart over the SAME transport instance is the case this exists for. Both + /// production transports clear their closed flag in open(), so a reader still parked + /// in readMessage() from the previous generation is looking at a live stream again the + /// moment the replacement opens -- and it can take a frame the new client sent. The + /// frame is then handled by a loop that belongs to a stopped server, or dropped + /// entirely; the new session simply never sees it, which reads as a client that hangs. + /// + /// stop() closes the transport before any of this, so the stale read unwinds + /// immediately and the wait below is measured in the time that takes rather than in + /// anything the client controls. + private final List activeReaders = new ArrayList(); + + /// How long a replacement waits for the previous reader of the same transport before + /// opening anyway. + /// + /// Bounded rather than indefinite, and the bound is the whole design. `stop()` closes + /// the transport first, so a reader blocked on a real socket unwinds in microseconds + /// and this wait is never observed. [MCPTransport] is a public interface, though, and + /// an implementation is entitled to a `readMessage()` that parks until something other + /// than `close()` releases it -- the loopback test transport is exactly that. Waiting + /// forever for such a reader deadlocks the restart against a thread only the caller + /// can end, which is worse than the overlap this is guarding against: the loop already + /// re-checks `isCurrent` after every read, so a stale reader cannot HANDLE anything, + /// and what is left is a frame it might swallow. + private static final long READER_HANDOVER_WAIT_MS = 2000L; + + /// Registers this generation as the reader of {@code t}, waiting out any older one. + private void awaitSoleReader(MCPTransport t, int generation) { + synchronized (activeReaders) { + long deadline = System.currentTimeMillis() + READER_HANDOVER_WAIT_MS; + for (;;) { + Object[] found = null; + for (Object[] entry : activeReaders) { + if (entry[0] == t) { // NOPMD identity: one reader per INSTANCE + found = entry; + break; + } + } + if (found == null) { + activeReaders.add(new Object[] {t, Integer.valueOf(generation)}); + return; + } + if (((Integer) found[1]).intValue() == generation) { + return; + } + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0L) { + // The previous reader is not coming back on its own. Taking the + // registration over rather than leaving it to the departed generation + // keeps a third restart waiting for THIS thread, which is the one + // actually on the transport. + found[1] = Integer.valueOf(generation); + return; + } + try { + activeReaders.wait(remaining); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return; + } + } + } + } + + private void releaseReader(MCPTransport t, int generation) { + synchronized (activeReaders) { + for (java.util.Iterator it = activeReaders.iterator(); it.hasNext();) { + Object[] entry = it.next(); + if (entry[0] == t // NOPMD identity: one reader per INSTANCE + && ((Integer) entry[1]).intValue() == generation) { + it.remove(); + break; + } + } + activeReaders.notifyAll(); + } + } + + private Object acquireOpenLock(MCPTransport t) { + synchronized (openLocks) { + for (Object[] entry : openLocks) { + if (entry[0] == t) { // NOPMD identity: one lock per transport INSTANCE + ((int[]) entry[2])[0]++; + return entry[1]; + } + } + Object lock = new Object(); + openLocks.add(new Object[] {t, lock, new int[] {1}}); + return lock; + } + } + + private void releaseOpenLock(MCPTransport t) { + synchronized (openLocks) { + // Iterator.remove rather than List.remove during a foreach. The foreach + // version could not actually throw -- it returned before the iterator was + // touched again -- but that made its safety depend on a `return` three lines + // away, which is the kind of coupling a later edit breaks without anything + // saying so. This shape is safe on its own terms. + for (java.util.Iterator it = openLocks.iterator(); it.hasNext();) { + Object[] entry = it.next(); + if (entry[0] == t) { // NOPMD identity: one lock per transport INSTANCE + int[] users = (int[]) entry[2]; + users[0]--; + if (users[0] <= 0) { + it.remove(); + } + return; + } + } + } + } + + private void runLoop(MCPTransport t, int generation) { + // Opening is deferred to this thread, so by the time it happens the server may + // already have been stopped or restarted. Either way stop()'s close() ran against a + // transport that had not opened yet and therefore released nothing, so opening now + // would leave a transport registered with nobody left to close it. That registration + // is process-wide: every later open() is refused on the grounds that an agent is + // already being served. + if (!isCurrent(t, generation)) { return; } - while (running) { + // Before opening, not after: a reader from the previous generation of this same + // transport instance may still be parked in readMessage(), and open() clears the + // flag that would have ended it. Opening first would put two readers on one + // stream, and the frame the new client sends can go to the one that no longer + // belongs to anybody. + awaitSoleReader(t, generation); + try { + if (!isCurrent(t, generation)) { + return; + } + if (!openSerialized(t, generation)) { + return; + } + readUntilClosed(t, generation); + } finally { + releaseReader(t, generation); + } + releaseAndCloseIfCurrent(t, generation); + } + + private void readUntilClosed(MCPTransport t, int generation) { + while (isCurrent(t, generation)) { String line; try { - line = transport.readMessage(); + line = t.readMessage(); } catch (IOException ex) { break; } + // Re-checked on the far side of the read as well as the near side. A read + // blocks for as long as the client is quiet, which is most of the time, so + // "was this server current when the read started" says nothing about whether + // it still is when the read returns -- and handling a request for a server + // that has been stopped answers on a transport somebody else may now own. + if (!isCurrent(t, generation)) { + break; + } if (line == null) { break; } @@ -188,14 +496,12 @@ private void runLoop() { String response = handleMessage(line); if (response != null) { try { - transport.writeMessage(response); + t.writeMessage(response); } catch (IOException ex) { break; } } } - running = false; - transport.close(); } /// Handles one inbound JSON-RPC message and returns the response line, or null diff --git a/CodenameOne/src/com/codename1/security/DeviceIntegrity.java b/CodenameOne/src/com/codename1/security/DeviceIntegrity.java index 801c70155a2..2fd64e50128 100644 --- a/CodenameOne/src/com/codename1/security/DeviceIntegrity.java +++ b/CodenameOne/src/com/codename1/security/DeviceIntegrity.java @@ -110,6 +110,46 @@ public static boolean isAttestationSupported() { return Display.getInstance().isAttestationSupported(); } + /// Discards the cached platform attestation state, so the next [#requestIntegrityToken(String)] + /// attests from a fresh hardware key. + /// + /// Only iOS holds client-side attestation state. Apple's model is: generate a hardware key once, + /// attest it once, then produce cheap assertions against it for every subsequent request. Your + /// backend records the key when it accepts the attestation. If the backend later rejects a request + /// because it does not recognise the key -- the app was reinstalled, the device was restored from a + /// backup, or the OS invalidated the key -- call this, then request a token again. That is the only + /// correct recovery; retrying with the same key will keep failing. + /// + /// Do not call this on every failure. Attestation is rate limited by Apple, and re-attesting in a + /// loop will get the app throttled. No-op on Android, where Play Integrity keeps no client key, and + /// where attestation is unsupported. + public static void resetAttestation() { + Display.getInstance().resetAttestation(); + } + + /// Tells the attestation layer that your backend has recorded the attested key, so later requests + /// can use cheap assertions instead of attesting again. + /// + /// This matters on iOS. The first token of a device's life is an attestation, which carries the + /// public key; every token after it is an assertion, which carries only the key's identifier. An + /// assertion sent before the backend has stored that public key is unresolvable, and the natural + /// reading of that rejection -- the key is invalid -- would throw away a key that was perfectly + /// good and burn one of Apple's rate limited attestations replacing it. So requests made between + /// the attestation and this acknowledgement are refused with a retry hint rather than asserted. + /// + /// Call it once, after the response accepting the attestation token, passing the key that + /// response acknowledged. Not calling it is safe but slower: the client assumes registration + /// succeeded after a short grace period. No-op on Android and where attestation is + /// unsupported. + /// @param keyId the key identifier your backend recorded -- the middle field of the + /// `cn1aa1:attest::` token it accepted, base64-decoded. + /// Naming it matters: a response for an earlier attestation can arrive after + /// the key has already been replaced, and acknowledging that would mark a key + /// attested which the backend has never seen. + public static void confirmAttestation(String keyId) { + Display.getInstance().confirmAttestation(keyId); + } + /// Non-exiting RASP check. Returns true when the device shows signs of being rooted, jailbroken, /// running under dynamic instrumentation (e.g. Frida) or otherwise tampered. Unlike the /// `android.rootCheck` / `ios.detectJailbreak` launch gates this never terminates the app, so it is diff --git a/CodenameOne/src/com/codename1/security/SecureStorage.java b/CodenameOne/src/com/codename1/security/SecureStorage.java index 91cc9416d86..b3eca7d6024 100644 --- a/CodenameOne/src/com/codename1/security/SecureStorage.java +++ b/CodenameOne/src/com/codename1/security/SecureStorage.java @@ -144,9 +144,10 @@ public void setKeychainAccessGroup(String group) { // `SecAccessControl`. Entries survive app updates and OS // reboots; they are extracted only after the user unlocks the // device at least once after each reboot. - // - Android: `EncryptedSharedPreferences` (Tink-backed AES-GCM) - // without `setUserAuthenticationRequired(true)`. No biometric - // prompt. + // - Android: AES-GCM under a dedicated AndroidKeyStore key created + // without `setUserAuthenticationRequired(true)`, persisted to a + // private preferences file. No biometric prompt. Devices below + // API 23 fall back to obfuscated (not encrypted) storage. // - JavaSE simulator: `java.util.prefs.Preferences` encrypted // with an AES key derived from the OS user account. Useful // for round-tripping `LlmClient.openai(SecureStorage.getInstance().get("openai_key"))` diff --git a/CodenameOne/src/com/codename1/security/shield/AppShield.java b/CodenameOne/src/com/codename1/security/shield/AppShield.java new file mode 100644 index 00000000000..06b5c79e674 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/AppShield.java @@ -0,0 +1,1052 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.Log; +import com.codename1.io.NetworkGuard; +import com.codename1.io.NetworkManager; +import com.codename1.security.shield.spi.ShieldEngine; +import com.codename1.security.shield.spi.ShieldEngineRegistry; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; +import java.util.Enumeration; +import java.util.Hashtable; +import java.util.Vector; + +/// API shielding: proves to your own backend that a request came from a genuine, unmodified build +/// of your app running on a device that has not been tampered with. +/// +/// #### How it differs from [com.codename1.security.DeviceIntegrity] +/// +/// `DeviceIntegrity` is the raw platform primitive -- it hands you a Play Integrity or App Attest +/// blob and leaves verification, policy and enforcement to you. `AppShield` is the managed layer +/// on top: the attestation is verified server side against Apple and Google, evaluated against a +/// policy you control, and turned into a short-lived signed token your backend can check with a +/// few lines of middleware. Both remain available and `DeviceIntegrity` keeps working unchanged; +/// an app that only needs the raw blob should keep using it. +/// +/// #### The shape of the thing +/// +/// ```java +/// AppShield.init(new ShieldConfig() +/// .protect("api.mybank.example", HostPolicy.PROTECTED)); +/// +/// // ...then just make requests. Protected hosts get the header and the pin check. +/// ConnectionRequest r = new ConnectionRequest("https://api.mybank.example/transfer", true); +/// NetworkManager.getInstance().addToQueueAndWait(r); +/// ``` +/// +/// Your backend rejects any request whose token is missing, expired or unsigned. That check is +/// where the security actually lives -- not in this class. A device the attacker fully controls +/// can always strip a header; what it cannot do is mint a token, because the token is signed by a +/// service the attacker does not control, on the strength of a statement from Apple or Google. +/// +/// #### When the engine is absent +/// +/// Builds without the enterprise attestation engine -- open-source builds, and any project not +/// entitled to it -- get a working, inert implementation. [#isProtected()] returns false, +/// [#fetchToken()] completes with [ShieldStatus#UNPROTECTED] rather than hanging, [#attach] does +/// nothing, and no request is ever blocked. The API is safe to call unconditionally; there is no +/// need to guard call sites. +/// +/// #### Threading +/// +/// [#fetchToken()] is asynchronous. [#attach(ConnectionRequest)] blocks and must not be called on +/// the EDT -- in normal use you never call it yourself, because a protected host is handled +/// automatically on the network thread. +public final class AppShield { + + private static ShieldConfig config; + private static boolean initialized; + private static NetworkGuard guard; + private static ShieldStatus lastStatus = ShieldStatus.NOT_INITIALIZED; + private static final Vector listeners = new Vector(); + private static final Hashtable runtimeHosts = new Hashtable(); + + /// Response header a backend sets to say it rejected the *attestation token*, as opposed to + /// the user's own credentials. + /// + /// Without it a 401 or 403 is ambiguous: protected APIs normally carry ordinary user + /// authorization too, and treating every such response as an attestation rejection would make + /// a client re-attest through an entire login failure. Emit it only when the token itself was + /// the problem; the value is ignored. + public static final String REJECT_HEADER = "X-CN1-Attest-Reject"; + + private AppShield() { + } + + // ----------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------- + + /// Initializes the shield. Call once during app startup, after `Display.init`. + /// + /// Safe to call in a build with no attestation engine: it logs one line and leaves the shield + /// inert. Calling it twice is a no-op. + public static void init(ShieldConfig cfg) { + synchronized (AppShield.class) { + // A concurrent caller WAITS rather than returning early. + // + // `initialized` used to be published before the engine was initialized and + // before the guard was installed, so a second init() returned as though + // setup were complete -- and, worse, a ConnectionRequest starting in that + // window found no network guard at all and sent a protected request with + // neither a token nor a pin check, including for a host configured to fail + // closed. The flag now means what its name says, and the window is closed by + // making anyone who arrives during setup wait for it rather than by making + // them guess. + while (initializing) { + try { + AppShield.class.wait(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + if (initialized) { + return; + } + config = cfg == null ? new ShieldConfig() : cfg; + initializing = true; + } + try { + // Installed FIRST, before the engine is given a chance to run. + // + // Publishing `initializing` is not enough on its own: a request that starts + // while the engine is still initializing only reaches awaitInitialization() + // if something routes it there, and the only thing that does is the guard. + // With the install last, a concurrent ConnectionRequest found a null guard at + // performOperationComplete(), skipped the shield entirely and opened the + // connection -- so a fail-closed protected host could be called with neither a + // token nor a pin check for as long as engine.initialize() took, which on a + // cold start is exactly when it takes longest. The guard reads the + // configuration live and every path through it waits for initialization, so + // installing it before the engine makes that window a wait rather than a + // bypass. + installNetworkGuard(); + ShieldEngine engine = ShieldEngineRegistry.getEngine(); + try { + engine.initialize(contextForEngine(), config); + setStatus(engine.isAvailable() ? ShieldStatus.OK : ShieldStatus.UNPROTECTED); + } catch (Throwable t) { + // A failure inside the engine must not stop the app from starting. + Log.e(t); + setStatus(ShieldStatus.UNPROTECTED); + } + } finally { + synchronized (AppShield.class) { + initializing = false; + initialized = true; + AppShield.class.notifyAll(); + } + } + } + + /// Test hook: puts the shield back to its pre-`init()` state. + /// + /// `init()` is deliberately one-shot, so without this the ordering it guarantees can + /// only be asserted once per JVM -- and the ordering is the thing that has been wrong + /// twice. Matches the hooks + /// [com.codename1.security.shield.spi.ShieldEngineRegistry] and + /// [com.codename1.io.NetworkManager] already carry for the same reason. + static void resetForTesting() { + synchronized (AppShield.class) { + config = null; + initialized = false; + initializing = false; + guard = null; + lastStatus = ShieldStatus.NOT_INITIALIZED; + runtimeHosts.clear(); + listeners.removeAllElements(); + synchronized (attachedHeaderNames) { + attachedHeaderNames.removeAllElements(); + } + AppShield.class.notifyAll(); + } + } + + /// True while [#init(ShieldConfig)] is between taking the job and finishing it. + /// + /// Separate from `initialized` because the two answer different questions, and + /// conflating them is what let a caller act on a half-built shield. + private static boolean initializing; + + /// Blocks until an initialization in progress has finished. Returns at once when + /// none is, which is every call after startup. + /// + /// Safe from a network thread, which is where [#attach(ConnectionRequest)] runs by + /// contract, and it waits on the same monitor `init()` notifies -- so the wait ends + /// when setup does, including when the engine threw and the `finally` released it. + /// False when the wait was cut short by an interrupt, which is NOT the same as the + /// shield being up. + /// + /// Returning quietly made the interrupt look like "initialization finished": the + /// caller then saw `initialized == false`, took its early return, and the request + /// went out with no token and no pin check -- on a fail-closed host, which is the one + /// request that must not. `ConnectionRequest` does not consult the interrupt flag + /// either, so nothing further down stopped it. The interrupt status is preserved for + /// whoever set it and the caller is told the shield could not be waited for. + private static boolean awaitInitialization() { + synchronized (AppShield.class) { + while (initializing) { + try { + AppShield.class.wait(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + } + return true; + } + + /// Hooks the shield into the network stack, which is what makes + /// [ShieldConfig#protect(String, HostPolicy)] take effect on ordinary requests. Without it a + /// registered host would carry a policy nothing consults. + /// + /// Installed even when no engine is present: the guard is inert in that case (the default + /// engine issues no tokens and enforces no pins) and installing unconditionally keeps the + /// behaviour identical whether or not the enterprise engine was injected. + private static void installNetworkGuard() { + try { + NetworkManager.setNetworkGuard(getNetworkGuard()); + } catch (IllegalStateException e) { + // The slot seals after the first install. An app that installed its own guard + // keeps it; say so rather than failing startup, and point at the composition + // that gets everything back -- not just the token. Advising attach() alone + // would restore header decoration while quietly dropping pin enforcement, + // which is the half of the shield an app cannot notice is missing. + Log.p("AppShield: a network guard is already installed, so protected hosts are " + + "not decorated or pinned automatically. Delegate to " + + "AppShield.getNetworkGuard() from your own guard to restore both; " + + "see the AppShield.getNetworkGuard() documentation."); + } catch (Throwable t) { + Log.e(t); + } + } + + /// The shield's own [NetworkGuard], for an app that has to install a guard of its own. + /// + /// [NetworkManager] holds a single guard and seals the slot on first install, so an app + /// with its own guard leaves no room for the shield's. Delegating to this one is the + /// supported way to have both. Delegate every method, not only + /// [NetworkGuard#beforeRequest(ConnectionRequest)]: attaching the token is the visible + /// half of the shield, and the certificate callbacks are the half that enforces + /// [HostPolicy#isEnforcePins()]. An app that forwards only `beforeRequest` gets tokens + /// and no pinning, and nothing about its behaviour says so. + /// + /// ```java + /// final NetworkGuard shield = AppShield.getNetworkGuard(); + /// NetworkManager.setNetworkGuard(new NetworkGuard() { + /// public void beforeRequest(ConnectionRequest r) throws IOException { + /// myOwnHeaders(r); + /// shield.beforeRequest(r); + /// } + /// public boolean isCertificateCheckRequired(String url) { + /// return myOwnCheckNeeded(url) || shield.isCertificateCheckRequired(url); + /// } + /// public void checkCertificates(ConnectionRequest r, + /// ConnectionRequest.SSLCertificate[] c) throws IOException { + /// myOwnCheck(r, c); + /// shield.checkCertificates(r, c); + /// } + /// public String[] interestingResponseHeaders() { + /// return concat(myOwnHeaderNames(), shield.interestingResponseHeaders()); + /// } + /// public void afterResponse(ConnectionRequest r, int code, String[] headers) { + /// shield.afterResponse(r, code, headers); + /// } + /// }); + /// AppShield.init(cfg); + /// ``` + /// + /// Note that `interestingResponseHeaders()` has to be the union of both guards' names, and + /// that the `headers` array handed to `afterResponse` is positional against it -- so a + /// composing guard must pass the shield the slice that corresponds to the shield's own + /// names, in that order. Installing the shield's guard directly, by calling + /// [#init(ShieldConfig)] before installing anything of your own, avoids the bookkeeping + /// entirely and is what most apps should do. + /// + /// Safe to call before [#init(ShieldConfig)]; the returned guard reads the configuration + /// live rather than capturing it. + public static NetworkGuard getNetworkGuard() { + synchronized (AppShield.class) { + if (guard == null) { + guard = new ShieldNetworkGuard(); + } + return guard; + } + } + + /// True when a real attestation engine is present and available. False in an open-source or + /// unentitled build, and in the simulator unless simulation is switched on. + public static boolean isProtected() { + // Guarded because the engine is pluggable and this is consulted on the failure + // path: a partially initialized engine whose isAvailable() throws would turn a + // fail-open host into a blocked request, which is the exact inversion the + // degradation contract promises will not happen. Unanswerable means unprotected. + try { + return ShieldEngineRegistry.getEngine().isAvailable(); + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + /// The active engine's name, for diagnostics and support logs. + public static String getEngineName() { + return ShieldEngineRegistry.getEngine().getName(); + } + + /// The configuration passed to [#init(ShieldConfig)], or defaults if it has not been called. + public static ShieldConfig getConfig() { + synchronized (AppShield.class) { + if (config == null) { + config = new ShieldConfig(); + } + return config; + } + } + + // ----------------------------------------------------------------- + // Tokens + // ----------------------------------------------------------------- + + /// Fetches a time-limited token, reusing the cached one when it is still good. + public static AsyncResource fetchToken() { + return fetchToken(null); + } + + /// Fetches a token bound to specific request data. + /// + /// Binding ties the token to one request, so a token lifted off a captured request cannot be + /// replayed against a different one. Worth the extra round trip on the calls that matter -- + /// a transfer, a password change -- and not worth it on the rest, which should use the plain + /// [#fetchToken()]. + /// + /// @param bindingData the data to bind to, typically a digest of the request body + public static AsyncResource fetchToken(final String bindingData) { + final AsyncResource result = new TokenResource(); + Display.getInstance().scheduleBackgroundTask(new Runnable() { + @Override + public void run() { + // The wait happens HERE, not before the task is scheduled. + // + // Same window attach() covers -- a caller racing startup must not be told + // the shield was never initialized, which is a lie that lasts + // milliseconds and an error the app cannot tell from the real one -- but + // this method is the asynchronous one, and waiting for it in the caller + // froze whatever thread asked. On the EDT that is a visible stall for the + // length of a cold start, and if the engine's own initialization needs + // anything dispatched to the EDT it is a deadlock: the EDT is parked + // waiting for the initialization that is waiting for the EDT. + if (!awaitInitialization()) { + result.error(new ShieldException(ShieldStatus.NOT_INITIALIZED, + "AppShield was still initializing and the wait was " + + "interrupted")); + return; + } + if (!initialized) { + result.error(new ShieldException(ShieldStatus.NOT_INITIALIZED, + "AppShield.init(...) has not been called")); + return; + } + try { + ShieldToken token = ShieldEngineRegistry.getEngine().fetchToken(bindingData); + setStatus(token.getStatus()); + result.complete(token); + } catch (ShieldException e) { + setStatus(e.getStatus()); + result.error(e); + } catch (Throwable t) { + setStatus(ShieldStatus.SERVICE_DOWN); + result.error(t); + } + } + }); + return result; + } + + /// Discards any cached token. Call this when your backend rejects a token, so the next request + /// re-attests rather than replaying the token that was just refused. + public static void invalidateToken() { + try { + ShieldEngineRegistry.getEngine().invalidate(); + } catch (Throwable t) { + Log.e(t); + } + } + + // ----------------------------------------------------------------- + // Request binding + // ----------------------------------------------------------------- + + /// Attaches the attestation header to a request. + /// + /// **Blocks** while a token is fetched, so it must be called on a network thread. Requests to + /// hosts registered via [ShieldConfig#protect(String, HostPolicy)] are handled automatically + /// and do not need this; use it for a request built outside the normal path. + /// + /// Honours the host's [FailureMode]: under [FailureMode#OPEN] a token failure leaves the + /// request untouched, under [FailureMode#CLOSED] it propagates. + public static void attach(ConnectionRequest request) throws ShieldException { + if (request == null) { + return; + } + // Waits for an initialization already under way rather than treating it as + // "no shield". installNetworkGuard() necessarily runs before init() publishes + // completion -- the guard has to exist before anything can claim to be + // protected -- so a request that started concurrently reaches this method + // through the freshly installed guard while the flag is still false. Returning + // there sent a protected request untouched, including for a host configured to + // fail closed, which is exactly the request that must not go out unprotected. + boolean waited = awaitInitialization(); + String url = request.getUrl(); + String host = hostOf(url); + HostPolicy policy = policyFor(host); + // Always clear first, and clear EVERY name a token has been attached under. + // + // A redirect reuses this request object with its headers intact, so a protected + // endpoint with an open redirect would otherwise hand a replayable token to + // whatever host it points at. Removing only the currently configured name was + // not enough for that: ShieldConfig is mutable and getConfig() hands out the live + // instance, so an app that renames its token header between attempts leaves the + // bearer token sitting in the request under the OLD name -- and the redirect + // carries it to the new host. The set is tiny (one entry unless an app renames + // the header) and only ever grows when a name is actually used. + clearAttachedHeaders(request); + if (!waited) { + // Interrupted mid-wait. Nothing is known about the shield, so this is + // routed through the host's failure mode exactly like an engine that + // could not produce a token: fail-closed refuses, fail-open proceeds. + failOrContinue(policy, new ShieldException(ShieldStatus.NOT_INITIALIZED, + "AppShield: interrupted while waiting for initialization, so no " + + "token could be attached for " + host)); + return; + } + if (!initialized) { + return; + } + if (!policy.isAttachToken()) { + return; + } + if (!isSecure(url)) { + // The token is a bearer credential. Sending it in plaintext -- after a + // downgrade redirect, or a mistyped scheme -- hands it to anyone on the + // path, and pinning cannot help because there is no certificate to pin. + // + // Routed through the failure mode rather than simply returning: a + // fail-closed host promises to refuse requests that carry no valid token, + // and silently sending the body over plaintext instead is the one outcome + // that policy exists to rule out. + Log.p("AppShield: refusing to attach a token to a plaintext URL for " + + host + ". Use https for protected hosts."); + failOrContinue(policy, new ShieldException(ShieldStatus.REJECTED, + "AppShield: " + host + " is a protected host but the request is " + + "plaintext, so no token can be attached safely. Use https.")); + return; + } + try { + ShieldToken token = ShieldEngineRegistry.getEngine().fetchToken(null); + setStatus(token == null ? ShieldStatus.SERVICE_DOWN : token.getStatus()); + if (token != null && token.isValid()) { + String header = getConfig().getTokenHeader(); + // Any spelling of it the app may already have set goes first. Header + // names are case-insensitive, so adding ours beside an existing + // "x-cn1-attest" leaves two fields on the wire and lets the backend or an + // intermediary pick the stale one -- while attach() reports success. + // Done HERE rather than in the general cleanup, which must not touch a + // request the shield is not attaching to: this is the one request that is + // about to receive a token under this name. + request.removeRequestHeader(header); + // Re-checked here, not only at configuration time: the cookie header name + // is a runtime setting, so an app can rename it AFTER choosing a token + // header and land on the same name. The cookie string is written after + // the request's own headers, so the token would be overwritten and this + // method would report success anyway. + if (ShieldHosts.normalize(header).equals( + ShieldHosts.normalize(ConnectionRequest.getCookieHeader()))) { + Log.p("AppShield: the token header " + header + " is now this app's " + + "cookie header, so the token would be overwritten before " + + "the request goes out. Change one of the two."); + failOrContinue(policy, new ShieldException(ShieldStatus.REJECTED, + "AppShield: the token header collides with the cookie " + + "header, so no token can be attached for " + host)); + return; + } + rememberAttachedHeader(request, header, token.getValue()); + request.addRequestHeader(header, token.getValue()); + return; + } + failOrContinue(policy, new ShieldException( + token == null ? ShieldStatus.SERVICE_DOWN : token.getStatus(), + "No valid attestation token for " + host)); + } catch (ShieldException e) { + setStatus(e.getStatus()); + failOrContinue(policy, e); + } catch (Throwable t) { + // An engine is pluggable code that can throw anything. Letting an + // unchecked failure escape would block the request regardless of the + // host's failure mode, which is the opposite of fail-open. + Log.e(t); + setStatus(ShieldStatus.SERVICE_DOWN); + failOrContinue(policy, new ShieldException(ShieldStatus.SERVICE_DOWN, + "Attestation engine failed for " + host)); + } + } + + /// The header name a token was attached under, and the token, per REQUEST. + /// + /// A single "current name" is not enough: [ShieldConfig] is mutable and + /// [#getConfig()] hands out the live instance, so the name that has to be removed on + /// a redirect is the one used when the header was set, which may no longer be + /// configured. A process-wide list of every name ever used is too much: the shield + /// would then strip that name from EVERY request, so an app whose token header is + /// also a header some unprotected service legitimately expects -- `X-API-Key` is the + /// obvious one -- would find the shield quietly deleting it on the way out, on a + /// request the shield has nothing to do with. + /// + /// So the name is remembered against the request it was attached to. Weak keys, + /// because a request that is dropped rather than redirected must not be held alive + /// by this, and the dead entries are swept on every attach so the list cannot grow + /// without bound in a long-lived app. + private static final Vector attachedHeaderNames = new Vector(); + + /// One remembered attachment: which request, the name used, and the token put there. + /// + /// The value is part of the identity of what has to be removed, not bookkeeping. The + /// request is reused across a redirect, and `ConnectionRequest.performOperationComplete()` + /// calls [ConnectionRequest#onRedirect(String)] in between -- the hook an app uses to + /// set up the headers the redirect target needs. If the app installs its own credential + /// under the same name there, the header at cleanup time is no longer the shield's, and + /// removing it by name deletes the app's. + private static final class AttachedHeader { + + private final java.lang.ref.WeakReference request; + private final String name; + private final String value; + + AttachedHeader(ConnectionRequest request, String name, String value) { + this.request = new java.lang.ref.WeakReference(request); + this.name = name; + this.value = value; + } + + ConnectionRequest get() { + return (ConnectionRequest) request.get(); + } + } + + private static void rememberAttachedHeader(ConnectionRequest request, String name, + String value) { + if (request == null || name == null || name.length() == 0) { + return; + } + synchronized (attachedHeaderNames) { + sweepAttachedHeaders(); + for (int i = attachedHeaderNames.size() - 1; i >= 0; i--) { + AttachedHeader entry = (AttachedHeader) attachedHeaderNames.elementAt(i); + if (entry.get() == request && name.equals(entry.name)) { // NOPMD identity + // Same request, same name, newer token: the entry has to carry the + // value that is actually on the request now, or the next cleanup + // compares against a token that was replaced and leaves this one on. + attachedHeaderNames.removeElementAt(i); + } + } + attachedHeaderNames.addElement(new AttachedHeader(request, name, value)); + } + } + + private static void clearAttachedHeaders(ConnectionRequest request) { + // ONLY what this request was given, and only while it is still what was given. + // + // Clearing the configured name unconditionally was the same mistake one step + // larger: it reached every request, so an app whose token header is also one an + // unprotected service expects lost that service's header on a call the shield has + // nothing to do with. Narrowing it to this request left the smaller version of it, + // because the request is not untouched in between: onRedirect() runs between the + // attachment and this cleanup, and an app whose redirect target needs its own key + // under the same name installs it there. A header the shield did not attach is the + // app's, whatever it is called -- including one that replaced the shield's own. + // + // The entry goes either way. Once the value has changed the header belongs to the + // app, and there is nothing left here for the shield to take back. + synchronized (attachedHeaderNames) { + for (int i = attachedHeaderNames.size() - 1; i >= 0; i--) { + AttachedHeader entry = (AttachedHeader) attachedHeaderNames.elementAt(i); + ConnectionRequest owner = entry.get(); + if (owner == null) { + attachedHeaderNames.removeElementAt(i); + } else if (owner == request) { // NOPMD identity: this request, not an equal one + if (entry.value == null) { + // Nothing to compare against, so the safe answer is the one that + // cannot leak a token: remove it. + request.removeRequestHeader(entry.name); + } else { + request.removeRequestHeaderIfUnchanged(entry.name, entry.value); + } + attachedHeaderNames.removeElementAt(i); + } + } + } + } + + /// Drops entries whose request has been collected. Called under the lock. + private static void sweepAttachedHeaders() { + for (int i = attachedHeaderNames.size() - 1; i >= 0; i--) { + if (((AttachedHeader) attachedHeaderNames.elementAt(i)).get() == null) { + attachedHeaderNames.removeElementAt(i); + } + } + } + + /// True for an absolute https URL. Anything else -- http, or a relative URL we cannot + /// classify -- is not somewhere a bearer token belongs. + static boolean isSecure(String url) { + return ShieldHosts.startsWithIgnoreCase(url, "https://"); + } + + private static void failOrContinue(HostPolicy policy, ShieldException e) throws ShieldException { + // A build with no engine must never block a request: that is the + // degradation contract this API documents, and a fail-closed host in an + // open-source or unentitled build would otherwise break outright. + // Registered, not available. An engine may legitimately report itself + // unavailable -- an unsupported device, a failed initialization -- and that is + // exactly when a fail-closed host must refuse, not the moment to stop + // enforcing. Only a build with no engine at all is exempt, which is the + // degradation contract the open-source path documents. + boolean enginePresent = ShieldEngineRegistry.isEngineRegistered(); + if (policy.getFailureMode() == FailureMode.CLOSED && enginePresent) { + throw e; + } + Log.p("AppShield: continuing without a token (" + e.getStatus().getId() + + "); " + (enginePresent ? "host policy is fail-open." + : "no attestation engine is present, so nothing is enforced.")); + } + + /// The headers a protected URL should carry, for network paths that do not go through + /// `ConnectionRequest` -- notably `BrowserComponent.setURL(url, headers)`. + /// + /// Returns an empty table when the host is unprotected or no token is available. Never blocks: + /// it uses the cached token only, because the callers are typically on the EDT. + /// + /// Note this covers only the initial navigation. Requests the loaded page makes itself are not + /// visible to the framework and cannot be given a token or pinned. + public static Hashtable headersFor(String url) { + Hashtable out = new Hashtable(); + if (url == null) { + return out; + } + // Deliberately does NOT wait for initialization. + // + // This method is documented as never blocking and is called from the EDT -- + // BrowserComponent is its reason for existing -- so waiting here froze the UI for + // the length of a cold start, and an engine whose initialization dispatches + // anything to the EDT deadlocked: the EDT parked on the initialization that was + // waiting for the EDT. A synchronous method that returns a map has no way to say + // "later", so the only honest options are to answer now or to hang, and hanging + // the UI is not an option. + // + // The cost is real and belongs in the log rather than in silence: a + // BrowserComponent navigating a protected host during startup loads it without a + // token, and that looks exactly like a page that loaded correctly. Apps that + // navigate to a protected host at launch should call init() before doing so, or + // use fetchToken(), which does wait -- on a background thread. + // Read under the monitor, and only read -- no wait. Without it there is no + // happens-before with init()'s writes, so a caller on another thread could go on + // seeing `initialized == false` after startup finished and quietly navigate a + // protected host without a token, indefinitely. The synchronized block costs an + // uncontended lock and keeps the never-blocking contract, which is a different + // promise from the never-synchronizing one nobody made. + boolean ready; + boolean starting; + synchronized (AppShield.class) { + ready = initialized; + starting = initializing; + } + if (!ready) { + if (starting) { + Log.p("AppShield: headersFor(" + hostOf(url) + ") was called while " + + "initialization is still running, so no token is attached. Call " + + "AppShield.init(...) before navigating to a protected host."); + } + return out; + } + if (!policyFor(hostOf(url)).isAttachToken()) { + return out; + } + if (!isSecure(url)) { + return out; + } + ShieldToken token = getCachedToken(); + // isBoundTo(null) as well as isValid(): a token minted for one specific + // request must not be handed to an unrelated navigation, which is the + // whole reason binding exists. + if (token != null && token.isValid() && token.isBoundTo(null)) { + out.put(getConfig().getTokenHeader(), token.getValue()); + } + return out; + } + + /// The cached token without triggering a fetch. May be null or lapsed. Never blocks. + public static ShieldToken getCachedToken() { + try { + return ShieldEngineRegistry.getEngine().getCachedToken(); + } catch (Throwable t) { + return null; + } + } + + // ----------------------------------------------------------------- + // Host policy + // ----------------------------------------------------------------- + + /// Registers a protected host after [#init(ShieldConfig)], for a backend discovered at + /// runtime. + /// + /// Takes the same patterns as [ShieldConfig#protect(String, HostPolicy)] -- an exact + /// host or a leading `*.` wildcard covering its subdomains -- and resolves them the same + /// way, most specific first. A registration made here wins over a configured one for the + /// same pattern, being the later statement of intent, but does not override a more + /// specific configured host. + public static void addProtectedHost(String host, HostPolicy policy) { + if (host != null && host.length() > 0) { + // Same rule as ShieldConfig.protect: an omitted policy has to pick up + // the configured default failure mode, or setting a fail-closed + // default silently does nothing on this path too. + runtimeHosts.put(ShieldHosts.normalize(host), + policy == null ? implicitPolicy() : policy); + } + } + + /// Registers a host with the default policy, honouring + /// [ShieldConfig#defaultFailureMode(FailureMode)]. + public static void addProtectedHost(String host) { + addProtectedHost(host, null); + } + + /// Every host currently protected: the ones [ShieldConfig] carried into + /// [#init(ShieldConfig)], plus anything registered since through + /// [#addProtectedHost(String, HostPolicy)]. + /// + /// The config alone is not the answer to "what is protected", and treating it as such + /// is a quiet way to lose the runtime registrations. An engine building a pin set from + /// the config would leave a backend discovered at runtime with no pins, so + /// [PinSet#isEnforcedFor] returns false for it and the certificate check is skipped + /// entirely -- the host the app went out of its way to register is the one host not + /// pinned. + public static Enumeration protectedHosts() { + Vector all = new Vector(); + Enumeration configured = getConfig().protectedHosts(); + while (configured.hasMoreElements()) { + Object host = configured.nextElement(); + if (host != null && !all.contains(host)) { + all.addElement(host); + } + } + Enumeration runtime = runtimeHosts.keys(); + while (runtime.hasMoreElements()) { + Object host = runtime.nextElement(); + if (host != null && !all.contains(host)) { + all.addElement(host); + } + } + return all.elements(); + } + + private static HostPolicy implicitPolicy() { + FailureMode mode = getConfig().getDefaultFailureMode(); + if (mode == FailureMode.OPEN) { + return HostPolicy.PROTECTED; + } + return new HostPolicy(true, true, mode); + } + + /// The policy in force for a host. Returns [HostPolicy#UNPROTECTED] for anything not + /// registered, which is the great majority of hosts an app talks to. + public static HostPolicy policyFor(String host) { + if (host == null) { + return HostPolicy.UNPROTECTED; + } + // Runtime registrations resolve exactly as configured ones do, wildcards included. + // An exact-name lookup meant addProtectedHost("*.example.com") covered nothing: + // api.example.com missed it, fell through to a configuration that had never heard + // of the host, and went out with no token and no pin check -- while + // protectedHosts() published the wildcard to the engine, so the pin set was built + // for a host the request path treated as unprotected. + // + // Key by key rather than one table before the other, so specificity decides first: + // a configured secure.example.com is not overridden by a later runtime + // *.example.com, and at the same key the runtime registration wins because it is + // the more recent statement of intent. + ShieldConfig cfg = getConfig(); + for (String key : ShieldHosts.lookupKeys(host)) { + Object runtime = runtimeHosts.get(key); + if (runtime != null) { + return (HostPolicy) runtime; + } + HostPolicy configured = cfg.policyForKey(key); + if (configured != null) { + return configured; + } + } + return HostPolicy.UNPROTECTED; + } + + // ----------------------------------------------------------------- + // Pinning + // ----------------------------------------------------------------- + + /// The pin set currently in force. Never null; may be [PinSet#EMPTY]. + public static PinSet getPinSet() { + try { + PinSet set = ShieldEngineRegistry.getEngine().getPinSet(); + return set == null ? PinSet.EMPTY : set; + } catch (Throwable t) { + return PinSet.EMPTY; + } + } + + // ----------------------------------------------------------------- + // RASP + // ----------------------------------------------------------------- + + /// The runtime self-protection observations recorded so far, combining what the engine + /// detected with anything the app or a library reported to [ShieldSignals]. + /// + /// Informational. The attestation service applies the policy, and it may reach a different + /// conclusion than a naive reading of this array -- an emulator signal, for instance, is + /// normal on a developer's machine. + public static ShieldSignal[] getSignals() { + ShieldSignal[] fromEngine; + try { + fromEngine = ShieldEngineRegistry.getEngine().collectSignals(); + } catch (Throwable t) { + fromEngine = new ShieldSignal[0]; + } + if (fromEngine != null) { + for (ShieldSignal s : fromEngine) { + ShieldSignals.add(s); + } + } + return ShieldSignals.snapshot(); + } + + /// The most recent token status. [ShieldStatus#NOT_INITIALIZED] before [#init(ShieldConfig)]. + public static ShieldStatus getStatus() { + synchronized (AppShield.class) { + return lastStatus; + } + } + + // ----------------------------------------------------------------- + // Observation + // ----------------------------------------------------------------- + + /// Registers a listener for status and signal changes. Callbacks arrive on the EDT. + public static void addListener(ShieldListener l) { + if (l == null) { + return; + } + synchronized (listeners) { + if (!listeners.contains(l)) { + listeners.addElement(l); + } + } + ShieldSignals.addListener(l); + } + + public static void removeListener(ShieldListener l) { + synchronized (listeners) { + listeners.removeElement(l); + } + ShieldSignals.removeListener(l); + } + + // ----------------------------------------------------------------- + // Internals + // ----------------------------------------------------------------- + + /// Test hook: drives a status transition, so the ordering between the transition and + /// the notification can be asserted from a test rather than only reasoned about. + static void setStatusForTesting(ShieldStatus status) { + setStatus(status); + } + + private static void setStatus(ShieldStatus status) { + if (status == null) { + return; + } + // The transition happens under the lock; the dispatch is queued outside it. + // + // Both halves matter. Storing the status and enqueueing its notification as two + // unsynchronized steps let two network threads interleave -- A stores, B stores + // and enqueues, A enqueues -- and listeners then finished on A while getStatus() + // already answered B, with nothing later to correct them. But holding the monitor + // across callSerially is not the fix: callSerially runs the task INLINE when the + // EDT is not up, so it would run application listeners under this class's monitor, + // and a listener that touches the shield -- or waits on a thread that does -- + // deadlocks against attach(), which waits on the same monitor for initialization. + // + // So staleness is settled at delivery instead, exactly as ShieldSignals does it: a + // dispatch that no longer describes the current status drops itself. A superseded + // status was already wrong when it was queued. + ShieldListener[] copy; + synchronized (AppShield.class) { + if (status.equals(lastStatus)) { + return; + } + lastStatus = status; + synchronized (listeners) { + if (listeners.isEmpty()) { + return; + } + copy = new ShieldListener[listeners.size()]; + listeners.copyInto(copy); + } + } + Display.getInstance().callSerially(new StatusDispatch(copy, status)); + } + + /// Whether this is still the status the shield holds. + /// + /// Read under the same monitor the transition is written under, so a dispatch either + /// sees the value it was queued for or a newer one -- never a half-written state. + static boolean isCurrentStatus(ShieldStatus status) { + synchronized (AppShield.class) { + return status.equals(lastStatus); + } + } + + /// The token handle handed back to callers, where exactly one of cancellation and + /// delivery wins. + /// + /// [AsyncResource#complete(Object)] does not consult the cancelled flag: it stores the + /// value, marks the resource done and runs the success callback regardless. So a + /// caller that cancelled -- the screen was closed, the user backed out -- still had + /// its `ready` callback invoked when the attestation round trip finished a moment + /// later, and the error branches did the same through [AsyncResource#error(Throwable)]. + /// That contradicts the contract the rest of the framework is built on and tests, and + /// this is the one place where a late callback fires against a screen that has gone. + /// + /// The claim is taken by whichever arrives first, and the loser does nothing. Both + /// entry points are overridden rather than only the internal ones, because this object + /// is handed to application code that can call either. + private static final class TokenResource extends AsyncResource { + private boolean claimed; + + private boolean claim() { + synchronized (this) { + if (claimed) { + return false; + } + claimed = true; + return true; + } + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + if (!claim()) { + // Already delivered. The base class answers false for a resource that is + // done, and so does this. + return false; + } + return super.cancel(mayInterruptIfRunning); + } + + @Override + public void complete(ShieldToken value) { + if (claim()) { + super.complete(value); + } + } + + @Override + public void error(Throwable t) { + if (claim()) { + super.error(t); + } + } + } + + private static final class StatusDispatch implements Runnable { + private final ShieldListener[] targets; + private final ShieldStatus status; + + StatusDispatch(ShieldListener[] targets, ShieldStatus status) { + this.targets = targets; + this.status = status; + } + + @Override + public void run() { + // Checked here rather than at enqueue time, because what matters is the state + // at delivery: a transition superseded between being queued and arriving would + // otherwise leave listeners holding a status the shield itself no longer has. + if (!isCurrentStatus(status)) { + return; + } + for (ShieldListener target : targets) { + target.statusChanged(status); + } + } + } + + /// Extracts the host from a URL without pulling in a URL parser. Returns null when the URL is + /// not absolute, in which case the host is treated as unprotected. + static String hostOf(String url) { + if (url == null) { + return null; + } + int scheme = url.indexOf("://"); + if (scheme < 0) { + return null; + } + int start = scheme + 3; + int end = url.length(); + for (int i = start; i < url.length(); i++) { + char c = url.charAt(i); + if (c == '/' || c == '?' || c == '#') { + end = i; + break; + } + } + String authority = url.substring(start, end); + // Strip userinfo and port. + int at = authority.lastIndexOf('@'); + if (at >= 0) { + authority = authority.substring(at + 1); + } + int colon = authority.lastIndexOf(':'); + if (colon >= 0 && authority.indexOf(']') < colon) { + authority = authority.substring(0, colon); + } + return authority.length() == 0 ? null : ShieldHosts.normalize(authority); + } + + private static com.codename1.security.shield.spi.EngineContext contextForEngine() { + return ShieldEngineRegistry.getDefaultContext(); + } +} diff --git a/CodenameOne/src/com/codename1/security/shield/FailureMode.java b/CodenameOne/src/com/codename1/security/shield/FailureMode.java new file mode 100644 index 00000000000..cf688260f26 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/FailureMode.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield; + +/// What a protected host should do when a token cannot be obtained. +/// +/// This is deliberately a per-host decision. An app typically wants [#CLOSED] on the handful of +/// endpoints that move money or read personal data, and [#OPEN] everywhere else, so that a shield +/// service outage degrades one feature rather than bricking the app. +public enum FailureMode { + + /// Send the request without a token. The customer's backend still decides what to do with an + /// unattested request; this only means the client does not block it locally. + /// + /// This is the default, and it is the only behaviour available when the app was built without + /// the enterprise engine. + OPEN, + + /// Refuse to send the request, failing it with a [ShieldException] carrying the reason. Use + /// this only where a false negative is more acceptable than an unattested call, and only after + /// reading [ShieldStatus#isTransient()] -- most token failures are network problems, not + /// compromised devices. + CLOSED +} diff --git a/CodenameOne/src/com/codename1/security/shield/HostPolicy.java b/CodenameOne/src/com/codename1/security/shield/HostPolicy.java new file mode 100644 index 00000000000..4c4f0cfde83 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/HostPolicy.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield; + +/// What the shield does for a given host: whether to attach an attestation token, whether to +/// enforce certificate pins, and what to do when a token cannot be obtained. +/// +/// Hosts are opt-in. A host with no policy registered gets [#UNPROTECTED] and is left completely +/// alone -- no header, no pin check, no possibility of a blocked request. That default is what +/// lets an existing app adopt the shield on its own API without disturbing its analytics, CDN, +/// map-tile or ad traffic. +public final class HostPolicy { + + /// The policy for any host the app did not explicitly register. Does nothing at all. + public static final HostPolicy UNPROTECTED = new HostPolicy(false, false, FailureMode.OPEN); + + /// Attach a token, enforce pins if the service has published any, and let the request through + /// when no token is available. The sensible starting point for a protected host. + public static final HostPolicy PROTECTED = new HostPolicy(true, true, FailureMode.OPEN); + + /// As [#PROTECTED] but refuses to send the request without a valid token. Adopt only after + /// running with [#PROTECTED] long enough to know the real token-failure rate for your users. + public static final HostPolicy ENFORCED = new HostPolicy(true, true, FailureMode.CLOSED); + + private final boolean attachToken; + private final boolean enforcePins; + private final FailureMode failureMode; + + public HostPolicy(boolean attachToken, boolean enforcePins, FailureMode failureMode) { + this.attachToken = attachToken; + this.enforcePins = enforcePins; + this.failureMode = failureMode == null ? FailureMode.OPEN : failureMode; + } + + /// True when requests to this host carry the attestation header. + public boolean isAttachToken() { + return attachToken; + } + + /// True when the certificate chain for this host is checked against the published pin set. + /// Note that enforcement still only happens if a pin set for the host actually exists; see + /// [PinSet] for the never-brick rules. + public boolean isEnforcePins() { + return enforcePins; + } + + /// What to do when no token could be obtained. + public FailureMode getFailureMode() { + return failureMode; + } + + /// True when this policy does nothing, so callers can skip work entirely. + public boolean isNoOp() { + return !attachToken && !enforcePins; + } + + @Override + public String toString() { + return "HostPolicy[token=" + attachToken + ", pins=" + enforcePins + + ", onFailure=" + failureMode + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/security/shield/PinSet.java b/CodenameOne/src/com/codename1/security/shield/PinSet.java new file mode 100644 index 00000000000..f28b8c8d650 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/PinSet.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield; + +import java.util.Hashtable; +import java.util.Vector; + +/// An immutable set of certificate pins, keyed by host, as published by the attestation service. +/// +/// Pins are over the **subject public key info**, not the whole certificate, so a host can renew +/// its certificate on the same key pair without invalidating the pin. A chain matches if *any* +/// certificate in it matches *any* pin for the host, which is what makes it safe to pin an issuing +/// CA as the backup. +/// +/// #### The never-brick rules +/// +/// Client-side pinning is the one part of the shield that can take an app offline for reasons the +/// developer cannot fix without an app store release, so the failure behaviour is deliberately +/// asymmetric: +/// +/// - A host with **no pins** is never enforced. That covers first run, a cold start with no +/// network, and any host the service has not published pins for. +/// - A **failed pin fetch** never fails a request. The last known set is kept. +/// - Pins carry a soft expiry, after which a refresh is attempted, and a much later hard expiry. +/// Past the hard expiry the set is dropped and enforcement stops. A device that cannot reach the +/// service for weeks loses pinning; it does not lose the app. +/// - Only an actual mismatch -- a host that *has* pins presenting a chain that matches none of +/// them -- fails a request, and it fails before any request body is written. +public final class PinSet { + + /// A pin set with no hosts. Enforces nothing. + public static final PinSet EMPTY = new PinSet(new Hashtable(), 0, 0, 0); + + private final Hashtable hostToPins; + private final int version; + private final long softExpiry; + private final long hardExpiry; + + /// @param hostToPins host (lowercase) to a `Vector` of base64 SHA-256 SPKI digests + /// @param version monotonic version from the service, used to detect a newer published set + /// @param softExpiry local millis after which a refresh should be attempted, 0 for never + /// @param hardExpiry local millis after which the set is discarded entirely, 0 for never + public PinSet(Hashtable hostToPins, int version, long softExpiry, long hardExpiry) { + // Deep copy. The set is reachable through the public AppShield.getPinSet(), + // and a caller that cleared the backing vectors would leave every host + // looking unpinned -- which silently disables enforcement rather than + // failing visibly. + this.hostToPins = copyOf(hostToPins); + this.version = version; + this.softExpiry = softExpiry; + this.hardExpiry = hardExpiry; + } + + public int getVersion() { + return version; + } + + /// True once the set should be refreshed. Does not mean it has stopped being enforced. + public boolean isStale() { + return softExpiry > 0 && System.currentTimeMillis() > softExpiry; + } + + /// True once the set is too old to keep enforcing. At this point pinning disables itself + /// rather than risk locking a long-offline device out of its own app. + public boolean isExpired() { + return hardExpiry > 0 && System.currentTimeMillis() > hardExpiry; + } + + /// True when this set has at least one pin for the host and has not hard-expired, i.e. when a + /// chain for this host is actually going to be checked. + public boolean isEnforcedFor(String host) { + if (host == null || isExpired()) { + return false; + } + Vector pins = pinsFor(host); + return pins != null && !pins.isEmpty(); + } + + /// Number of hosts with at least one pin. Used by tests and diagnostics. + public int hostCount() { + return hostToPins.size(); + } + + /// The pins registered for a host, honouring a leading `*.` wildcard, or null when the host is + /// not pinned. + public Vector pinsFor(String host) { + if (host == null) { + return null; + } + String h = ShieldHosts.normalize(host); + Object exact = hostToPins.get(h); + if (exact != null) { + return copyOf((Vector) exact); + } + // Walk up the labels so a "*.example.com" entry covers "api.example.com". + int dot = h.indexOf('.'); + while (dot >= 0 && dot < h.length() - 1) { + Object wild = hostToPins.get("*." + h.substring(dot + 1)); + if (wild != null) { + return copyOf((Vector) wild); + } + dot = h.indexOf('.', dot + 1); + } + return null; + } + + private static Hashtable copyOf(Hashtable in) { + Hashtable out = new Hashtable(); + if (in == null) { + return out; + } + java.util.Enumeration keys = in.keys(); + while (keys.hasMoreElements()) { + Object key = keys.nextElement(); + Object value = in.get(key); + out.put(key, value instanceof Vector ? copyOf((Vector) value) : value); + } + return out; + } + + private static Vector copyOf(Vector in) { + Vector out = new Vector(); + if (in != null) { + for (int i = 0; i < in.size(); i++) { + out.addElement(in.elementAt(i)); + } + } + return out; + } + + /// True when at least one of the supplied chain digests matches a pin for the host. + /// + /// Returns true when the host is not pinned at all -- "no opinion" must never be reported as a + /// mismatch, or an unpinned host would start failing. + public boolean matches(String host, String[] chainSpkiDigests) { + if (!isEnforcedFor(host)) { + return true; + } + if (chainSpkiDigests == null || chainSpkiDigests.length == 0) { + return false; + } + Vector pins = pinsFor(host); + for (String digest : chainSpkiDigests) { + if (digest != null && pins.contains(digest)) { + return true; + } + } + return false; + } + + /// True when no host is pinned. + public boolean isEmpty() { + return hostToPins.isEmpty(); + } + + @Override + public String toString() { + return "PinSet[version=" + version + ", hosts=" + hostToPins.size() + + ", stale=" + isStale() + ", expired=" + isExpired() + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java b/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java new file mode 100644 index 00000000000..3996e82fbf1 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/ShieldConfig.java @@ -0,0 +1,348 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield; + +import com.codename1.io.ConnectionRequest; +import java.util.Enumeration; +import java.util.Hashtable; + +/// Configuration for [AppShield#init(ShieldConfig)]. Chainable. +/// +/// ```java +/// AppShield.init(new ShieldConfig() +/// .protect("api.mybank.example", HostPolicy.PROTECTED) +/// .protect("*.mybank.example", HostPolicy.PROTECTED)); +/// ``` +/// +/// The defaults are chosen so that adding the shield to an existing app changes nothing until +/// hosts are explicitly protected: no host is touched, failures are open, and signal collection is +/// on because reporting costs nothing and is what makes the service useful. +public final class ShieldConfig { + + /// Header that carries the attestation token. + /// + /// Deliberately not `Authorization`: that slot belongs to the app's own user authentication, + /// and the two answer different questions -- who the user is, versus whether this is a genuine + /// unmodified app on an uncompromised device. A backend needs both, so they must compose. + public static final String DEFAULT_TOKEN_HEADER = "X-CN1-Attest"; + + private static final String DEFAULT_ENDPOINT = "https://cloud.codenameone.com/api/v2/attest"; + + private String endpoint = DEFAULT_ENDPOINT; + private String tokenHeader = DEFAULT_TOKEN_HEADER; + private FailureMode defaultFailureMode = FailureMode.OPEN; + /// Hosts registered without an explicit policy. Their policy is resolved from the default at + /// read time, so it does not depend on the order the builder was called in. + private final java.util.Vector implicitHosts = new java.util.Vector(); + private int refreshThresholdPercent = 50; + private boolean collectSignals = true; + private final Hashtable hostPolicies = new Hashtable(); + + /// Overrides the attestation service endpoint. Only needed for a private deployment or a test + /// double. + public ShieldConfig endpoint(String url) { + if (url != null) { + this.endpoint = url; + } + return this; + } + + /// Overrides the header used to carry the token. Change this only if it collides with + /// something already in use on your backend. + /// + /// `Content-Type` is refused. [com.codename1.io.ConnectionRequest#addRequestHeader] + /// special-cases that name into the request's own content type rather than the header + /// map, so the token would replace the request's media type and -- because the removal + /// path only clears the map -- would survive a redirect to an unprotected host and be + /// handed to it. A leak with no symptom on the way there. + /// + /// @throws IllegalArgumentException if the name cannot carry a token safely + public ShieldConfig tokenHeader(String name) { + if (name != null && name.length() > 0) { + String normalized = ShieldHosts.normalize(name); + // Before every reserved-name check, because folding case does not make a + // malformed name comparable: "Cookie " normalizes to "cookie ", matches + // nothing on any list below, and walks straight past the checks written for + // exactly that name. The name then goes on the wire -- HttpURLConnection + // accepts a trailing space -- where an intermediary is entitled to reject the + // malformed field or read it as something else, so a fail-closed host ends up + // with attach() reporting success and a backend that received no token. + if (!isFieldNameToken(name)) { + throw new IllegalArgumentException(name + " is not a legal HTTP header " + + "name, so it cannot carry the attestation token: field names are " + + "tokens, with no spaces and no separators. Whatever survives on " + + "the wire is not what was asked for. Use a header of your own, " + + "or leave the default " + DEFAULT_TOKEN_HEADER + "."); + } + if ("content-type".equals(normalized)) { + throw new IllegalArgumentException("Content-Type cannot carry the " + + "attestation token: it is not stored as an ordinary header, so it " + + "cannot be cleared when a request redirects off a protected host, " + + "and the token would follow the redirect. Use a header of your " + + "own, or leave the default " + DEFAULT_TOKEN_HEADER + "."); + } + // The cookie header NAME is configurable at runtime + // (ConnectionRequest.setCookieHeader), so a hard-coded "cookie" in the list + // below is only the default. An app that renames it and then picks the same + // name for the token loses the token to its own cookie string -- written + // after the request's own headers, exactly as the default name is. + if (ShieldHosts.normalize(ConnectionRequest.getCookieHeader()) + .equals(normalized)) { + throw new IllegalArgumentException(name + " cannot carry the " + + "attestation token: it is this app's cookie header, which " + + "ConnectionRequest writes from its own cookie store after the " + + "request's headers are in place -- so the token would be " + + "overwritten after attach() reported success. Use a header of " + + "your own, or leave the default " + DEFAULT_TOKEN_HEADER + "."); + } + for (String reserved : TRANSPORT_HEADERS) { + if (reserved.equals(normalized)) { + throw new IllegalArgumentException(name + " cannot carry the " + + "attestation token: it is connection or framing metadata, " + + "which the HTTP transport owns. Depending on the platform it " + + "is overwritten, refused, or acted on -- so attach() would " + + "report success while the backend received a request with no " + + "token, or a malformed one. Use a header of your own, or " + + "leave the default " + DEFAULT_TOKEN_HEADER + "."); + } + } + this.tokenHeader = name; + } + return this; + } + + /// Whether this is a legal HTTP field name -- an RFC 9110 token, so no spaces, no + /// separators, nothing outside printable ASCII. + /// + /// Rejecting rather than trimming, for the same reason the websocket handshake does: + /// 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) { + 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; + } + + /// Header names the transport owns, so an attestation token put in one does not + /// arrive as a header at all. + /// + /// Four families, all lower-cased for comparison because header names are + /// case-insensitive: + /// + /// - framing (`content-length`, `transfer-encoding`) -- the transport computes these + /// from the body it is about to send, and a value that disagrees is either + /// discarded or produces a request the server rejects outright; + /// - routing (`host`) -- this selects the virtual host, so overwriting it sends the + /// request somewhere else entirely; + /// - hop-by-hop (`connection`, `keep-alive`, `proxy-connection`, `te`, `trailer`, + /// `upgrade`, `proxy-authorization`, `proxy-authenticate`) -- defined to be + /// consumed by the next hop and not forwarded, so the token would be stripped in + /// transit by a proxy that is behaving correctly; + /// - written by a port on the way out (`accept-encoding`, `x-http-method-override`) + /// -- the Android implementation clears `Accept-Encoding` on a HEAD request to work + /// around a platform bug, and sets `X-HTTP-Method-Override` itself when its PATCH + /// fallback is in use. Both overwrite whatever was there, so a token in one of them + /// is replaced between `attach()` reporting success and the request going out, and + /// only on the platform and request shape that triggers it. + /// + /// Refused rather than warned about, because the failure has no symptom on the + /// client: `attach()` returns having set the header, and the request reaches the + /// backend without a usable token. The developer sees a working call and a backend + /// that says they are unauthenticated. + private static final String[] TRANSPORT_HEADERS = { + "host", "content-length", "transfer-encoding", "connection", + "keep-alive", "proxy-connection", "te", "trailer", "upgrade", + // The two proxy-credential headers are hop-by-hop like the rest, and worth + // naming because they do not LOOK like transport plumbing -- they look like a + // place credentials belong, which is exactly why someone would pick one. A + // forward proxy consumes them; the origin never sees the token, and the failure + // appears only once a user is behind such a proxy, on a network nobody testing + // this was on. + "proxy-authorization", "proxy-authenticate", + // Not transport metadata: these are written by a port. Android clears + // Accept-Encoding on HEAD around a platform bug and sets X-HTTP-Method-Override + // for its PATCH fallback, both after the request's own headers are in place. The + // failure is worse than a plain collision because it is conditional -- one HTTP + // method, one platform -- so it would pass every test that did not happen to use + // that shape. + "accept-encoding", "x-http-method-override", + // And User-Agent, which JavaSEPort rewrites to a fixed BlackBerry string for any + // URL containing facebook.com -- an old patch for getting a readable login page. + // Conditional on the HOST, so a token there works everywhere until the one + // request that matters. + "user-agent", + // Cookie is not transport framing, but it fails the same way and worse. + // ConnectionRequest emits userHeaders first and THEN calls setHeader("Cookie", + // ...) with the generated cookie string, so with cookie handling on and any + // stored cookie the token is overwritten by the request itself -- after + // attach() has reported success. A fail-closed host would then send a protected + // request with no token and no indication anything went wrong. + "cookie" + }; + + /// The failure mode applied to hosts registered without an explicit one. + public ShieldConfig defaultFailureMode(FailureMode mode) { + if (mode != null) { + this.defaultFailureMode = mode; + } + return this; + } + + /// How far through a token's lifetime to trigger a background refresh, as a percentage. + /// Refreshing early is what stops a request ever having to wait on the network. + public ShieldConfig refreshThresholdPercent(int percent) { + if (percent > 0 && percent < 100) { + this.refreshThresholdPercent = percent; + } + return this; + } + + /// Whether to gather runtime self-protection observations. On by default; they ride along with + /// the token fetch, so there is no extra request and no extra battery cost. + public ShieldConfig collectSignals(boolean collect) { + this.collectSignals = collect; + return this; + } + + /// Registers a host to protect. Accepts an exact host or a leading `*.` wildcard covering its + /// subdomains. Hosts not registered here are never touched. + public ShieldConfig protect(String hostPattern, HostPolicy policy) { + if (hostPattern != null && hostPattern.length() > 0) { + String key = ShieldHosts.normalize(hostPattern); + if (policy == null) { + // Recorded as implicit rather than resolved now. A builder is chained in + // whatever order reads well, so `.protect(h).defaultFailureMode(CLOSED)` + // must mean the same thing as the reverse -- snapshotting the default at + // registration time left the host fail-open while the finished config + // reported a closed default, which is the kind of disagreement nobody + // finds until it matters. + hostPolicies.remove(key); + implicitHosts.addElement(key); + } else { + implicitHosts.removeElement(key); + hostPolicies.put(key, policy); + } + } + return this; + } + + /// Registers a host with the default policy, which honours + /// [#defaultFailureMode(FailureMode)]. + public ShieldConfig protect(String hostPattern) { + return protect(hostPattern, null); + } + + /// The policy used when a host is registered without an explicit one. + /// + /// Built from [#defaultFailureMode(FailureMode)] rather than returning the + /// [HostPolicy#PROTECTED] constant, whose mode is always + /// [FailureMode#OPEN] -- otherwise setting a fail-closed default would + /// silently do nothing for every host registered the short way, which is + /// most of them. + private HostPolicy implicitPolicy() { + if (defaultFailureMode == FailureMode.OPEN) { + return HostPolicy.PROTECTED; + } + return new HostPolicy(true, true, defaultFailureMode); + } + + public String getEndpoint() { + return endpoint; + } + + public String getTokenHeader() { + return tokenHeader; + } + + public FailureMode getDefaultFailureMode() { + return defaultFailureMode; + } + + public int getRefreshThresholdPercent() { + return refreshThresholdPercent; + } + + public boolean isCollectSignals() { + return collectSignals; + } + + /// Resolves a host to its policy: exact match first, then the nearest `*.` wildcard, then + /// [HostPolicy#UNPROTECTED]. Never returns null. + public HostPolicy policyFor(String host) { + if (host == null) { + return HostPolicy.UNPROTECTED; + } + for (String key : ShieldHosts.lookupKeys(host)) { + HostPolicy match = policyForKey(key); + if (match != null) { + return match; + } + } + return HostPolicy.UNPROTECTED; + } + + /// The policy registered for an exact key, or null. Implicit registrations resolve against the + /// default as it stands now, not as it stood when they were registered. + /// + /// Reachable from [AppShield] so runtime registrations can be resolved against the + /// configured ones key by key, most specific first, rather than one table entirely + /// before the other. + HostPolicy policyForKey(String key) { + Object explicit = hostPolicies.get(key); + if (explicit != null) { + return (HostPolicy) explicit; + } + return implicitHosts.contains(key) ? implicitPolicy() : null; + } + + /// True when at least one host is registered, so callers can skip work entirely. + public boolean hasProtectedHosts() { + return !hostPolicies.isEmpty() || !implicitHosts.isEmpty(); + } + + /// The registered host patterns, explicit and implicit alike. + public Enumeration protectedHosts() { + java.util.Vector all = new java.util.Vector(); + Enumeration keys = hostPolicies.keys(); + while (keys.hasMoreElements()) { + all.addElement(keys.nextElement()); + } + for (int i = 0; i < implicitHosts.size(); i++) { + if (!all.contains(implicitHosts.elementAt(i))) { + all.addElement(implicitHosts.elementAt(i)); + } + } + return all.elements(); + } +} diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldException.java b/CodenameOne/src/com/codename1/security/shield/ShieldException.java new file mode 100644 index 00000000000..43070c41814 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/ShieldException.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield; + +import java.io.IOException; + +/// Raised when a shield operation cannot produce a usable token, or when a request to a protected +/// host is refused because its certificate chain matched no configured pin. +/// +/// Extends `IOException` so it flows through the normal `ConnectionRequest` error path rather than +/// needing its own handling. Always check [#getStatus()] before deciding what to show the user: +/// [ShieldStatus#isTransient()] distinguishes "could not reach the service" from "this device was +/// rejected", and those deserve very different UX. +public class ShieldException extends IOException { + + /// The status identifier rather than the [ShieldStatus] itself. + /// + /// `IOException` is serializable, and holding a non-serializable field on a + /// serializable class is both a static-analysis error and a latent null after a + /// round trip -- which would break [#getStatus()]'s never-null contract at exactly + /// the moment someone is trying to work out why a request failed. A `String` + /// survives serialization, and [ShieldStatus#forId(String)] resolves it back to the + /// canonical constant, so identity comparisons still hold. + private final String statusId; + + public ShieldException(ShieldStatus status, String message) { + super(message); + this.statusId = (status == null ? ShieldStatus.NOT_INITIALIZED : status).getId(); + } + + /// Why the operation failed. Never null. + public ShieldStatus getStatus() { + return ShieldStatus.forId(statusId); + } +} diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldHosts.java b/CodenameOne/src/com/codename1/security/shield/ShieldHosts.java new file mode 100644 index 00000000000..efda471f251 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/ShieldHosts.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield; + +/// Case folding for hostnames and URL schemes, fixed to ASCII. +/// +/// #### Why not `String.toLowerCase()` +/// +/// It folds using the device's locale. Under the Turkish locale an uppercase ASCII `I` becomes a +/// dotless lowercase letter outside ASCII, so a request to `API.example.com` stops matching a +/// policy registered as `api.example.com`. The consequence is not a display glitch: the host +/// silently looks unprotected, +/// so no token is attached and no pin is enforced -- on precisely the devices whose users the +/// developer never tests with. +/// +/// `toLowerCase(Locale.ENGLISH)` would fix it too, but hostnames and URL schemes are ASCII by +/// definition, so folding them by hand removes the locale from the question entirely and keeps this +/// working on the ports with a reduced `java.util.Locale`. +final class ShieldHosts { + + private ShieldHosts() { + } + + /// Lowercases the ASCII letters and drops a terminal DNS root dot. Null in, null out. + /// + /// `https://api.example.com./` is a valid absolute name that resolves identically to + /// `api.example.com`, so keeping the dot would leave a normally registered policy unmatched -- + /// and the failure is the silent kind: no token attached, no pin enforced. Applied to + /// configured, runtime, request and pin hosts alike, since they all pass through here. + static String normalize(String s) { + if (s == null) { + return null; + } + // Only one, and never on a bare "." -- an empty host is not an improvement. + if (s.length() > 1 && s.charAt(s.length() - 1) == '.') { + s = s.substring(0, s.length() - 1); + } + int len = s.length(); + StringBuilder sb = null; + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + if (c >= 'A' && c <= 'Z') { + if (sb == null) { + sb = new StringBuilder(len); + sb.append(s, 0, i); + } + sb.append((char) (c + 32)); + } else if (sb != null) { + sb.append(c); + } + } + return sb == null ? s : sb.toString(); + } + + /// The keys a host can match, most specific first: the host itself, then a `*.` wildcard + /// for each parent domain outward. + /// + /// One generator, because two resolutions are one bug. Runtime registrations went into a + /// table looked up by exact name only, so `addProtectedHost("*.example.com")` covered + /// nothing at all: `api.example.com` missed it, fell through to the startup configuration + /// which had never heard of the host, and went out with no token and no pin check -- the + /// host the app went out of its way to protect being the one host unprotected. + /// + /// The apex is deliberately absent: `*.example.com` is subdomains, and `example.com` + /// itself has to be registered on its own. Nothing here can escape the name either -- + /// the wildcards are built from the suffixes of the host being resolved, so + /// `example.com.evil.test` produces `*.com.evil.test` and never `*.example.com`. + static String[] lookupKeys(String host) { + String h = normalize(host); + if (h == null || h.length() == 0) { + return new String[0]; + } + java.util.Vector keys = new java.util.Vector(); + keys.addElement(h); + int dot = h.indexOf('.'); + while (dot >= 0 && dot < h.length() - 1) { + keys.addElement("*." + h.substring(dot + 1)); + dot = h.indexOf('.', dot + 1); + } + String[] out = new String[keys.size()]; + keys.copyInto(out); + return out; + } + + /// True when the URL carries the given lowercase ASCII scheme prefix, whatever case it is in. + static boolean startsWithIgnoreCase(String value, String lowerPrefix) { + if (value == null || lowerPrefix == null || value.length() < lowerPrefix.length()) { + return false; + } + for (int i = 0; i < lowerPrefix.length(); i++) { + char c = value.charAt(i); + if (c >= 'A' && c <= 'Z') { + c = (char) (c + 32); + } + if (c != lowerPrefix.charAt(i)) { + return false; + } + } + return true; + } +} diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldListener.java b/CodenameOne/src/com/codename1/security/shield/ShieldListener.java new file mode 100644 index 00000000000..5872b55350a --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/ShieldListener.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield; + +/// Callback for shield state changes. Register with [AppShield#addListener(ShieldListener)]. +/// +/// All callbacks are delivered on the EDT, so they may touch the UI directly. The framework itself +/// never shows a dialog or terminates the app over a shield event -- what the user sees is entirely +/// the app's decision, made here. +public interface ShieldListener { + + /// The token status changed, for example from [ShieldStatus#OK] to [ShieldStatus#REJECTED]. + /// + /// Branch on [ShieldStatus#isTransient()] before reacting. A transient status means the + /// service was unreachable and will likely be reachable again shortly; reacting to it the same + /// way as [ShieldStatus#REJECTED] is how an app ends up locking out users on a bad connection. + void statusChanged(ShieldStatus status); + + /// A new runtime self-protection observation was recorded. Informational: the attestation + /// service decides what a signal means for token issuance, and it may reach a different + /// conclusion than the device would. + void signalRaised(ShieldSignal signal); +} diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java b/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java new file mode 100644 index 00000000000..6eceedd9804 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/ShieldNetworkGuard.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.Log; +import com.codename1.io.NetworkGuard; +import com.codename1.security.shield.spi.ShieldEngineRegistry; +import java.io.IOException; + +/// Connects [AppShield] to the network stack. +/// +/// This is what makes [ShieldConfig#protect(String, HostPolicy)] mean anything: without a guard +/// installed, registering a host would configure a policy that nothing ever consults, and tokens +/// would only ever be attached by an app calling [AppShield#attach(ConnectionRequest)] by hand. +/// Installed once from [AppShield#init(ShieldConfig)]. +/// +/// The class stays package-private -- apps configure behaviour through [ShieldConfig], and there +/// is nothing here to subclass or reconfigure. The *instance* is reachable through +/// [AppShield#getNetworkGuard()], because [com.codename1.io.NetworkManager] holds one guard and +/// an app that needs its own has to be able to delegate to this one rather than displace it. +final class ShieldNetworkGuard implements NetworkGuard { + + @Override + public void beforeRequest(ConnectionRequest request) throws IOException { + // Also clears the header when the host is not protected, which is what + // stops a token following a cross-host redirect. + AppShield.attach(request); + } + + @Override + public boolean isCertificateCheckRequired(String url) { + String host = AppShield.hostOf(url); + if (host == null || !AppShield.policyFor(host).isEnforcePins()) { + return false; + } + // Only ask for the richer certificate details when a pin set actually + // covers this host -- collecting them has a per-connection cost, and an + // unpinned host must be left completely alone. + return AppShield.getPinSet().isEnforcedFor(host); + } + + @Override + public void checkCertificates(ConnectionRequest request, + ConnectionRequest.SSLCertificate[] certificates) throws IOException { + String host = AppShield.hostOf(request.getUrl()); + if (host == null || !AppShield.policyFor(host).isEnforcePins()) { + return; + } + if (certificates == null || certificates.length == 0) { + // The iOS contract explicitly allows an empty array when the chain is not + // available from the TLS cache. That is "we did not see a certificate", not + // "we saw a wrong one" -- and reporting a mismatch would fail a perfectly + // good cached connection with a PIN_MISMATCH naming a certificate nobody + // observed. Pinning fails open on unavailability everywhere else in this + // design; this is the same rule. + return; + } + String[] spki = new String[certificates.length]; + String[] certs = new String[spki.length]; + for (int i = 0; i < spki.length; i++) { + spki[i] = certificates[i].getPublicKeyDigest(); + certs[i] = certificates[i].getFingerprint(); + } + boolean ok; + try { + // Local and non-blocking by contract: on iOS this runs on the TLS + // delegate thread with the handshake held open. + ok = ShieldEngineRegistry.getEngine().verifyPins(host, spki, certs); + } catch (Throwable t) { + // A crash in pin comparison must not fail closed by accident. A real + // mismatch is reported as false, not thrown. + Log.e(t); + return; + } + if (!ok) { + throw new ShieldException(ShieldStatus.PIN_MISMATCH, + "The certificate chain presented by " + host + + " matched none of its configured pins"); + } + } + + @Override + public String[] interestingResponseHeaders() { + return new String[] {AppShield.REJECT_HEADER}; + } + + @Override + public void afterResponse(ConnectionRequest request, int responseCode, String[] headers) { + if (responseCode != 401 && responseCode != 403) { + return; + } + String host = AppShield.hostOf(request.getUrl()); + if (host == null || !AppShield.policyFor(host).isAttachToken()) { + return; + } + // Only when the backend says it was the *token* it rejected. A protected + // API usually also carries ordinary user authorization, so an expired + // login or a plain permission denial is a 401/403 that has nothing to do + // with attestation -- re-attesting on every one of those would push a + // client into rate limiting precisely while it is already failing to log + // in. + if (headers == null || headers.length == 0 || headers[0] == null) { + return; + } + AppShield.invalidateToken(); + } +} diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldSignal.java b/CodenameOne/src/com/codename1/security/shield/ShieldSignal.java new file mode 100644 index 00000000000..d8d0cdc7b2a --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/ShieldSignal.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield; + +/// One runtime self-protection observation, such as "a hooking framework is loaded". +/// +/// Signals are **reports, not verdicts**. The device never decides it is compromised and never +/// terminates itself over one of these; it reports what it saw and the attestation service decides +/// whether to keep issuing tokens. That ordering matters for two reasons: a hard local exit is +/// trivially patched out of the binary, and it destroys the telemetry that would have told the +/// developer an attack was happening at all. +public final class ShieldSignal { + + /// A rooted Android device. + public static final String ROOT = "root"; + /// A jailbroken iOS device. + public static final String JAILBREAK = "jailbreak"; + /// A dynamic instrumentation or hooking framework is present. + public static final String HOOK = "hook"; + /// The app is running on an emulator or simulator. + public static final String EMULATOR = "emulator"; + /// A debugger is attached to the process. + public static final String DEBUGGER = "debugger"; + /// The app's signing certificate does not match the one it was built with. + public static final String REPACKAGED = "repackaged"; + /// An accessibility service that is not on the allow list is enabled. + public static final String ACCESSIBILITY = "accessibility"; + + private final String id; + private final int severity; + private final String detail; + private final long timestamp; + + public ShieldSignal(String id, int severity, String detail) { + this.id = id; + this.severity = severity < 0 ? 0 : (severity > 100 ? 100 : severity); + this.detail = detail; + this.timestamp = System.currentTimeMillis(); + } + + /// A stable identifier such as [#HOOK]. Engines may report ids this build predates. + public String getId() { + return id; + } + + /// How strongly this points at an attack, 0 to 100. Advisory only -- the service applies the + /// policy, so a low severity here does not mean the service will ignore it. + public int getSeverity() { + return severity; + } + + /// What was actually observed, for example the offending package or library name. May be null. + public String getDetail() { + return detail; + } + + /// When the observation was made. + public long getTimestamp() { + return timestamp; + } + + @Override + public String toString() { + return id + "(" + severity + (detail == null ? "" : ", " + detail) + ")"; + } +} diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldSignals.java b/CodenameOne/src/com/codename1/security/shield/ShieldSignals.java new file mode 100644 index 00000000000..70858dcd0fe --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/ShieldSignals.java @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield; + +import com.codename1.ui.Display; +import java.util.Vector; + +/// Collection point for runtime self-protection observations. +/// +/// The framework's own detections report here, and so can a cn1lib or the app itself when it +/// notices something the platform checks cannot see -- a failed server-side consistency check, a +/// suspicious sequence of user actions. Everything recorded here is offered to the attestation +/// service on the next token fetch, where the policy engine decides what it means. +/// +/// The bus is bounded: it keeps only the most recent observations, and repeat reports of the same +/// id collapse onto the existing entry rather than accumulating. A hooking framework that trips a +/// detector on every frame must not be able to exhaust memory. +public final class ShieldSignals { + + private static final int MAX_SIGNALS = 32; + + private static final Vector signals = new Vector(); + private static final Vector listeners = new Vector(); + + private ShieldSignals() { + } + + /// Records an observation. Repeat reports of an id already present update that entry in place. + /// Safe to call from any thread; listeners are notified on the EDT. + /// + /// An identical repeat still updates the stored entry -- the snapshot is meant to hold + /// the most recent observation of each signal -- but does not notify again. + public static void add(ShieldSignal signal) { + if (signal == null || signal.getId() == null) { + return; + } + synchronized (signals) { + boolean replaced = false; + for (int i = 0; i < signals.size(); i++) { + ShieldSignal existing = (ShieldSignal) signals.elementAt(i); + if (existing.getId().equals(signal.getId())) { + // Nothing new to say. Re-reporting an identical observation is the + // normal case, not an edge one: AppShield.getSignals() re-adds + // everything collectSignals() returns, so a listener that refreshes + // its view by calling getSignals() notified itself, forever. Even + // without that, a detector polling on a timer queued a runnable per + // poll per signal onto the EDT -- an unbounded queue behind a bus + // whose whole selling point is that it is bounded. + boolean sameObservation = existing.getSeverity() == signal.getSeverity() + && sameDetail(existing.getDetail(), signal.getDetail()); + // The entry is replaced either way, and only the NOTIFICATION is + // suppressed. Keeping the old object was a second bug hiding behind + // the first: this 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, so the entry the + // engine and the server were shown kept the timestamp of the first + // sighting hours after the fact. "When did this device last look + // compromised" is a question the answer is used for. + signals.setElementAt(signal, i); + replaced = true; + if (sameObservation) { + return; + } + break; + } + } + if (!replaced) { + if (signals.size() >= MAX_SIGNALS) { + signals.removeElementAt(0); + } + signals.addElement(signal); + } + } + // Outside the lock on every path. Display.callSerially runs the task inline when + // the EDT is not up yet, so notifying in here would run application listeners + // while holding the signal monitor -- and a listener that waits on anything which + // needs that monitor deadlocks. + // + // Which is why the ordering problem is solved at the far end instead. Two workers + // reporting different observations of one id could interleave as: A stores, B + // stores over it, B enqueues, A enqueues -- and listeners then finished on A while + // snapshot() already answered B, with nothing later guaranteed to correct them. + // The dispatch checks on arrival whether it still describes the current + // observation and drops itself if it does not, so the last thing a listener is + // told is always what the bus holds. A superseded observation is not worth + // announcing: it was already wrong when it was queued. + notifyListeners(signal); + } + + /// Whether two observations of one signal say the same thing. + /// + /// The detail is what distinguishes "accessibility service X" from "service Y" under + /// one id, so a change in it is a new observation and a repeat of it is not. + private static boolean sameDetail(String a, String b) { + return a == null ? b == null : a.equals(b); + } + + /// Convenience overload for the common case. + public static void add(String id, int severity, String detail) { + add(new ShieldSignal(id, severity, detail)); + } + + /// The observations recorded so far. Never null. + public static ShieldSignal[] snapshot() { + synchronized (signals) { + ShieldSignal[] out = new ShieldSignal[signals.size()]; + signals.copyInto(out); + return out; + } + } + + /// True when any recorded observation is at or above the given severity. + public static boolean hasSignalAtLeast(int severity) { + synchronized (signals) { + for (int i = 0; i < signals.size(); i++) { + if (((ShieldSignal) signals.elementAt(i)).getSeverity() >= severity) { + return true; + } + } + } + return false; + } + + /// Discards every recorded observation. Intended for tests and for the simulator's + /// signal-faking menu. + public static void clear() { + synchronized (signals) { + signals.removeAllElements(); + } + } + + static void addListener(ShieldListener l) { + if (l == null) { + return; + } + synchronized (listeners) { + if (!listeners.contains(l)) { + listeners.addElement(l); + } + } + } + + static void removeListener(ShieldListener l) { + synchronized (listeners) { + listeners.removeElement(l); + } + } + + private static void notifyListeners(ShieldSignal signal) { + ShieldListener[] copy; + synchronized (listeners) { + if (listeners.isEmpty()) { + return; + } + copy = new ShieldListener[listeners.size()]; + listeners.copyInto(copy); + } + Display.getInstance().callSerially(new SignalDispatch(copy, signal)); + } + + /// Whether this still says what the bus holds for its id. + /// + /// What matters at delivery is the OBSERVATION, not the object. Identity alone was the + /// obvious reading -- a newer report replaces the entry, so a different object means + /// this one was superseded -- and it dropped notifications that nothing else was going + /// to send. An identical repeat replaces the entry too, and deliberately queues + /// nothing; a detector polling on a timer therefore silenced the first sighting of a + /// signal whenever its second poll landed while the first notification was still in + /// flight. That first sighting is the notification listeners exist for, and the signal + /// sat in [#snapshot()] with nobody told. + /// + /// So the question is whether the entry still says the same thing. A superseded report + /// -- a different severity or detail -- is still dropped, which is what this exists + /// for; the cost is that two reports of the same observation can both be announced, + /// and being told twice about a signal that is genuinely there is not a defect. + static boolean isCurrentObservation(ShieldSignal signal) { + synchronized (signals) { + for (int i = 0; i < signals.size(); i++) { + ShieldSignal existing = (ShieldSignal) signals.elementAt(i); + if (existing.getId().equals(signal.getId())) { + return existing == signal // NOPMD identity: the ordinary case + || (existing.getSeverity() == signal.getSeverity() + && sameDetail(existing.getDetail(), signal.getDetail())); + } + } + } + // Cleared, or evicted by the bound. Either way nothing is claiming it now. + return false; + } + + private static final class SignalDispatch implements Runnable { + private final ShieldListener[] targets; + private final ShieldSignal signal; + + SignalDispatch(ShieldListener[] targets, ShieldSignal signal) { + this.targets = targets; + this.signal = signal; + } + + @Override + public void run() { + // Checked here rather than at enqueue time, because the point is the state + // at delivery: a report that has been superseded between being queued and + // arriving would otherwise leave listeners holding an observation the bus + // itself no longer has. + if (!isCurrentObservation(signal)) { + return; + } + for (ShieldListener target : targets) { + target.signalRaised(signal); + } + } + } +} diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldStatus.java b/CodenameOne/src/com/codename1/security/shield/ShieldStatus.java new file mode 100644 index 00000000000..bf43f91eec1 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/ShieldStatus.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield; + +/// Outcome of a shield operation. +/// +/// The single most important distinction in this class is between *"I could not reach the +/// attestation service"* ([#NO_NETWORK], [#POOR_NETWORK], [#SERVICE_DOWN], [#RATE_LIMITED]) and +/// *"the attestation service looked at this device and said no"* ([#REJECTED]). An app should +/// almost always treat the first group as a transient condition to retry through, and only the +/// second as evidence that something is actually wrong with the device it is running on. +/// Collapsing the two into a single "attestation failed" boolean is the most common way to build +/// an app that either locks out users on a train or trusts a rooted phone. +/// +/// This is a class of constants rather than an enum because the vocabulary is wire-visible: the +/// attestation engine may report a status that this build of the framework predates, and +/// [#getId()] round-trips it rather than failing to resolve. +public final class ShieldStatus { + + /// The operation succeeded and any token returned is usable. + public static final ShieldStatus OK = new ShieldStatus("ok", true); + + /// The app was built without the enterprise attestation engine. Everything degrades to a + /// no-op: no token is issued, no pin is enforced, and no request is blocked. + public static final ShieldStatus UNPROTECTED = new ShieldStatus("unprotected", false); + + /// [AppShield#init(ShieldConfig)] has not been called yet. + public static final ShieldStatus NOT_INITIALIZED = new ShieldStatus("notInitialized", false); + + /// The device has no connectivity. Transient. + public static final ShieldStatus NO_NETWORK = new ShieldStatus("noNetwork", false); + + /// The request timed out or DNS failed. Transient. + public static final ShieldStatus POOR_NETWORK = new ShieldStatus("poorNetwork", false); + + /// The attestation service answered with a server error. Transient. + public static final ShieldStatus SERVICE_DOWN = new ShieldStatus("serviceUnavailable", false); + + /// This device is asking too often and is being throttled. Transient, but back off before + /// retrying rather than looping. + public static final ShieldStatus RATE_LIMITED = new ShieldStatus("rateLimited", false); + + /// The service evaluated this device and declined to issue a token. **Not** transient: the + /// device itself is what failed the policy. Retrying will not help. + public static final ShieldStatus REJECTED = new ShieldStatus("rejected", false); + + /// The certificate chain presented by a protected host matched no configured pin. The request + /// was refused before any request body was sent. + public static final ShieldStatus PIN_MISMATCH = new ShieldStatus("pinMismatch", false); + + private static final ShieldStatus[] KNOWN = { + OK, UNPROTECTED, NOT_INITIALIZED, NO_NETWORK, POOR_NETWORK, + SERVICE_DOWN, RATE_LIMITED, REJECTED, PIN_MISMATCH + }; + + private final String id; + private final boolean success; + + private ShieldStatus(String id, boolean success) { + this.id = id; + this.success = success; + } + + /// The stable wire identifier, e.g. `rateLimited`. + public String getId() { + return id; + } + + /// True only for [#OK]. Every other status means no usable token was produced. + public boolean isSuccess() { + return success; + } + + /// True when the failure is about reaching the service rather than about this device. Retrying + /// later may succeed. False for [#REJECTED] and [#PIN_MISMATCH], which describe the device and + /// the connection respectively. + public boolean isTransient() { + return this == NO_NETWORK || this == POOR_NETWORK + || this == SERVICE_DOWN || this == RATE_LIMITED; + } + + /// Resolves a wire identifier to a constant, or synthesises a non-success status for an + /// identifier this build does not know about. Never returns null. + public static ShieldStatus forId(String id) { + if (id == null) { + return NOT_INITIALIZED; + } + for (ShieldStatus known : KNOWN) { + if (known.id.equals(id)) { + return known; + } + } + return new ShieldStatus(id, false); + } + + @Override + public String toString() { + return id; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ShieldStatus)) { + return false; + } + return id.equals(((ShieldStatus) o).id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } +} diff --git a/CodenameOne/src/com/codename1/security/shield/ShieldToken.java b/CodenameOne/src/com/codename1/security/shield/ShieldToken.java new file mode 100644 index 00000000000..d8fc4f250d9 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/ShieldToken.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield; + +/// A short-lived attestation token, ready to be attached to a request. +/// +/// The value is opaque to the app. It is meaningful only to the backend that verifies it against +/// the published Codename One signing keys -- do not parse it, and do not make a security decision +/// on the device based on its contents, because a device the attacker controls can be made to say +/// anything. +/// +/// #### Expiry is measured locally, on purpose +/// +/// Validity is tracked as "fetched at + time to live" using local elapsed time, never by reading an +/// expiry field out of the token and comparing it against the device clock. On a rooted device the +/// clock is attacker-controlled, so a token-embedded expiry can be made to look valid forever. The +/// verifying backend does its own absolute-time check regardless; [#isValid()] exists so the client +/// knows when to refresh, not to enforce anything. +public final class ShieldToken { + + private final String value; + private final ShieldStatus status; + private final long fetchedAt; + private final long ttlMillis; + private final String binding; + + /// Monotonic reference captured at construction. `System.currentTimeMillis()` + /// is the wall clock: it can be stepped backwards or forwards, by the user or + /// by NTP, which would keep a lapsed token looking valid or expire a good one + /// early. Elapsed time is measured from here instead; `fetchedAt` is retained + /// only because it is meaningful to a human reading a log. + private final long fetchedNanos; + + public ShieldToken(String value, ShieldStatus status, long fetchedAt, + long ttlMillis, String binding) { + this(value, status, fetchedAt, ttlMillis, binding, System.nanoTime()); + } + + /// Test seam: lets a test place the token at a chosen point in its lifetime. + /// Not public, because an app supplying its own reference could only make a + /// lapsed token look fresh. + ShieldToken(String value, ShieldStatus status, long fetchedAt, + long ttlMillis, String binding, long fetchedNanos) { + this.value = value; + // A missing status is an engine bug, not a success. Defaulting to OK + // would make isValid() true and attach a token nobody vouched for. + this.status = status == null ? ShieldStatus.NOT_INITIALIZED : status; + this.fetchedAt = fetchedAt; + this.ttlMillis = ttlMillis; + this.binding = binding; + this.fetchedNanos = fetchedNanos; + } + + /// The opaque token to place in the request header. May be null when [#getStatus()] is not + /// [ShieldStatus#OK]. + public String getValue() { + return value; + } + + /// Outcome of the fetch that produced this token. + public ShieldStatus getStatus() { + return status; + } + + /// Milliseconds until this token stops being worth sending, or 0 once it has lapsed. + public long getMillisUntilExpiry() { + long elapsed = (System.nanoTime() - fetchedNanos) / 1000000L; + long remaining = ttlMillis - elapsed; + return remaining > 0 ? remaining : 0; + } + + /// True when the token has a value, was fetched successfully, and has not lapsed. + public boolean isValid() { + return value != null && status.isSuccess() && getMillisUntilExpiry() > 0; + } + + /// True once the token is far enough through its lifetime to be worth refreshing in the + /// background. Refreshing before expiry is what keeps a request from ever having to block. + public boolean shouldRefresh(int thresholdPercent) { + if (ttlMillis <= 0) { + return true; + } + long used = (System.nanoTime() - fetchedNanos) / 1000000L; + return used * 100 >= ttlMillis * thresholdPercent; + } + + /// The request-binding data this token was minted for, or null when it is a plain + /// time-limited token not tied to a specific request. + public String getBinding() { + return binding; + } + + /// True when this token was minted for exactly the supplied binding data. A token bound to one + /// request must not be reused for another; that is the whole point of binding. + public boolean isBoundTo(String data) { + if (binding == null) { + return data == null; + } + return binding.equals(data); + } + + /// When this token was fetched, in wall-clock time. + /// + /// For correlating a client log with a server log, and nothing else -- + /// lifetime decisions use the monotonic reference instead, for the reasons + /// in the class documentation. + public long getFetchedAt() { + return fetchedAt; + } + + /// Never renders the token value -- these strings end up in logs. + @Override + public String toString() { + return "ShieldToken[status=" + status.getId() + + ", fetchedAt=" + fetchedAt + + ", validMs=" + getMillisUntilExpiry() + + ", bound=" + (binding != null) + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/security/shield/package-info.java b/CodenameOne/src/com/codename1/security/shield/package-info.java new file mode 100644 index 00000000000..01dff0e4a22 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/package-info.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/// API shielding: attestation tokens, over-the-air certificate pinning, and runtime +/// self-protection reporting. +/// +/// Start at [com.codename1.security.shield.AppShield]. +/// +/// #### What this can and cannot do +/// +/// Worth being precise about, because the category attracts overclaiming. On a device the attacker +/// fully controls, no client-side check is unbypassable -- detection code can be patched out and +/// headers can be stripped. What this buys you is threefold: +/// +/// 1. Your backend gets a **cryptographically verifiable statement from Apple or Google** about the +/// app and device, evaluated by a service the attacker does not control. That is a categorically +/// different thing from a boolean your own app computed about itself. +/// 2. The cost of scripted abuse rises from "reproduce the API calls with a shell script" to +/// "reverse-engineer and re-sign a native binary, per release". +/// 3. Certificate pins rotate over the air, so a pin change no longer needs an app store release -- +/// which is what makes pinning practical to run at all. +/// +/// It does not make an app unhackable, and any product in this space that says otherwise is selling +/// something. +/// +/// #### The load-bearing part is on your server +/// +/// The token means nothing until your backend refuses to serve requests without a valid one. Until +/// that check exists, adding the shield changes nothing about your security. +package com.codename1.security.shield; diff --git a/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java b/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java new file mode 100644 index 00000000000..0a64d704a60 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/spi/DefaultEngineContext.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield.spi; + +import com.codename1.io.Log; +import com.codename1.security.DeviceIntegrity; +import com.codename1.security.SecureStorage; +import com.codename1.security.shield.ShieldSignal; +import com.codename1.security.shield.ShieldSignals; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +/// The framework-backed [EngineContext] handed to an engine at initialization. +/// +/// Every method is defensive: an engine runs early in startup, often on a device in an unusual +/// state, and a platform probe that throws must degrade to an empty answer rather than take the +/// app down before it has drawn a frame. +final class DefaultEngineContext implements EngineContext { + + static final DefaultEngineContext INSTANCE = new DefaultEngineContext(); + + private DefaultEngineContext() { + } + + @Override + public SecureStorage getSecureStorage() { + return SecureStorage.getInstance(); + } + + @Override + public AsyncResource requestPlatformAttestation(String nonce) { + return DeviceIntegrity.requestIntegrityToken(nonce); + } + + @Override + public boolean isPlatformAttestationSupported() { + try { + return DeviceIntegrity.isAttestationSupported(); + } catch (Throwable t) { + return false; + } + } + + @Override + public void resetPlatformAttestation() { + try { + DeviceIntegrity.resetAttestation(); + } catch (Throwable t) { + Log.e(t); + } + } + + @Override + public void confirmPlatformAttestation(String keyId) { + try { + DeviceIntegrity.confirmAttestation(keyId); + } catch (Throwable t) { + Log.e(t); + } + } + + @Override + public String[] getPlatformCompromiseReasons() { + try { + String[] r = DeviceIntegrity.getCompromiseReasons(); + return r == null ? new String[0] : r; + } catch (Throwable t) { + return new String[0]; + } + } + + @Override + public String[] getEnabledAccessibilityServices() { + try { + String[] r = DeviceIntegrity.getEnabledAccessibilityServices(); + return r == null ? new String[0] : r; + } catch (Throwable t) { + return new String[0]; + } + } + + @Override + public String[] getAppSignerDigests() { + try { + String[] r = Display.getInstance().getAppSignerDigests(); + return r == null ? new String[0] : r; + } catch (Throwable t) { + return new String[0]; + } + } + + @Override + public String getProperty(String key, String defaultValue) { + try { + return Display.getInstance().getProperty(key, defaultValue); + } catch (Throwable t) { + return defaultValue; + } + } + + @Override + public void log(String message) { + Log.p(message); + } + + @Override + public void publishSignal(ShieldSignal signal) { + ShieldSignals.add(signal); + } +} diff --git a/CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java b/CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java new file mode 100644 index 00000000000..2ec982522b5 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/spi/EngineContext.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield.spi; + +import com.codename1.security.SecureStorage; +import com.codename1.security.shield.ShieldSignal; +import com.codename1.util.AsyncResource; + +/// The narrow set of framework services lent to a [ShieldEngine]. +/// +/// Handed to the engine rather than reached for, so the engine never needs access to the platform +/// implementation object. That keeps the framework's implementation accessor package-private, and +/// it keeps the list of things an engine can do small enough to review. +public interface EngineContext { + + /// Non-prompting secure storage, for the attestation key identifier and cached tokens. On + /// device this is the platform keychain or keystore. + SecureStorage getSecureStorage(); + + /// Requests a raw platform attestation (Play Integrity or App Attest) bound to the nonce. + /// + /// The result is opaque and must be forwarded to the verifying service; the engine must not + /// try to interpret it on the device, because a device the attacker controls can be made to + /// produce any interpretation. + AsyncResource requestPlatformAttestation(String nonce); + + /// True when the platform provides attestation and this build bundled it. + boolean isPlatformAttestationSupported(); + + /// Clears cached platform attestation state, forcing a fresh hardware key on the next request. + /// Used when the service reports that the device's attestation key is unknown to it. + void resetPlatformAttestation(); + + /// Acknowledges that the verifying service recorded the attested key, releasing the client to use + /// cheap assertions from here on. Call it once the service has accepted an attestation token; until + /// then the platform refuses to assert against a key the service cannot yet resolve. + void confirmPlatformAttestation(String keyId); + + /// Platform-detected compromise reasons, such as `root` or `frida`. + String[] getPlatformCompromiseReasons(); + + /// Component identifiers of the accessibility services currently enabled. + String[] getEnabledAccessibilityServices(); + + /// Digests of the certificates the running app is actually signed with, for comparison against + /// what it was built with. Empty where the platform cannot report it. + /// + /// Not exposed as public framework API: nothing in an app needs this, and publishing it would + /// only tell an attacker exactly which value to fake. + String[] getAppSignerDigests(); + + /// A build-stamped property, such as the build key or the per-build hardening manifest. + String getProperty(String key, String defaultValue); + + /// Writes to the framework log. + void log(String message); + + /// Publishes an observation to [com.codename1.security.shield.ShieldSignals], where the app + /// can see it and from where it is offered to the service on the next token fetch. + void publishSignal(ShieldSignal signal); +} diff --git a/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngine.java b/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngine.java new file mode 100644 index 00000000000..06d83e186b7 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngine.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield.spi; + +import com.codename1.security.shield.PinSet; +import com.codename1.security.shield.ShieldConfig; +import com.codename1.security.shield.ShieldException; +import com.codename1.security.shield.ShieldSignal; +import com.codename1.security.shield.ShieldToken; + +/// The service-provider seam between the public shield API and the attestation engine that +/// implements it. +/// +/// Codename One ships an inert default that reports itself unavailable, so an app written against +/// [com.codename1.security.shield.AppShield] compiles and runs everywhere. A build entitled to the +/// enterprise engine has a real implementation registered by the build server before +/// `Display.init`, via [ShieldEngineRegistry#setEngine(ShieldEngine)]. +/// +/// #### What must never move into the framework +/// +/// The split is only worth anything if the engine keeps the parts an attacker would want to reach. +/// An implementation must own, and must never delegate to open framework code: +/// +/// - challenge and nonce generation -- predictable nonces make replay possible; +/// - any key material, and any code that touches it; +/// - the pin **comparison** and the decision to fail a request. The framework may hold a +/// [PinSet]; patching the framework's copy must not be enough to disable pinning; +/// - the detection heuristics themselves. Published heuristics are bypassed heuristics, so the +/// framework only ever sees finished [ShieldSignal] results -- and only the ones the engine +/// chooses to publish; +/// - interpretation of the raw platform attestation. Those responses go to the verifying service, +/// which the attacker does not control, rather than being judged on the device. +/// +/// The security property this preserves is not "the app refuses to make the call" -- an attacker +/// who controls the device can always strip a header. It is that the customer's backend refuses to +/// *serve* a request without a valid, unexpired, service-signed token, which a substituted engine +/// cannot mint. +public interface ShieldEngine { + + /// A stable name for diagnostics, for example `unprotected`, `simulator` or the enterprise + /// engine's own identifier. + String getName(); + + /// True when this engine can actually attest. False for the inert default, which is how + /// [com.codename1.security.shield.AppShield#isProtected()] is answered. + boolean isAvailable(); + + /// Called once from [com.codename1.security.shield.AppShield#init(ShieldConfig)]. Must not + /// block on the network; do warm-up work on a background thread. + void initialize(EngineContext ctx, ShieldConfig config); + + /// Obtains a token, blocking until it has one or fails. Called on a network thread, never the + /// EDT. + /// + /// @param bindingData request data to bind the token to, or null for a plain time-limited + /// token. A bound token is only valid for the request whose data was supplied. + /// @throws ShieldException carrying the [com.codename1.security.shield.ShieldStatus] that + /// explains whether the failure was about reaching the service or about this device + ShieldToken fetchToken(String bindingData) throws ShieldException; + + /// The cached token, without contacting the service. Returns null when nothing is cached. + /// + /// Must never block: callers are typically on the EDT, deciding whether they can decorate a + /// request right now. + ShieldToken getCachedToken(); + + /// Decides whether a certificate chain is acceptable for a host. + /// + /// Must be purely local and non-blocking: on iOS this is invoked synchronously from the TLS + /// delegate thread while the handshake is held open, so any network call or blocking wait here + /// deadlocks the connection. + /// + /// Returns true when the host is not pinned -- "no opinion" must never read as a mismatch. + /// + /// @param spkiDigests base64 SHA-256 digests of each chain certificate's public key info + /// @param certDigests whole-certificate digests, for engines that pin those instead + boolean verifyPins(String host, String[] spkiDigests, String[] certDigests); + + /// The pin set currently in force, never null. May be [PinSet#EMPTY]. + PinSet getPinSet(); + + /// The runtime self-protection observations this engine wants reported. May legitimately be a + /// subset of what it detected. + ShieldSignal[] collectSignals(); + + /// Discards any cached token, forcing the next fetch to go to the service. Called when a + /// backend rejects a token, which usually means the device's attestation state is stale. + void invalidate(); + + /// Releases resources. Called when the app is shutting down. + void shutdown(); +} diff --git a/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngineRegistry.java b/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngineRegistry.java new file mode 100644 index 00000000000..05d09810d13 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/spi/ShieldEngineRegistry.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield.spi; + +/// Where the attestation engine registers itself. +/// +/// Registration is by direct instance, not by class name. Codename One obfuscates and renames +/// classes -- ProGuard/R8 on Android, the bytecode-to-C translation on iOS -- so a +/// `Class.forName` lookup is unreliable by construction. The port or the build-server-generated +/// bootstrap instantiates the engine itself and passes the instance here, which survives renaming +/// because it is an ordinary symbol reference. The same convention is used elsewhere in the +/// framework for port-supplied implementations. +/// +/// Registration happens in one of three places: +/// +/// - **Device builds**: the build server splices a bootstrap into the generated application stub, +/// ahead of `Display.init`, when the project is entitled to the enterprise engine. +/// - **Simulator**: the desktop port's post-init bootstrap scan picks it up, which is the one +/// place a name-based lookup is safe because the desktop port is not obfuscated. +/// - **Tests**: call [#setEngine(ShieldEngine)] directly. +/// +/// The first registration wins and the registry then seals. Without that, any code running later +/// in the process -- including code an attacker injected -- could swap in an engine that returns +/// whatever it likes. Sealing does not make the app tamper-proof (an attacker who can patch the +/// binary can patch this too); it removes the version of the attack that needs no patching at all. +public final class ShieldEngineRegistry { + + private static ShieldEngine engine; + private static boolean sealed; + + private ShieldEngineRegistry() { + } + + /// Registers the engine. The first call wins. + /// + /// @throws IllegalStateException if an engine is already registered + public static void setEngine(ShieldEngine e) { + if (e == null) { + throw new IllegalArgumentException("engine is null"); + } + synchronized (ShieldEngineRegistry.class) { + if (sealed) { + throw new IllegalStateException( + "A shield engine is already registered: " + engine.getName()); + } + engine = e; + sealed = true; + } + } + + /// The registered engine, or the inert default when none was registered. Never null, so no + /// caller needs a null check and no code path can silently skip a check that should have run. + public static ShieldEngine getEngine() { + synchronized (ShieldEngineRegistry.class) { + return engine != null ? engine : UnprotectedEngine.INSTANCE; + } + } + + /// The framework-backed [EngineContext] an engine is initialized with. Exposed so a port or a + /// test can construct an engine against the real services without reimplementing them. + public static EngineContext getDefaultContext() { + return DefaultEngineContext.INSTANCE; + } + + /// True when a real engine was registered, whatever it currently reports about its + /// availability. + /// + /// Distinct from [com.codename1.security.shield.AppShield#isProtected()], which asks + /// whether attestation can run *right now*. A fail-closed host must refuse when a + /// registered engine cannot attest -- that is the case it exists for -- and relax only + /// for a build that has no engine at all. + public static boolean isEngineRegistered() { + synchronized (ShieldEngineRegistry.class) { + return engine != null; + } + } + + /// Test hook: drops the registration and unseals. Not for application use -- there is no + /// legitimate reason for a shipping app to replace its engine at runtime. + static void resetForTesting() { + synchronized (ShieldEngineRegistry.class) { + engine = null; + sealed = false; + } + } +} diff --git a/CodenameOne/src/com/codename1/security/shield/spi/UnprotectedEngine.java b/CodenameOne/src/com/codename1/security/shield/spi/UnprotectedEngine.java new file mode 100644 index 00000000000..bf0f546c140 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/spi/UnprotectedEngine.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.security.shield.spi; + +import com.codename1.security.DeviceIntegrity; +import com.codename1.security.shield.PinSet; +import com.codename1.security.shield.ShieldConfig; +import com.codename1.security.shield.ShieldException; +import com.codename1.security.shield.ShieldSignal; +import com.codename1.security.shield.ShieldStatus; +import com.codename1.security.shield.ShieldToken; +import java.util.Vector; + +/// The engine used when no attestation engine was registered -- an open-source build, a build not +/// entitled to the enterprise engine, or a unit test. +/// +/// The contract it implements is the degradation promise the public API makes: an app written +/// against the shield must run everywhere, and must never fail closed just because attestation is +/// unavailable. +/// +/// - `fetchToken` **completes**, with a [ShieldStatus#UNPROTECTED] failure. It never hangs and +/// never throws synchronously, so callers written for the real engine follow their normal error +/// path instead of deadlocking. +/// - `verifyPins` returns true. There is no pin set to enforce, and reporting "no opinion" as a +/// mismatch would break every request. +/// - Nothing here can block a request. +/// +/// It is not entirely inert: [#collectSignals()] still reports what the free platform checks +/// found, so an app can react to a rooted device without an enterprise entitlement. +final class UnprotectedEngine implements ShieldEngine { + + static final UnprotectedEngine INSTANCE = new UnprotectedEngine(); + + private UnprotectedEngine() { + } + + @Override + public String getName() { + return "unprotected"; + } + + @Override + public boolean isAvailable() { + return false; + } + + @Override + public void initialize(EngineContext ctx, ShieldConfig config) { + if (ctx != null) { + ctx.log("AppShield: no attestation engine registered; running unprotected. " + + "Tokens are not issued and certificate pins are not enforced."); + } + } + + @Override + public ShieldToken fetchToken(String bindingData) throws ShieldException { + throw new ShieldException(ShieldStatus.UNPROTECTED, + "This build has no attestation engine, so no token can be issued."); + } + + @Override + public ShieldToken getCachedToken() { + return null; + } + + @Override + public boolean verifyPins(String host, String[] spkiDigests, String[] certDigests) { + return true; + } + + @Override + public PinSet getPinSet() { + return PinSet.EMPTY; + } + + @Override + public ShieldSignal[] collectSignals() { + String[] reasons; + try { + reasons = DeviceIntegrity.getCompromiseReasons(); + } catch (Throwable t) { + // Never let a platform probe break the caller; an absent signal is + // strictly better than a crashed app. + return new ShieldSignal[0]; + } + if (reasons == null || reasons.length == 0) { + return new ShieldSignal[0]; + } + Vector out = new Vector(); + for (String reason : reasons) { + ShieldSignal s = toSignal(reason); + if (s != null) { + out.addElement(s); + } + } + ShieldSignal[] arr = new ShieldSignal[out.size()]; + out.copyInto(arr); + return arr; + } + + private static ShieldSignal toSignal(String reason) { + if (reason == null) { + return null; + } + if ("root".equals(reason)) { + return new ShieldSignal(ShieldSignal.ROOT, 70, null); + } + if ("jailbreak".equals(reason)) { + return new ShieldSignal(ShieldSignal.JAILBREAK, 70, null); + } + if ("frida".equals(reason)) { + return new ShieldSignal(ShieldSignal.HOOK, 90, "frida"); + } + if ("emulator".equals(reason)) { + // Low severity on purpose: every developer's device is an emulator. + // The service weighs it against the other signals. + return new ShieldSignal(ShieldSignal.EMULATOR, 30, null); + } + return new ShieldSignal(reason, 50, null); + } + + @Override + public void invalidate() { + } + + @Override + public void shutdown() { + } +} diff --git a/CodenameOne/src/com/codename1/security/shield/spi/package-info.java b/CodenameOne/src/com/codename1/security/shield/spi/package-info.java new file mode 100644 index 00000000000..062b0127f6b --- /dev/null +++ b/CodenameOne/src/com/codename1/security/shield/spi/package-info.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/// Service-provider interface between the public shield API and the attestation engine. +/// +/// Application code does not use this package. It exists so the engine that performs attestation, +/// pin enforcement and tamper detection can be supplied separately from the framework, while +/// [com.codename1.security.shield.AppShield] keeps a single stable surface that compiles and runs +/// whether or not an engine is present. +/// +/// See [com.codename1.security.shield.spi.ShieldEngine] for the contract, and in particular for +/// the list of responsibilities an engine must not delegate back into open framework code. +package com.codename1.security.shield.spi; diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index a2e77b60de6..5fb2956ca11 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -6888,6 +6888,25 @@ public String[] getEnabledAccessibilityServices() { return impl.getEnabledAccessibilityServices(); } + /// Discards cached platform attestation state, forcing the next attestation to start from a fresh + /// hardware key. See `com.codename1.security.DeviceIntegrity#resetAttestation()`. + public void resetAttestation() { + impl.resetAttestation(); + } + + /// Acknowledges that a backend recorded the attested key. See + /// `com.codename1.security.DeviceIntegrity#confirmAttestation()`. + public void confirmAttestation(String keyId) { + impl.confirmAttestation(keyId); + } + + /// Returns digests of the certificates the running app is signed with. Low level hook for the + /// attestation layer, which reports them to a verifying service; an on-device comparison proves + /// nothing on its own. Empty where the platform has no such concept. + public String[] getAppSignerDigests() { + return impl.getAppSignerDigests(); + } + /// Marks the current screen secure (Android `FLAG_SECURE`), blocking screenshots/recording/scraping. public void setSecureScreen(boolean secure) { impl.setSecureScreen(secure); diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 63702181cb5..f29f75f5576 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -221,7 +221,7 @@ import java.security.MessageDigest; import java.text.ParseException; import java.util.*; -import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicLong; import javax.net.ssl.HttpsURLConnection; import javax.xml.parsers.ParserConfigurationException; @@ -231,10 +231,10 @@ import org.xml.sax.SAXException; //import android.webkit.JavascriptInterface; -public class AndroidImplementation extends CodenameOneImplementation implements IntentResultListener { - private AndroidCalendarSource calendarSource; - private static final AtomicLong V3_NOTIFICATION_SEQUENCE = new AtomicLong(); - +public class AndroidImplementation extends CodenameOneImplementation implements IntentResultListener { + private AndroidCalendarSource calendarSource; + private static final AtomicLong V3_NOTIFICATION_SEQUENCE = new AtomicLong(); + public static final Thread.UncaughtExceptionHandler exceptionHandler = new Thread.UncaughtExceptionHandler() { @Override public void uncaughtException(Thread t, Throwable e) { @@ -821,94 +821,94 @@ private static byte[] readInputStream(InputStream i) throws IOException { } - public static void appendNotification(String type, String body, Context a) { + public static void appendNotification(String type, String body, Context a) { appendNotification(type, body, null, null, a); - } - - /** Receives the managed typed envelope from FCM without applying legacy push decoding. */ - public static void handleV3Push(final String envelope, Context context, - boolean appRunning, Class appStubClass) { - if (appRunning && Display.isInitialized() - && com.codename1.push.PushClient.hasActiveClient()) { - Display.getInstance().callSerially(new Runnable() { - public void run() { - com.codename1.push.PushClient.dispatch(envelope); - } - }); - return; - } - try { - org.json.JSONObject message = new org.json.JSONObject(envelope); - // The pending-push file explicitly encodes whether a legacy type is present. - // A missing type is the sentinel for a typed V3 envelope and is replayed intact. - appendNotification(null, envelope, context); - if (message.optBoolean("silent", false)) { - return; - } - String title = message.optString("title", ""); - String body = message.optString("body", ""); - String image = message.optString("image", ""); - if (title.length() == 0 && body.length() == 0 && image.length() == 0) { - return; - } - if (title.length() == 0) { - title = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString(); - } - Intent intent = new Intent(context, appStubClass); - PendingIntent contentIntent = createPendingIntent(context, 0, intent); - int smallIcon = context.getResources().getIdentifier("ic_stat_notify", "drawable", - context.getPackageName()); - if (smallIcon == 0) { - smallIcon = context.getApplicationInfo().icon; - } - NotificationCompat.Builder builder = new NotificationCompat.Builder(context) - .setContentTitle(title) - .setContentText(body) - .setSmallIcon(smallIcon) - .setContentIntent(contentIntent) - .setAutoCancel(true) - .setWhen(System.currentTimeMillis()); - NotificationManager manager = (NotificationManager) - context.getSystemService(Context.NOTIFICATION_SERVICE); - setNotificationChannel(manager, builder, context); - String collapseKey = message.optString("collapseKey", null); - String messageId = message.optString("id", null); - String notificationTag; - if (collapseKey != null && collapseKey.length() > 0) { - notificationTag = v3NotificationTag("CN1_PUSH_V3_COLLAPSE:", collapseKey); - } else if (messageId != null && messageId.length() > 0) { - notificationTag = v3NotificationTag("CN1_PUSH_V3_MESSAGE:", messageId); - } else { - notificationTag = "CN1_PUSH_V3_EPHEMERAL:" + System.currentTimeMillis() - + ":" + V3_NOTIFICATION_SEQUENCE.incrementAndGet(); - } - manager.notify(notificationTag, 0, builder.build()); - } catch (Exception error) { - Log.e("Codename One", "Failed to handle a Push V3 envelope", error); - } - } - - private static String v3NotificationTag(String prefix, String value) { - if (prefix.length() + value.length() <= 128) { - return prefix + value; - } - try { - byte[] digest = MessageDigest.getInstance("SHA-256") - .digest(value.getBytes(StandardCharsets.UTF_8)); - StringBuilder out = new StringBuilder(prefix.length() + digest.length * 2); - out.append(prefix); - for (byte item : digest) { - int unsigned = item & 0xff; - if (unsigned < 0x10) { - out.append('0'); - } - out.append(Integer.toHexString(unsigned)); - } - return out.toString(); - } catch (Exception error) { - return prefix + Integer.toHexString(value.hashCode()); - } - } + } + + /** Receives the managed typed envelope from FCM without applying legacy push decoding. */ + public static void handleV3Push(final String envelope, Context context, + boolean appRunning, Class appStubClass) { + if (appRunning && Display.isInitialized() + && com.codename1.push.PushClient.hasActiveClient()) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + com.codename1.push.PushClient.dispatch(envelope); + } + }); + return; + } + try { + org.json.JSONObject message = new org.json.JSONObject(envelope); + // The pending-push file explicitly encodes whether a legacy type is present. + // A missing type is the sentinel for a typed V3 envelope and is replayed intact. + appendNotification(null, envelope, context); + if (message.optBoolean("silent", false)) { + return; + } + String title = message.optString("title", ""); + String body = message.optString("body", ""); + String image = message.optString("image", ""); + if (title.length() == 0 && body.length() == 0 && image.length() == 0) { + return; + } + if (title.length() == 0) { + title = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString(); + } + Intent intent = new Intent(context, appStubClass); + PendingIntent contentIntent = createPendingIntent(context, 0, intent); + int smallIcon = context.getResources().getIdentifier("ic_stat_notify", "drawable", + context.getPackageName()); + if (smallIcon == 0) { + smallIcon = context.getApplicationInfo().icon; + } + NotificationCompat.Builder builder = new NotificationCompat.Builder(context) + .setContentTitle(title) + .setContentText(body) + .setSmallIcon(smallIcon) + .setContentIntent(contentIntent) + .setAutoCancel(true) + .setWhen(System.currentTimeMillis()); + NotificationManager manager = (NotificationManager) + context.getSystemService(Context.NOTIFICATION_SERVICE); + setNotificationChannel(manager, builder, context); + String collapseKey = message.optString("collapseKey", null); + String messageId = message.optString("id", null); + String notificationTag; + if (collapseKey != null && collapseKey.length() > 0) { + notificationTag = v3NotificationTag("CN1_PUSH_V3_COLLAPSE:", collapseKey); + } else if (messageId != null && messageId.length() > 0) { + notificationTag = v3NotificationTag("CN1_PUSH_V3_MESSAGE:", messageId); + } else { + notificationTag = "CN1_PUSH_V3_EPHEMERAL:" + System.currentTimeMillis() + + ":" + V3_NOTIFICATION_SEQUENCE.incrementAndGet(); + } + manager.notify(notificationTag, 0, builder.build()); + } catch (Exception error) { + Log.e("Codename One", "Failed to handle a Push V3 envelope", error); + } + } + + private static String v3NotificationTag(String prefix, String value) { + if (prefix.length() + value.length() <= 128) { + return prefix + value; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder out = new StringBuilder(prefix.length() + digest.length * 2); + out.append(prefix); + for (byte item : digest) { + int unsigned = item & 0xff; + if (unsigned < 0x10) { + out.append('0'); + } + out.append(Integer.toHexString(unsigned)); + } + return out.toString(); + } catch (Exception error) { + return prefix + Integer.toHexString(value.hashCode()); + } + } public static void appendNotification(String type, String body, String image, String category, Context a) { try { @@ -7175,6 +7175,46 @@ public boolean canGetSSLCertificates() { return true; } + @Override + public boolean canGetPublicKeyDigests() { + return true; + } + + @Override + public String[] getSSLCertificatesEx(Object connection, String url) throws IOException { + if (connection instanceof HttpsURLConnection) { + HttpsURLConnection conn = (HttpsURLConnection) connection; + try { + conn.connect(); + java.security.cert.Certificate[] certs = conn.getServerCertificates(); + java.util.List out = new java.util.ArrayList(); + for (int i = 0; i < certs.length; i++) { + java.security.cert.Certificate cert = certs[i]; + out.add("CHAIN:" + i); + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + sha256.update(cert.getEncoded()); + out.add("SHA-256:" + dumpHex(sha256.digest())); + MessageDigest sha1 = MessageDigest.getInstance("SHA1"); + sha1.update(cert.getEncoded()); + out.add("SHA1:" + dumpHex(sha1.digest())); + // getPublicKey().getEncoded() is already the DER SubjectPublicKeyInfo, + // which is exactly what a public-key pin is computed over. + java.security.PublicKey pk = cert.getPublicKey(); + if (pk != null && pk.getEncoded() != null) { + MessageDigest spki = MessageDigest.getInstance("SHA-256"); + spki.update(pk.getEncoded()); + out.add("SPKI-SHA-256:" + + com.codename1.util.Base64.encodeNoNewline(spki.digest())); + } + } + return out.toArray(new String[out.size()]); + } catch (Exception ex) { + ex.printStackTrace(); + } + } + return new String[0]; + } + /** * @inheritDoc */ @@ -8433,20 +8473,20 @@ public boolean isContactsPermissionGranted() { @Override - public String[] getAllContacts(boolean withNumbers) { + public String[] getAllContacts(boolean withNumbers) { if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ return new String[]{}; } return AndroidContactsManager.getInstance().getContacts(getContext(), withNumbers); - } - - @Override - public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() { - if (calendarSource == null) { - calendarSource = new AndroidCalendarSource(getContext()); - } - return calendarSource; - } + } + + @Override + public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() { + if (calendarSource == null) { + calendarSource = new AndroidCalendarSource(getContext()); + } + return calendarSource; + } @Override public Contact getContactById(String id) { @@ -9048,10 +9088,10 @@ private ClipData enrichClipWithBinaryContent(ClipboardContent content, ClipData imageExt = "gif"; } if (imageBytes != null) { - // AndroidGradleBuilder exposes cache/intent_files through the app's - // FileProvider. Keep generated clipboard payloads inside that root so - // FileProvider can safely create a content:// URI for paste targets. - File imageFile = new File(new File(getContext().getCacheDir(), "intent_files"), + // AndroidGradleBuilder exposes cache/intent_files through the app's + // FileProvider. Keep generated clipboard payloads inside that root so + // FileProvider can safely create a content:// URI for paste targets. + File imageFile = new File(new File(getContext().getCacheDir(), "intent_files"), "cn1-clip-image-" + System.currentTimeMillis() + "." + imageExt); imageFile.getParentFile().mkdirs(); OutputStream os = new FileOutputStream(imageFile); @@ -9085,14 +9125,14 @@ private ClipData enrichClipWithBinaryContent(ClipboardContent content, ClipData continue; } Uri u; - if (pathOrUri.startsWith("content:")) { - u = Uri.parse(pathOrUri); - } else { - File file = pathOrUri.startsWith("file:") - ? new File(Uri.parse(pathOrUri).getPath()) - : new File(pathOrUri); - u = FileProvider.getUriForFile(getContext(), authority, file); - getContext().grantUriPermission("android", u, Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (pathOrUri.startsWith("content:")) { + u = Uri.parse(pathOrUri); + } else { + File file = pathOrUri.startsWith("file:") + ? new File(Uri.parse(pathOrUri).getPath()) + : new File(pathOrUri); + u = FileProvider.getUriForFile(getContext(), authority, file); + getContext().grantUriPermission("android", u, Intent.FLAG_GRANT_READ_URI_PERMISSION); } if (clip == null) { clip = new ClipData("Codename One", new String[]{ "text/uri-list" }, new ClipData.Item(u)); @@ -10615,7 +10655,7 @@ public static boolean hasAndroidMarket(Context activity) { } @Override - public void registerPush(Hashtable metaData, boolean noFallback) { + public void registerPush(Hashtable metaData, boolean noFallback) { if (getActivity() == null) { return; } @@ -10626,18 +10666,18 @@ public void registerPush(Hashtable metaData, boolean noFallback) { } } - boolean huawei = "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", "")); - if (!hasAndroidMarket() && !huawei) { - Log.d("Codename One", "Device doesn't have Android market/google play can't register for push!"); - return; - } - String id = ""; - if (!huawei) { - id = (String)metaData.get(com.codename1.push.Push.GOOGLE_PUSH_KEY); - if (id == null) { - id = Display.getInstance().getProperty("gcm.sender_id", null); - } - } + boolean huawei = "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", "")); + if (!hasAndroidMarket() && !huawei) { + Log.d("Codename One", "Device doesn't have Android market/google play can't register for push!"); + return; + } + String id = ""; + if (!huawei) { + id = (String)metaData.get(com.codename1.push.Push.GOOGLE_PUSH_KEY); + if (id == null) { + id = Display.getInstance().getProperty("gcm.sender_id", null); + } + } Log.d("Codename One", "Sending async push request for id: " + id); ((CodenameOneActivity) getActivity()).registerForPush(id); } @@ -10651,9 +10691,9 @@ public static void registerPolling() { } @Override - public void deregisterPush() { - boolean has = hasAndroidMarket() - || "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", "")); + public void deregisterPush() { + boolean has = hasAndroidMarket() + || "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", "")); if (has) { ((CodenameOneActivity) getActivity()).stopReceivingPush(); deregisterPushFromServer(); @@ -13611,6 +13651,87 @@ public boolean isDeviceCompromised() { return getCompromiseReasons().length > 0; } + /** + * Base64 SHA-256 digests of the certificates this APK is actually signed with. + * + *

Uses the v2/v3 signing-block API on API 28 and up, which reports the full + * signing lineage after a key rotation; below that only the legacy v1 signature + * is available. Note that under Play App Signing the digest seen here is + * Google's app signing key, not the developer's upload key -- comparing + * against the upload key is the classic way to make every production install + * report itself as repackaged.

+ */ + @Override + public String[] getAppSignerDigests() { + try { + Context ctx = getContext(); + if (ctx == null) { + return new String[0]; + } + PackageManager pm = ctx.getPackageManager(); + String pkg = ctx.getPackageName(); + Signature[] signatures = null; + if (android.os.Build.VERSION.SDK_INT >= 28) { + // Reflection because the port compiles against an older android.jar + // than the devices it runs on, the same reason the Play Integrity + // call in this file is reflective. + signatures = signingCertificatesViaReflection(pm, pkg); + } + if (signatures == null) { + PackageInfo info = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES); + signatures = info.signatures; + } + if (signatures == null) { + return new String[0]; + } + java.util.ArrayList out = new java.util.ArrayList(); + for (int i = 0; i < signatures.length; i++) { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(signatures[i].toByteArray()); + out.add(com.codename1.util.Base64.encodeNoNewline(md.digest())); + } + return out.toArray(new String[out.size()]); + } catch (Throwable t) { + // Reporting nothing is better than failing a request over a + // package-manager quirk on some OEM build. + com.codename1.io.Log.e(t); + return new String[0]; + } + } + + /** + * PackageManager.GET_SIGNING_CERTIFICATES. Inlined because the port compiles + * against an android.jar that predates it. + */ + private static final int FLAG_GET_SIGNING_CERTIFICATES = 0x08000000; + + /** + * Reads the v2/v3 signing certificates on API 28+, or null when unavailable so + * the caller falls back to the legacy v1 signatures. + */ + private static Signature[] signingCertificatesViaReflection(PackageManager pm, String pkg) { + try { + PackageInfo info = pm.getPackageInfo(pkg, FLAG_GET_SIGNING_CERTIFICATES); + java.lang.reflect.Field signingInfoField = + PackageInfo.class.getField("signingInfo"); + Object signingInfo = signingInfoField.get(info); + if (signingInfo == null) { + return null; + } + Class signingInfoClass = signingInfo.getClass(); + boolean multipleSigners = ((Boolean) signingInfoClass + .getMethod("hasMultipleSigners").invoke(signingInfo)).booleanValue(); + // With one signer the history includes the pre-rotation certificates, + // which a server comparing against an older build still needs to accept. + String method = multipleSigners + ? "getApkContentsSigners" + : "getSigningCertificateHistory"; + return (Signature[]) signingInfoClass.getMethod(method).invoke(signingInfo); + } catch (Throwable t) { + return null; + } + } + @Override public String[] getCompromiseReasons() { java.util.ArrayList reasons = new java.util.ArrayList(); diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java index c1329f53e74..2cd9d8ee3f7 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java @@ -51,9 +51,11 @@ import java.security.cert.CertificateException; import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; import javax.crypto.KeyGenerator; import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.IvParameterSpec; /** @@ -76,6 +78,13 @@ * key and recreate it on first failure to recover. * See Google issue 65578763. * + * + *

The non-prompting tier ({@code set(account, value)} and friends) uses a + * separate keystore key ({@code CN1PlainKey}) and preferences file, + * created without {@code setUserAuthenticationRequired}, with AES/GCM. Keeping + * it separate matters: the biometric key is invalidated whenever the user + * re-enrols biometrics, and secrets read on every network call must survive + * that.

*/ public final class AndroidSecureStorage extends SecureStorage { @@ -83,9 +92,25 @@ public final class AndroidSecureStorage extends SecureStorage { private static final String PREFS = "CN1BiometricSecureStorage"; private static final String ANDROID_KEY_STORE = "AndroidKeyStore"; + /** + * Deliberately distinct from {@link #KEY_ID}: the biometric key is created + * with {@code setUserAuthenticationRequired(true)} and is invalidated when + * the user re-enrols biometrics. The non-prompting tier must survive that, + * so it gets its own key and its own preferences file. + */ + private static final String PLAIN_KEY_ID = "CN1PlainKey"; + private static final String PLAIN_PREFS = "CN1PlainSecureStorage"; + + /** + * Serializes load-check-generate on the non-prompting key, and the shared + * AndroidKeyStore handle with it. Static because the keystore alias is + * process-wide, so two instances would race just as two threads would. + */ + private static final Object PLAIN_KEY_LOCK = new Object(); + private static final int GCM_TAG_BITS = 128; + private KeyStore keyStore; private KeyGenerator keyGenerator; - private Cipher cipher; private boolean keyRevoked; private CancellationSignal cancellationSignal; @@ -123,11 +148,14 @@ public Boolean run(Cipher c) throws Exception { SharedPreferences sp = AndroidNativeUtil.getActivity() .getApplicationContext() .getSharedPreferences(PREFS, Context.MODE_PRIVATE); - sp.edit() + // commit(), so the Boolean this hands back is a statement about the disk. The + // pair of entries is also all-or-nothing that way: apply() could persist a + // ciphertext whose IV had not landed, which decrypts to nothing on the next + // launch and looks to the caller like a value it successfully stored. + return Boolean.valueOf(sp.edit() .putString("v_" + account, Base64.encodeToString(enc, Base64.DEFAULT)) .putString("iv_" + account, Base64.encodeToString(c.getIV(), Base64.DEFAULT)) - .apply(); - return Boolean.TRUE; + .commit()); } } @@ -172,11 +200,318 @@ public AsyncResource remove(String reason, String account) { SharedPreferences sp = AndroidNativeUtil.getActivity() .getApplicationContext() .getSharedPreferences(PREFS, Context.MODE_PRIVATE); - sp.edit().remove("v_" + account).remove("iv_" + account).apply(); - result.complete(Boolean.TRUE); + // And the prompting tier deletes durably too. This is the credential a logout + // clears; reporting it gone while the removal sits in memory means it comes back + // if the process is killed before the write lands, which on Android is how a + // process usually ends. + result.complete(Boolean.valueOf( + sp.edit().remove("v_" + account).remove("iv_" + account).commit())); return result; } + // --- Non-prompting tier ------------------------------------------------ + // + // AES/GCM under a dedicated AndroidKeyStore key created *without* + // setUserAuthenticationRequired, so reads never raise a biometric prompt. + // Deliberately not androidx.security EncryptedSharedPreferences: that + // would force a transitive dependency on every Android build and it is + // itself deprecated. The value is stored as + // base64(iv) + ":" + base64(ciphertext) in a private preferences file. + + @Override + public boolean set(String account, String value) { + if (account == null || value == null) { + return false; + } + if (Build.VERSION.SDK_INT < 23) { + return legacyPlainSet(account, value); + } + try { + // The whole use-and-persist runs under the same lock a reset takes. + // Releasing it after the lookup let a concurrent resetPlainKey() delete the + // alias and clear the preferences between here and the write, so this + // reported success while storing ciphertext under a key that no longer + // exists -- unreadable forever, and silently so. + synchronized (PLAIN_KEY_LOCK) { + // The invalid-key DECISION is taken in here too, not in a catch outside + // the lock. Deciding out there let a delayed caller reset a key that was + // no longer the one that failed it: another caller had already reset, a + // writer had created a fresh key and committed ciphertext under it, and + // this one then deleted that new key and wiped every stored value -- + // destroying data written after the failure it was reacting to. + try { + SecretKey key = plainKey(true); + if (key == null) { + return false; + } + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.ENCRYPT_MODE, key); + byte[] enc = c.doFinal(value.getBytes("UTF-8")); + SharedPreferences prefs = plainPrefs(); + if (prefs == null) { + return false; + } + // commit(), not apply(): apply() is asynchronous, so the write could + // land on disk after a reset that ran once this lock was released -- + // storing ciphertext under a key that had already been deleted. + // Holding the lock is only atomic if the persist finishes inside it. + return prefs.edit() + .putString(account, Base64.encodeToString(c.getIV(), Base64.NO_WRAP) + + ":" + Base64.encodeToString(enc, Base64.NO_WRAP)) + .commit(); + } catch (InvalidKeyException e) { + // Includes KeyPermanentlyInvalidatedException. + resetPlainKey(); + return false; + } catch (UnrecoverableKeyException e) { + // Handled like an invalid key rather than falling into the generic + // catch: leaving the unusable alias installed made every later write + // return false for good, and only a read happened to clear it -- so + // an app that only ever writes could never store anything again. + resetPlainKey(); + return false; + } + } + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + @Override + public String get(String account) { + if (account == null) { + return null; + } + if (Build.VERSION.SDK_INT < 23) { + return legacyPlainGet(account); + } + SharedPreferences prefs = plainPrefs(); + if (prefs == null) { + return null; + } + String stored = prefs.getString(account, null); + if (stored == null) { + return null; + } + int sep = stored.indexOf(':'); + if (sep < 0) { + // No IV separator, so this was written by legacyPlainSet on API 22 or below + // and the device has since been upgraded to 23+. Reporting it missing would + // silently discard a cached credential across an OS upgrade the user did not + // choose to lose anything by. Decode it and re-store it encrypted, so this + // only happens once. + String legacy = decodeLegacyPlain(stored); + if (legacy != null) { + set(account, legacy); + } + return legacy; + } + try { + // Same reasoning as set(): a reset landing mid-read would otherwise + // invalidate the key between the lookup and the decrypt. + synchronized (PLAIN_KEY_LOCK) { + // The invalid-key DECISION is taken in here too, not in a catch + // outside the lock. Deciding out there let a delayed caller reset a key + // that was no longer the one that failed it: another caller had already + // reset, a writer had created a fresh key and committed ciphertext under + // it, and this one then deleted that new key and wiped every stored + // value -- destroying data written after the failure it was reacting to. + try { + SecretKey key = plainKey(false); + if (key == null) { + return null; + } + byte[] iv = Base64.decode(stored.substring(0, sep), Base64.NO_WRAP); + byte[] enc = Base64.decode(stored.substring(sep + 1), Base64.NO_WRAP); + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); + return new String(c.doFinal(enc), "UTF-8"); + } catch (InvalidKeyException e) { + // The key was invalidated out from under us (device-wide credential + // change, or the Samsung 8.0.0 quirk documented on the biometric + // tier). Everything encrypted under it is unrecoverable, so drop + // the key and the ciphertexts rather than failing forever. + resetPlainKey(); + return null; + } catch (UnrecoverableKeyException e) { + resetPlainKey(); + return null; + } + } + } catch (Throwable t) { + Log.e(t); + return null; + } + } + + @Override + public boolean remove(String account) { + if (account == null) { + return false; + } + SharedPreferences prefs = plainPrefs(); + if (prefs == null) { + return false; + } + // commit(), and its answer is this method's answer. apply() persists on a + // background thread, so returning true said the credential was gone while the + // deletion was still in memory: an app that removes a token on logout and is then + // killed -- which is the ordinary way an Android process ends -- finds it back on + // the next launch. A removal that reports success has to have happened, and this + // is the one operation where the caller cannot verify it later by reading. + // + // Under the same lock as the write and the reset, so a removal cannot be + // interleaved with a set that recreates the entry it was clearing. + synchronized (PLAIN_KEY_LOCK) { + return prefs.edit().remove(account).commit(); + } + } + + /** + * The preferences file, resolved from the application context rather than an + * Activity. + * + *

A port initialized from a background service has no Activity but does + * have a context, and this tier exists precisely so a background caller can + * read a cached secret without prompting. Requiring an Activity would make + * {@code get()} throw there -- outside its try/catch, so the caller crashes + * rather than reading the value it asked for.

+ */ + private SharedPreferences plainPrefs() { + Context ctx = AndroidNativeUtil.getContext(); + if (ctx == null) { + return null; + } + return ctx.getApplicationContext() + .getSharedPreferences(PLAIN_PREFS, Context.MODE_PRIVATE); + } + + /** + * Loads the non-prompting keystore key, optionally creating it. Returns + * null when the key is absent and {@code create} is false, or when + * generation fails. + */ + private SecretKey plainKey(boolean create) throws Exception { + // The whole load-check-generate sequence is serialized, not just the + // generation. Two first writers that each saw the alias absent would each + // generate under it, and the second generation replaces the key the first + // one had already encrypted with -- leaving that ciphertext permanently + // undecryptable. The shared KeyStore is not thread safe either. + synchronized (PLAIN_KEY_LOCK) { + // A KeyStore instance of this tier's own. The biometric tier touches the + // shared one without PLAIN_KEY_LOCK, and KeyStore is not thread safe, so + // sharing it here would trade a race inside this tier for a race across the + // two -- surfacing as intermittent keystore errors that neither tier's code + // would explain. Widening this lock into the biometric path would be worse. + KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE); + ks.load(null); + SecretKey existing = (SecretKey) ks.getKey(PLAIN_KEY_ID, null); + if (existing != null || !create) { + return existing; + } + KeyGenerator gen = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE); + gen.init(new KeyGenParameterSpec.Builder(PLAIN_KEY_ID, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setRandomizedEncryptionRequired(true) + .build()); + gen.generateKey(); + return (SecretKey) ks.getKey(PLAIN_KEY_ID, null); + } + } + + private void resetPlainKey() { + // Deleting the key and dropping the ciphertexts it protected are one step, under + // the same lock readers and writers hold. Clearing outside it left a window + // where a writer had already encrypted under the old key and was about to store + // a value this was about to wipe -- or worse, stored it just after. + synchronized (PLAIN_KEY_LOCK) { + try { + // Same reasoning as plainKey(): this tier does not touch the shared + // KeyStore instance. + KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE); + ks.load(null); + ks.deleteEntry(PLAIN_KEY_ID); + } catch (Exception e) { + Log.e(e); + } + SharedPreferences prefs = plainPrefs(); + if (prefs != null) { + // Also commit(), for the same reason: this method's whole purpose is to + // make the key deletion and the ciphertext deletion one step, and an + // asynchronous clear can be reordered after a writer's pending write. + prefs.edit().clear().commit(); + } + } + } + + // API 22 and below have no KeyGenParameterSpec. The preferences file is + // still app-private, but the value is only obfuscated, not encrypted -- + // it is extractable from a rooted device or a backup. + private boolean legacyPlainSet(String account, String value) { + warnLegacyPlainStorage(); + try { + SharedPreferences prefs = plainPrefs(); + if (prefs == null) { + return false; + } + // Same reason the encrypted tier commits: this returns whether the value was + // stored, and with apply() it returned that before it was true. The legacy + // path is weaker on confidentiality by construction; it does not get to be + // weaker on the one thing the API actually promises. + return prefs.edit() + .putString(account, Base64.encodeToString( + value.getBytes("UTF-8"), Base64.NO_WRAP)) + .commit(); + } catch (IOException e) { + Log.e(e); + return false; + } + } + + /** The obfuscated-only form written on API 22 and below, or null if unreadable. */ + private String decodeLegacyPlain(String stored) { + try { + return new String(Base64.decode(stored, Base64.NO_WRAP), "UTF-8"); + } catch (Throwable t) { + Log.e(t); + return null; + } + } + + private String legacyPlainGet(String account) { + warnLegacyPlainStorage(); + SharedPreferences prefs = plainPrefs(); + if (prefs == null) { + return null; + } + String stored = prefs.getString(account, null); + if (stored == null) { + return null; + } + try { + return new String(Base64.decode(stored, Base64.NO_WRAP), "UTF-8"); + } catch (IOException e) { + Log.e(e); + return null; + } + } + + private boolean legacyPlainWarned; + + private void warnLegacyPlainStorage() { + if (!legacyPlainWarned) { + legacyPlainWarned = true; + Log.p("SecureStorage: this device predates Android API 23, so the " + + "non-prompting tier stores values obfuscated rather than " + + "encrypted. Do not use it for high-value secrets here."); + } + } + /** * Generic helper that initialises the cipher under the keystore key, * prompts the user via {@code BiometricPrompt} (or legacy @@ -202,9 +537,13 @@ private void runAuthenticatedCipher(final String reason, final String accoun return; } } - if (!initCipher(mode, account)) { + Cipher operationCipher = initCipher(mode, account); + if (operationCipher == null) { if (mode == Cipher.ENCRYPT_MODE) { - if (!createKey() || !initCipher(mode, account)) { + if (createKey()) { + operationCipher = initCipher(mode, account); + } + if (operationCipher == null) { failResult(result, BiometricError.UNKNOWN, "Failed to initialise cipher"); return; } @@ -214,15 +553,19 @@ private void runAuthenticatedCipher(final String reason, final String accoun return; } } + // Carried as a parameter from here on. It belongs to this operation and to no + // other, which is what stops a concurrent call from handing its cipher to this + // prompt. if (Build.VERSION.SDK_INT >= 29) { - promptBiometric29(reason, mode, account, result, work); + promptBiometric29(reason, mode, account, result, work, operationCipher); } else { - promptBiometricLegacy(mode, account, result, work); + promptBiometricLegacy(mode, account, operationCipher, result, work); } } private void promptBiometric29(final String reason, final int mode, final String account, - final AsyncResource result, final CipherWork work) { + final AsyncResource result, final CipherWork work, + final Cipher operationCipher) { AndroidBiometrics.runOnUi(new Runnable() { @Override public void run() { @@ -235,7 +578,7 @@ public void run() { AndroidNativeUtil.getActivity(), reason == null ? "Authenticate" : reason, null, null, "Cancel", - cipher, + operationCipher, cs, new BiometricsApi29.CipherAuthCallback() { @Override @@ -256,6 +599,7 @@ public void onError(int errorCode, String errString) { } private void promptBiometricLegacy(final int mode, final String account, + final Cipher operationCipher, final AsyncResource result, final CipherWork work) { AndroidBiometrics.runOnUi(new Runnable() { @Override @@ -273,7 +617,7 @@ public void run() { final CancellationSignal cs = new CancellationSignal(); cancellationSignal = cs; FingerprintManager.CryptoObject crypto = - new FingerprintManager.CryptoObject(cipher); + new FingerprintManager.CryptoObject(operationCipher); fpm.authenticate(crypto, cs, 0, new FingerprintManager.AuthenticationCallback() { int failures; @@ -308,14 +652,24 @@ private void runCipherWork(Cipher authedCipher, CipherWork work, V v = work.run(authedCipher); succeedResult(result, v); } catch (Throwable t) { - // Samsung 8.0.0 quirk: the cipher passes init but doFinal fails - // with a key-invalidated error. Delete the key and let the caller - // retry the entire operation. - // https://issuetracker.google.com/u/0/issues/65578763 - removePermanentlyInvalidatedKey(); - cipher = null; - failResult(result, BiometricError.KEY_REVOKED, - "Cipher operation failed; key invalidated: " + t.getMessage()); + // Only a failure that says the KEY is finished deletes the key. + // + // There is one keystore key behind every biometric account, so this catch + // used to answer a malformed stored value, or an Activity that went away + // mid-prompt, by destroying every other entry in the store -- permanently, + // and while telling the caller its key had been revoked when it had not. + // The Samsung 8.0.0 quirk this was written for is still handled: a cipher + // that initialises and then fails inside doFinal with a keystore error + // underneath is that case, and isKeyInvalidation recognises it. + if (isKeyInvalidation(t)) { + removePermanentlyInvalidatedKey(); + failResult(result, BiometricError.KEY_REVOKED, + "Cipher operation failed; key invalidated: " + t.getMessage()); + } else { + Log.e(t); + failResult(result, BiometricError.UNKNOWN, + "Cipher operation failed: " + t.getMessage()); + } } } @@ -427,58 +781,101 @@ private SecretKey getSecretKey() { return null; } - private Cipher cipher() { - if (cipher == null) { - try { - cipher = Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES - + "/" + KeyProperties.BLOCK_MODE_CBC - + "/" + KeyProperties.ENCRYPTION_PADDING_PKCS7); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("Cipher init failed", e); - } catch (NoSuchPaddingException e) { - throw new RuntimeException("Cipher init failed", e); - } + /** + * A NEW cipher every time, never a shared field. + * + *

The prompt is raised from a UI runnable, so an operation is in flight from the + * moment it initialises its cipher until that runnable runs. With one instance field, + * a second {@code set()} or {@code get()} starting in that window re-initialised the + * same object and the first prompt was handed the second operation's cipher -- wrong + * mode, or the wrong account's IV. The work then failed, and the failure handler read + * that as an invalidated key and deleted the one key every biometric entry shares.

+ */ + private Cipher newCipher() { + try { + return Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + + "/" + KeyProperties.BLOCK_MODE_CBC + + "/" + KeyProperties.ENCRYPTION_PADDING_PKCS7); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Cipher init failed", e); + } catch (NoSuchPaddingException e) { + throw new RuntimeException("Cipher init failed", e); } - return cipher; } - private boolean initCipher(int mode, String account) { + /** The initialised cipher for this one operation, or null if it could not be made. */ + private Cipher initCipher(int mode, String account) { try { SecretKey key = getSecretKey(); if (key == null) { - return false; + return null; } + Cipher c = newCipher(); if (mode == Cipher.ENCRYPT_MODE) { - cipher().init(mode, key); + c.init(mode, key); } else { SharedPreferences sp = AndroidNativeUtil.getActivity() .getApplicationContext() .getSharedPreferences(PREFS, Context.MODE_PRIVATE); byte[] iv = Base64.decode(sp.getString("iv_" + account, ""), Base64.DEFAULT); - cipher().init(mode, key, new IvParameterSpec(iv)); + c.init(mode, key, new IvParameterSpec(iv)); } - return true; + return c; } catch (KeyPermanentlyInvalidatedException e) { removePermanentlyInvalidatedKey(); - return false; + return null; } catch (InvalidKeyException e) { Log.e(e); - return false; + return null; } catch (InvalidAlgorithmParameterException e) { Log.e(e); - return false; + return null; } } private void removePermanentlyInvalidatedKey() { try { keyStore().deleteEntry(KEY_ID); - cipher = null; } catch (KeyStoreException e) { Log.e(e); } } + /** + * Whether a failure means the keystore key is gone, as opposed to this one operation + * having failed. + * + *

The distinction is the whole point. There is ONE key behind every biometric + * account, so deleting it on any failure -- a malformed stored value, an Activity that + * went away mid-prompt, a null passed into the work -- made every other entry + * permanently unreadable, and told the caller its key had been revoked when it had + * not. Only two shapes say the key itself is finished: the exception Android raises + * for it, and the Samsung 8.0.0 quirk where a cipher initialises and then fails inside + * doFinal with a keystore error underneath.

+ * + *

https://issuetracker.google.com/u/0/issues/65578763

+ */ + private static boolean isKeyInvalidation(Throwable t) { + // Bounded rather than while(cause != null): a self-referential cause is rare and + // a hang inside a failure handler is worse than a missed classification. + Throwable c = t; + for (int depth = 0; c != null && depth < 8; depth++) { + if (c instanceof KeyPermanentlyInvalidatedException) { + return true; + } + if (c instanceof IllegalBlockSizeException + && c.getCause() instanceof KeyStoreException) { + return true; + } + Throwable next = c.getCause(); + if (next == c) { + break; + } + c = next; + } + return false; + } + /** Lambda-stand-in for Java 5 source level: cipher op that may throw. */ private interface CipherWork { V run(Cipher c) throws Exception; diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidWebSocketImpl.java b/Ports/Android/src/com/codename1/impl/android/AndroidWebSocketImpl.java index b844a6d7715..55ff0b54b60 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidWebSocketImpl.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidWebSocketImpl.java @@ -162,6 +162,7 @@ private void doHandshake(int connectTimeoutMs) throws IOException { } req.append("\r\n"); } + appendRequestHeaders(req); req.append("\r\n"); out.write(req.toString().getBytes(ASCII)); out.flush(); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 7353168fe4a..25006ee01a6 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -6539,6 +6539,8 @@ public void actionPerformed(ActionEvent ae) { simulateMenu.add(biometricMenu); + final JMenu shieldMenu = installShieldSimulationMenu(simulateMenu, pref); + final JMenu nfcMenu = installNfcSimulationMenu(simulateMenu, pref); final JMenu foldableMenu = installFoldableSimulationMenu(simulateMenu, pref); @@ -7056,6 +7058,7 @@ public void actionPerformed(ActionEvent e) { simulateMenu.add(motionSim); simulateMenu.add(pushSim); simulateMenu.add(biometricMenu); + simulateMenu.add(shieldMenu); simulateMenu.add(nfcMenu); simulateMenu.add(foldableMenu); simulateMenu.add(statusBarTapDiag); @@ -8316,6 +8319,235 @@ public void run() { }); } + /** + * Builds the {@code Simulate > App Shield} menu. + * + *

Every toggle here exists to make an otherwise untestable branch + * reachable on a developer's desktop. "Force Pin Mismatch On Next Request" + * is the most valuable of them: a fail-closed pinning branch is otherwise + * only exercisable by deliberately mis-pinning a live host.

+ */ + private JMenu installShieldSimulationMenu(JMenu simulateMenu, final Preferences pref) { + JMenu shieldMenu = new JMenu("App Shield"); + shieldMenu.setToolTipText("Simulate attestation outcomes, compromised-device signals " + + "and certificate pin failures."); + + final JCheckBoxMenuItem supported = new JCheckBoxMenuItem("Attestation Supported", + pref.getBoolean("ShieldSim.supported", true)); + JavaSEShield.attestationSupported = supported.isSelected(); + // A restored FALSE is a simulation left switched on, exactly like a restored + // checkbox elsewhere in this menu -- the action listener does not fire during + // construction, so without this the flag is set and no engine answers, and a + // fail-closed host is let through instead of exercising the unsupported-device + // rejection the setting exists to simulate. True is the default and arms nothing. + if (!supported.isSelected()) { + JavaSEShieldEngine.ensureRegistered(); + } + supported.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent ae) { + JavaSEShield.attestationSupported = supported.isSelected(); + pref.putBoolean("ShieldSim.supported", supported.isSelected()); + // Unchecking this is a simulation like any other -- "this platform has no + // attestation" is an outcome an app has to handle -- so it arms the + // engine as well. + JavaSEShieldEngine.ensureRegistered(); + } + }); + shieldMenu.add(supported); + + JMenu outcomeMenu = new JMenu("Attestation Result"); + ButtonGroup outcomeGroup = new ButtonGroup(); + String storedOutcome = pref.get("ShieldSim.outcome", + JavaSEShield.AttestOutcome.PASS.name()); + for (final JavaSEShield.AttestOutcome outcome : JavaSEShield.AttestOutcome.values()) { + JRadioButtonMenuItem item = new JRadioButtonMenuItem(outcome.name()); + if (outcome.name().equals(storedOutcome)) { + item.setSelected(true); + JavaSEShield.attestOutcome = outcome; + // Restored non-default state arms the engine too, for the same reason a + // restored checkbox does: a developer who left FAIL_REJECTED selected + // expects the next run to fail, and without an engine registered + // AppShield asks the inert default and reports UNPROTECTED instead. + if (outcome != JavaSEShield.AttestOutcome.PASS) { + JavaSEShieldEngine.ensureRegistered(); + } + } + outcomeGroup.add(item); + item.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent ae) { + JavaSEShield.attestOutcome = outcome; + pref.put("ShieldSim.outcome", outcome.name()); + // Selecting any outcome registers the engine, PASS included: PASS is + // "hand out a simulated token", which the inert default does not do + // either. Only the untouched menu leaves the simulator alone. + JavaSEShieldEngine.ensureRegistered(); + } + }); + outcomeMenu.add(item); + } + shieldMenu.add(outcomeMenu); + + shieldMenu.addSeparator(); + + // The signals a compromised device would report. Independent toggles + // because an app's response to a rooted device and to a hooking + // framework are usually different decisions. + shieldMenu.add(shieldToggle(pref, "Rooted / Jailbroken", "ShieldSim.rooted", + new ShieldToggleSink() { + @Override + public void set(boolean v) { + JavaSEShield.simRooted = v; + } + })); + shieldMenu.add(shieldToggle(pref, "Hooking Framework (Frida)", "ShieldSim.hooked", + new ShieldToggleSink() { + @Override + public void set(boolean v) { + JavaSEShield.simHooked = v; + } + })); + shieldMenu.add(shieldToggle(pref, "Emulator", "ShieldSim.emulator", + new ShieldToggleSink() { + @Override + public void set(boolean v) { + JavaSEShield.simEmulator = v; + } + })); + shieldMenu.add(shieldToggle(pref, "Debugger Attached", "ShieldSim.debugger", + new ShieldToggleSink() { + @Override + public void set(boolean v) { + JavaSEShield.simDebugger = v; + } + })); + shieldMenu.add(shieldToggle(pref, "Repackaged", "ShieldSim.repackaged", + new ShieldToggleSink() { + @Override + public void set(boolean v) { + JavaSEShield.simRepackaged = v; + } + })); + shieldMenu.add(shieldToggle(pref, "Untrusted Accessibility Service", + "ShieldSim.accessibility", new ShieldToggleSink() { + @Override + public void set(boolean v) { + JavaSEShield.simUntrustedAccessibility = v; + } + })); + + shieldMenu.addSeparator(); + + shieldMenu.add(shieldToggle(pref, "Serve Expired Token", "ShieldSim.expiredToken", + new ShieldToggleSink() { + @Override + public void set(boolean v) { + JavaSEShield.serveExpiredToken = v; + } + })); + + // The one branch that is otherwise effectively untestable. + final JCheckBoxMenuItem pinMismatch = shieldToggle(pref, + "Force Pin Mismatch On Next Request", "ShieldSim.pinMismatch", + new ShieldToggleSink() { + @Override + public void set(boolean v) { + JavaSEShield.forcePinMismatch = v; + } + }); + // It is a one-shot, so when the engine spends it the menu and the stored + // preference have to follow. Otherwise the checkbox says a mismatch is armed + // when it is not, the next click disarms instead of arming, and the next launch + // restores a mismatch that already fired. + JavaSEShield.onForcePinMismatchConsumed = new Runnable() { + @Override + public void run() { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + pinMismatch.setSelected(false); + pref.putBoolean("ShieldSim.pinMismatch", false); + } + }); + } + }; + shieldMenu.add(pinMismatch); + shieldMenu.add(shieldToggle(pref, "Fail Pin Fetch", "ShieldSim.pinFetchFail", + new ShieldToggleSink() { + @Override + public void set(boolean v) { + JavaSEShield.failPinFetch = v; + } + })); + + shieldMenu.addSeparator(); + + JMenuItem status = new JMenuItem("Show Shield Status..."); + status.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent ae) { + // The engine line matters: every toggle below the attestation section + // is read by the simulator engine, so "not registered" is the + // difference between a switch that acts and a switch that only shows. + JOptionPane.showMessageDialog(canvas, JavaSEShield.describe() + + "Simulator engine: " + + (com.codename1.security.shield.spi.ShieldEngineRegistry + .isEngineRegistered() + ? com.codename1.security.shield.spi.ShieldEngineRegistry + .getEngine().getName() + " (registered)" + : "not registered -- switch on any toggle above to install it") + + "\n", + "App Shield Simulation", JOptionPane.INFORMATION_MESSAGE); + } + }); + shieldMenu.add(status); + + // Returned rather than added here, matching installNfcSimulationMenu and + // installFoldableSimulationMenu. The caller does the adding, in the + // removeAll-then-rebuild block that assembles the final menu order. + return shieldMenu; + } + + /** Lambda stand-in so the toggle wiring is written once rather than nine times. */ + private interface ShieldToggleSink { + void set(boolean value); + } + + private JCheckBoxMenuItem shieldToggle(final Preferences pref, String label, + final String prefKey, final ShieldToggleSink sink) { + final JCheckBoxMenuItem item = new JCheckBoxMenuItem(label, + pref.getBoolean(prefKey, false)); + applyShieldToggle(sink, item.isSelected()); + item.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent ae) { + applyShieldToggle(sink, item.isSelected()); + pref.putBoolean(prefKey, item.isSelected()); + } + }); + return item; + } + + /** + * Applies a shield toggle and, when it is being switched ON, makes sure the + * simulator engine is registered. + * + *

The token and pinning toggles are read by {@link JavaSEShieldEngine}, and with + * no engine registered the inert default answers instead -- so the switches set a + * field, the status dialog reported the field, and nothing behaved differently. + * Registration happens here rather than when the menu is built, because sealing the + * registry at startup would make every app run in the simulator report itself as + * protected. It also covers a toggle restored from preferences, since a developer + * who left one armed expects it to still be armed.

+ */ + private void applyShieldToggle(ShieldToggleSink sink, boolean value) { + sink.set(value); + if (value) { + JavaSEShieldEngine.ensureRegistered(); + } + } + private JMenu installNfcSimulationMenu(JMenu simulateMenu, final Preferences pref) { JMenu nfcMenu = new JMenu("NFC"); @@ -14254,9 +14486,47 @@ public String[] getSSLCertificates(Object connection, String url) throws IOExcep public boolean canGetSSLCertificates() { return true; } - - - + + @Override + public boolean canGetPublicKeyDigests() { + return true; + } + + @Override + public String[] getSSLCertificatesEx(Object connection, String url) throws IOException { + if (connection instanceof HttpsURLConnection) { + HttpsURLConnection conn = (HttpsURLConnection) connection; + try { + conn.connect(); + java.security.cert.Certificate[] certs = conn.getServerCertificates(); + java.util.List out = new java.util.ArrayList(); + for (int i = 0; i < certs.length; i++) { + java.security.cert.Certificate cert = certs[i]; + out.add("CHAIN:" + i); + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + sha256.update(cert.getEncoded()); + out.add("SHA-256:" + dumpHex(sha256.digest())); + MessageDigest sha1 = MessageDigest.getInstance("SHA1"); + sha1.update(cert.getEncoded()); + out.add("SHA1:" + dumpHex(sha1.digest())); + // getPublicKey().getEncoded() is already the DER SubjectPublicKeyInfo, + // which is exactly what a public-key pin is computed over. + java.security.PublicKey pk = cert.getPublicKey(); + if (pk != null && pk.getEncoded() != null) { + MessageDigest spki = MessageDigest.getInstance("SHA-256"); + spki.update(pk.getEncoded()); + out.add("SPKI-SHA-256:" + + com.codename1.util.Base64.encodeNoNewline(spki.digest())); + } + } + return out.toArray(new String[out.size()]); + } catch (Exception ex) { + ex.printStackTrace(); + } + } + return new String[0]; + } + /** * @inheritDoc */ @@ -18821,6 +19091,86 @@ public boolean isJailbrokenDevice() { return super.isJailbrokenDevice(); } + // --- DeviceIntegrity / App Shield simulation -------------------------- + // + // Without these the simulator reports "no attestation, clean device" for + // everything, so the branches an app takes when a device looks compromised + // are unreachable until it is on real hardware. Driven by the + // Simulate > App Shield menu; see JavaSEShield. + + @Override + public boolean isAttestationSupported() { + return JavaSEShield.attestationSupported; + } + + @Override + public com.codename1.util.AsyncResource requestIntegrityToken(String nonce) { + final com.codename1.util.AsyncResource result = + new com.codename1.util.AsyncResource(); + if (!JavaSEShield.attestationSupported + || JavaSEShield.attestOutcome == JavaSEShield.AttestOutcome.UNSUPPORTED) { + result.error(new UnsupportedOperationException( + "Simulated: attestation is not supported on this device")); + return result; + } + switch (JavaSEShield.attestOutcome) { + case PASS: + // Stamped as simulated so it cannot be mistaken for, or accepted + // as, a real attestation by any backend. + result.complete("cn1sim:attest:" + (nonce == null ? "" : nonce)); + break; + case FAIL_REJECTED: + result.error(new RuntimeException( + "Simulated: the attestation service rejected this device")); + break; + case FAIL_NO_NETWORK: + result.error(new java.io.IOException("Simulated: no network")); + break; + case FAIL_SERVICE_DOWN: + result.error(new java.io.IOException( + "Simulated: the attestation service is unavailable")); + break; + case FAIL_RATE_LIMITED: + result.error(new RuntimeException("Simulated: rate limited, back off")); + break; + default: + result.error(new RuntimeException("Simulated attestation failure")); + } + return result; + } + + @Override + public void resetAttestation() { + // Nothing is cached in the simulator; the menu is the state. + } + + @Override + public void confirmAttestation(String keyId) { + // No client-side key here either, so there is nothing to acknowledge. + } + + @Override + public boolean isDeviceCompromised() { + return JavaSEShield.simReasons().length > 0; + } + + @Override + public String[] getCompromiseReasons() { + return JavaSEShield.simReasons(); + } + + @Override + public String[] getEnabledAccessibilityServices() { + return JavaSEShield.simAccessibility(); + } + + @Override + public void setSecureScreen(boolean secure) { + // No OS-level equivalent on the desktop. Recorded so the menu can show + // whether the app asked for it, which is what a developer is checking. + JavaSEShield.secureScreen = secure; + } + @Override public Boolean canExecute(String url) { // If this is a registered simulator hook URL, report it as diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSESecureStorage.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSESecureStorage.java index 4c175eca4a6..bd4f4404246 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSESecureStorage.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSESecureStorage.java @@ -27,23 +27,52 @@ import com.codename1.security.BiometricException; import com.codename1.security.Biometrics; import com.codename1.security.SecureStorage; +import com.codename1.io.Log; import com.codename1.util.AsyncResource; import com.codename1.util.AsyncResult; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.prefs.BackingStoreException; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + /** * Simulator backing for {@link SecureStorage}. Reads gate behind the * {@link Biometrics} prompt (which the simulator menu controls); writes * persist to {@code java.util.prefs} so values survive a JVM restart. + * + *

The non-prompting tier is obfuscation, not security. It encrypts + * with a key derived from the OS user account plus a per-node random salt, both + * of which live on the same machine as the ciphertext -- anything running as + * this user can recover the plaintext. Its job is to keep API keys out of + * cleartext in the project tree during simulator runs, which is exactly what + * the base-class contract promises for the desktop. Device platforms back the + * same API with real hardware-held keys.

*/ public final class JavaSESecureStorage extends SecureStorage { private static final String NODE = "com.codename1.simulator.secureStorage"; + private static final String PLAIN_NODE = "com.codename1.simulator.secureStorage.plain"; + private static final String SALT_KEY = "__cn1_salt"; + private static final String VALUE_PREFIX = "v_"; + private static final int GCM_TAG_BITS = 128; + private static final int PBKDF2_ROUNDS = 120000; + private final java.util.prefs.Preferences prefs; + private final java.util.prefs.Preferences plainPrefs; private final JavaSEBiometrics biometrics; + private SecretKey plainKey; JavaSESecureStorage(JavaSEBiometrics biometrics) { this.biometrics = biometrics; this.prefs = java.util.prefs.Preferences.userRoot().node(NODE); + this.plainPrefs = java.util.prefs.Preferences.userRoot().node(PLAIN_NODE); } @Override @@ -95,4 +124,123 @@ public AsyncResource remove(String reason, String account) { public void setKeychainAccessGroup(String group) { // No-op in the simulator. } + + // --- Non-prompting tier ------------------------------------------------ + + @Override + public boolean set(String account, String value) { + if (account == null || value == null) { + return false; + } + try { + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.ENCRYPT_MODE, plainKey()); + byte[] enc = c.doFinal(value.getBytes("UTF-8")); + Base64.Encoder b64 = Base64.getEncoder(); + plainPrefs.put(VALUE_PREFIX + account, + b64.encodeToString(c.getIV()) + ":" + b64.encodeToString(enc)); + // flush(), and its outcome is this method's answer. Preferences writes back + // on its own schedule, so returning true said the secret was stored while it + // was still only in memory -- and the simulator is killed abruptly all the + // time, by the run button and by the IDE. The same reasoning as the Android + // tier committing rather than applying: a write that reports success has to + // have happened. + plainPrefs.flush(); + return true; + } catch (Exception e) { + Log.e(e); + return false; + } + } + + @Override + public String get(String account) { + if (account == null) { + return null; + } + String stored = plainPrefs.get(VALUE_PREFIX + account, null); + if (stored == null) { + return null; + } + int sep = stored.indexOf(':'); + if (sep < 0) { + return null; + } + try { + Base64.Decoder b64 = Base64.getDecoder(); + byte[] iv = b64.decode(stored.substring(0, sep)); + byte[] enc = b64.decode(stored.substring(sep + 1)); + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.DECRYPT_MODE, plainKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); + return new String(c.doFinal(enc), "UTF-8"); + } catch (Exception e) { + Log.e(e); + return null; + } + } + + @Override + public boolean remove(String account) { + if (account == null) { + return false; + } + plainPrefs.remove(VALUE_PREFIX + account); + try { + // Same for the removal, and it matters more: this is the credential a logout + // clears, so an unflushed deletion is one that comes back on the next launch. + plainPrefs.flush(); + } catch (BackingStoreException e) { + Log.e(e); + return false; + } + return true; + } + + /** + * Derives (or returns) the key the non-prompting tier encrypts with. + * + *

Locked on the class, not on the instance. {@code JavaSEPort.getSecureStorage()} + * creates its singleton without synchronization, so two threads making the first call + * concurrently each get their own {@code JavaSESecureStorage} -- and an instance lock + * then serializes nothing. Both would find the shared salt missing, generate different + * ones, and write values encrypted under different keys before one salt overwrote the + * other in the same Preferences node; whichever lost is permanently undecryptable. The + * salt and the node are process-wide, so the lock has to be too.

+ */ + private SecretKey plainKey() throws Exception { + synchronized (KEY_LOCK) { + return plainKeyLocked(); + } + } + + /** The salt is shared by every instance in the process, so the lock is as well. */ + private static final Object KEY_LOCK = new Object(); + + private SecretKey plainKeyLocked() throws Exception { + if (plainKey != null) { + return plainKey; + } + String saltB64 = plainPrefs.get(SALT_KEY, null); + byte[] salt; + if (saltB64 == null) { + salt = new byte[16]; + new SecureRandom().nextBytes(salt); + plainPrefs.put(SALT_KEY, Base64.getEncoder().encodeToString(salt)); + } else { + salt = Base64.getDecoder().decode(saltB64); + } + char[] material = (System.getProperty("user.name", "cn1") + // "\0", not a literal zero byte in the file. Java accepts the raw + // character, but git then classifies the whole source as binary: diffs + // report "- -" instead of lines, and grep stops matching it, so every + // later review of this file is blind. The octal escape rather than + // \u0000 because unicode escapes are processed before the source is + // tokenized, which is a footgun this line does not need to inherit. + + "\0" + System.getProperty("user.home", "")).toCharArray(); + SecretKeyFactory f = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + byte[] derived = f.generateSecret( + new PBEKeySpec(material, salt, PBKDF2_ROUNDS, 256)).getEncoded(); + plainKey = new SecretKeySpec(derived, "AES"); + return plainKey; + } } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShield.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShield.java new file mode 100644 index 00000000000..5b7bfc0e3a1 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShield.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import java.util.ArrayList; +import java.util.List; + +/** + * Simulator state behind the {@code Simulate > App Shield} menu. + * + *

Exists so a developer can exercise the branches an app takes when a device + * looks compromised or attestation fails. Those are exactly the paths that are + * otherwise impossible to test: rooting a phone, attaching a hooking framework + * or deliberately mis-pinning a real host are all things nobody does casually, + * so without simulation the error handling ships untested and is discovered by + * users.

+ * + *

Mirrors {@link JavaSEBiometrics}: plain static fields driven by menu + * checkboxes and persisted to preferences, deliberately with no logic of its + * own.

+ */ +public final class JavaSEShield { + + private JavaSEShield() { + } + + // --- attestation ------------------------------------------------------ + + /** What the next attestation request should do. */ + public enum AttestOutcome { + /** Return a simulated token. */ + PASS, + /** The service evaluated the device and said no. Not retryable. */ + FAIL_REJECTED, + /** No connectivity. Retryable. */ + FAIL_NO_NETWORK, + /** The service returned an error. Retryable. */ + FAIL_SERVICE_DOWN, + /** The device is being throttled. Retryable after backoff. */ + FAIL_RATE_LIMITED, + /** The platform has no attestation at all. */ + UNSUPPORTED + } + + public static AttestOutcome attestOutcome = AttestOutcome.PASS; + + /** Whether the simulated platform reports attestation support. */ + public static boolean attestationSupported = true; + + // --- device signals --------------------------------------------------- + + public static boolean simRooted; + public static boolean simHooked; + public static boolean simEmulator; + public static boolean simDebugger; + public static boolean simRepackaged; + public static boolean simUntrustedAccessibility; + + // --- token ------------------------------------------------------------ + + /** Simulated token lifetime. */ + public static int tokenTtlSeconds = 300; + + /** Hand out a token that has already lapsed, to exercise refresh handling. */ + public static boolean serveExpiredToken; + + // --- pinning ---------------------------------------------------------- + + /** + * Fail the next certificate check. + * + *

The most useful switch here by a distance: it is the only practical way + * to test a fail-closed branch, short of deliberately mis-pinning a live + * host and waiting for the request to break.

+ */ + public static boolean forcePinMismatch; + + /** Simulate being unable to fetch a pin set. Must never fail a request. */ + public static boolean failPinFetch; + + /** + * Notified when the one-shot forced mismatch is spent, so the menu can disarm too. + * + *

Without it the engine cleared its own flag and the checkbox and the stored + * preference stayed true: the menu claimed a mismatch was still armed, the next click + * disarmed it instead of scheduling another, and restarting the simulator re-armed + * one that had already fired. A control that reports a state it does not cause is + * the failure this whole engine exists to remove, so it must not reappear in the + * engine's own bookkeeping.

+ */ + public static Runnable onForcePinMismatchConsumed; + + /** Clears the forced mismatch and lets the menu know, so the two cannot disagree. */ + static void consumeForcePinMismatch() { + forcePinMismatch = false; + Runnable r = onForcePinMismatchConsumed; + if (r != null) { + r.run(); + } + } + + /** True when the window is displaying a screen marked secure. */ + public static boolean secureScreen; + + /** The compromise reasons the simulated device reports. */ + public static String[] simReasons() { + List out = new ArrayList(); + if (simRooted) { + out.add("root"); + } + if (simHooked) { + out.add("frida"); + } + if (simEmulator) { + out.add("emulator"); + } + if (simDebugger) { + out.add("debugger"); + } + if (simRepackaged) { + out.add("repackaged"); + } + return out.toArray(new String[out.size()]); + } + + /** The accessibility services the simulated device reports as enabled. */ + public static String[] simAccessibility() { + if (!simUntrustedAccessibility) { + return new String[0]; + } + return new String[] {"com.example.malware/.OverlayService"}; + } + + /** Resets every toggle. Used by the menu's reset item and by tests. */ + public static void reset() { + attestOutcome = AttestOutcome.PASS; + attestationSupported = true; + simRooted = false; + simHooked = false; + simEmulator = false; + simDebugger = false; + simRepackaged = false; + simUntrustedAccessibility = false; + tokenTtlSeconds = 300; + serveExpiredToken = false; + forcePinMismatch = false; + failPinFetch = false; + secureScreen = false; + onForcePinMismatchConsumed = null; + } + + /** A human-readable dump for the menu's status dialog. */ + public static String describe() { + StringBuilder sb = new StringBuilder(); + sb.append("Attestation outcome: ").append(attestOutcome).append('\n'); + sb.append("Attestation supported: ").append(attestationSupported).append('\n'); + String[] reasons = simReasons(); + sb.append("Device signals: ") + .append(reasons.length == 0 ? "(none)" : String.join(", ", reasons)).append('\n'); + sb.append("Accessibility: ") + .append(simUntrustedAccessibility ? "untrusted service enabled" : "clean").append('\n'); + sb.append("Token TTL: ").append(tokenTtlSeconds).append("s") + .append(serveExpiredToken ? " (serving expired)" : "").append('\n'); + sb.append("Pinning: ") + .append(forcePinMismatch ? "forcing mismatch" : "normal") + .append(failPinFetch ? ", pin fetch failing" : "").append('\n'); + sb.append("Secure screen: ").append(secureScreen).append('\n'); + return sb.toString(); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java new file mode 100644 index 00000000000..fc9950de88b --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEShieldEngine.java @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import com.codename1.io.Log; +import com.codename1.security.shield.AppShield; +import com.codename1.security.shield.FailureMode; +import com.codename1.security.shield.HostPolicy; +import com.codename1.security.shield.PinSet; +import com.codename1.security.shield.ShieldConfig; +import com.codename1.security.shield.ShieldException; +import com.codename1.security.shield.ShieldSignal; +import com.codename1.security.shield.ShieldStatus; +import com.codename1.security.shield.ShieldToken; +import com.codename1.security.shield.spi.EngineContext; +import com.codename1.security.shield.spi.ShieldEngine; +import com.codename1.security.shield.spi.ShieldEngineRegistry; +import java.util.Hashtable; +import java.util.Vector; + +/** + * The engine behind {@code Simulate > App Shield}. + * + *

The toggles in that menu described outcomes nothing produced: a developer could + * switch on Force Pin Mismatch, watch the status dialog agree that pinning was + * "forcing mismatch", and see every request succeed -- because no engine was registered + * in the simulator, so the inert default answered every call and the fail-closed branch + * the switch exists to reach was unreachable. A switch that reports a state it does not + * cause is worse than no switch: it is a test that passes for the wrong reason.

+ * + *

Registered on demand, the first time a developer touches that menu, and never + * automatically -- a simulator that reported {@code isProtected()} true out of the box + * would change what every existing app does when run there. Tokens are stamped + * {@link #SIMULATED_MARKER} so nothing can mistake one for the real thing, and every + * one of them says so in its own value.

+ */ +public final class JavaSEShieldEngine implements ShieldEngine { + + /** + * Present in every simulated token. + * + *

These are minted locally with no attestation behind them at all, so a backend + * that ever sees one is talking to a simulator. It is in the token value rather than + * only in a header because the value is the part that gets copied into a curl command + * and pasted into a bug report.

+ */ + public static final String SIMULATED_MARKER = "cn1-simulated"; + + private ShieldConfig config; + private ShieldToken cached; + + /** + * Installs the simulator engine, once, if nothing has claimed the slot. + * + *

Called when a shield simulation is switched on -- including one restored from + * preferences at startup, because a developer who left "Force Pin Mismatch" armed + * expects it to still be armed. Not called merely because the menu exists: the + * registry seals on first registration and a simulator that always reported + * {@code isProtected()} true would change what every app does when run there.

+ * + *

The engine is initialized here as well as by + * {@code AppShield.init(ShieldConfig)}, because the two can happen in either order. + * An app that called {@code init()} at startup and a developer who opens the menu + * afterwards would otherwise leave this holding a null config -- and a null config + * means no pinned hosts, which means {@code verifyPins()} is never reached and the + * force switch quietly does nothing. That is the exact failure this class exists to + * remove, so it must not be reintroduced by ordering.

+ */ + public static void ensureRegistered() { + if (ShieldEngineRegistry.isEngineRegistered()) { + return; + } + JavaSEShieldEngine engine = new JavaSEShieldEngine(); + try { + ShieldEngineRegistry.setEngine(engine); + } catch (IllegalStateException alreadySealed) { + // A real engine got there first -- a build-server bootstrap, or a test. The + // simulator does not displace it. + return; + } catch (RuntimeException other) { + Log.e(other); + return; + } + try { + engine.initialize(ShieldEngineRegistry.getDefaultContext(), + AppShield.getConfig()); + } catch (RuntimeException e) { + Log.e(e); + } + } + + public String getName() { + return "simulator"; + } + + public boolean isAvailable() { + return JavaSEShield.attestationSupported; + } + + public void initialize(EngineContext ctx, ShieldConfig cfg) { + this.config = cfg; + } + + public ShieldToken fetchToken(String bindingData) throws ShieldException { + if (!JavaSEShield.attestationSupported) { + throw new ShieldException(ShieldStatus.UNPROTECTED, + "The simulated platform reports no attestation support"); + } + switch (JavaSEShield.attestOutcome) { + case FAIL_REJECTED: + throw new ShieldException(ShieldStatus.REJECTED, + "Simulated: the service rejected this device"); + case FAIL_NO_NETWORK: + throw new ShieldException(ShieldStatus.NO_NETWORK, + "Simulated: no network"); + case FAIL_SERVICE_DOWN: + throw new ShieldException(ShieldStatus.SERVICE_DOWN, + "Simulated: the attestation service is down"); + case FAIL_RATE_LIMITED: + throw new ShieldException(ShieldStatus.RATE_LIMITED, + "Simulated: rate limited"); + case UNSUPPORTED: + throw new ShieldException(ShieldStatus.UNPROTECTED, + "Simulated: this platform has no attestation"); + default: + break; + } + long ttl = (long) Math.max(1, JavaSEShield.tokenTtlSeconds) * 1000L; + long fetchedAt = System.currentTimeMillis(); + if (JavaSEShield.serveExpiredToken) { + // Handed out already lapsed rather than with a short lifetime, so a test does + // not have to wait for it. isValid() is answered from a monotonic reading + // taken at construction, so backdating fetchedAt alone would not do it. + ttl = 0L; + } + cached = new ShieldToken(SIMULATED_MARKER + "." + Long.toHexString(fetchedAt) + + (bindingData == null ? "" : "." + Integer.toHexString(bindingData.hashCode())), + ShieldStatus.OK, fetchedAt, ttl, bindingData); + return cached; + } + + public ShieldToken getCachedToken() { + return cached; + } + + public boolean verifyPins(String host, String[] spkiDigests, String[] certDigests) { + if (!JavaSEShield.forcePinMismatch) { + return true; + } + // One shot, because the switch is labelled "on next request". Leaving it armed + // would fail every subsequent request too, and a developer testing a recovery + // path would be testing a permanently broken app instead. Through + // consumeForcePinMismatch so the menu and the stored preference disarm with the + // flag -- clearing the field alone left the checkbox claiming it was still armed + // and re-armed it on the next launch. + JavaSEShield.consumeForcePinMismatch(); + return false; + } + + public PinSet getPinSet() { + if (JavaSEShield.failPinFetch) { + // An unavailable pin set is not a mismatch. Pinning fails OPEN on + // unavailability everywhere in this design, and the simulator has to be able + // to demonstrate that rather than assert it. + return new PinSet(new Hashtable(), 0, 0L, 0L); + } + // Every host the app registered, pinned to a digest no real chain can produce. + // Enforcement is what makes verifyPins() run at all, so without this the force + // switch would still have nothing to act on: PinSet.isEnforcedFor() is false for + // a host with no pins, and ShieldNetworkGuard checks that before asking. + Hashtable hostToPins = new Hashtable(); + // Both sources, not just the ShieldConfig handed to initialize(). That config is a + // snapshot of what the app declared up front and never learns about + // AppShield.addProtectedHost() -- so a backend discovered at runtime, which is + // exactly the case that call exists for, had no simulated pin, hence no + // enforcement, hence no way for "Force Pin Mismatch On Next Request" to reach it. + // The one pin-failure path a developer cannot produce any other way was the one + // the simulator could not produce either. + // + // The config is still read directly rather than only through AppShield, because + // an engine can be initialized with a config that AppShield was never handed -- + // which is how the tests drive it, and losing that would trade one blind spot + // for another. + Vector sources = new Vector(); + if (config != null) { + sources.addElement(config.protectedHosts()); + } + sources.addElement(AppShield.protectedHosts()); + for (int i = 0; i < sources.size(); i++) { + java.util.Enumeration hosts = (java.util.Enumeration) sources.elementAt(i); + while (hosts.hasMoreElements()) { + String host = (String) hosts.nextElement(); + if (host == null || hostToPins.containsKey(host)) { + continue; + } + // Wildcards included. PinSet.isEnforcedFor() resolves "api.example.com" + // against a "*.example.com" entry, so skipping them left every app that + // registers its hosts by pattern -- which is the common way to do it -- + // with nothing enforced and the force switch doing nothing, which is the + // whole failure this engine exists to remove. + Vector pins = new Vector(); + pins.addElement(SIMULATED_PIN); + hostToPins.put(host, pins); + } + } + long now = System.currentTimeMillis(); + return new PinSet(hostToPins, 1, now + DAY_MILLIS, now + 30L * DAY_MILLIS); + } + + public ShieldSignal[] collectSignals() { + String[] reasons = JavaSEShield.simReasons(); + Vector out = new Vector(); + for (int i = 0; i < reasons.length; i++) { + out.addElement(new ShieldSignal(reasons[i], severityFor(reasons[i]), + "simulated")); + } + String[] accessibility = JavaSEShield.simAccessibility(); + for (int i = 0; i < accessibility.length; i++) { + out.addElement(new ShieldSignal(ShieldSignal.ACCESSIBILITY, 60, + accessibility[i])); + } + ShieldSignal[] arr = new ShieldSignal[out.size()]; + out.copyInto(arr); + return arr; + } + + public void invalidate() { + cached = null; + } + + public void shutdown() { + cached = null; + } + + private static int severityFor(String reason) { + if (ShieldSignal.HOOK.equals(reason) || "frida".equals(reason)) { + return 90; + } + if (ShieldSignal.ROOT.equals(reason) || ShieldSignal.JAILBREAK.equals(reason)) { + return 70; + } + if (ShieldSignal.REPACKAGED.equals(reason)) { + return 80; + } + if (ShieldSignal.DEBUGGER.equals(reason)) { + return 50; + } + return 30; + } + + private static final long DAY_MILLIS = 24L * 60L * 60L * 1000L; + + /** + * A digest no live chain can match, so an enforced host fails when the switch is on. + * + *

Deliberately not a real digest: if it ever collided with a host's actual key the + * force switch would silently stop working, which is the failure mode this whole + * class exists to remove.

+ */ + private static final String SIMULATED_PIN = + "c2ltdWxhdGVkLXBpbi1uby1yZWFsLWNoYWluLW1hdGNoZXMtdGhpcw=="; + + /** + * The policy a simulated host gets when the app registered none, so the menu's + * pinning switches have something to act on even in an app that only called + * {@code AppShield.init()}. + */ + static HostPolicy simulatedPolicy() { + return new HostPolicy(true, true, FailureMode.CLOSED); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWebSocketImpl.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWebSocketImpl.java index ed50c455a46..4c21f646eba 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWebSocketImpl.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWebSocketImpl.java @@ -163,6 +163,7 @@ private void doHandshake(int connectTimeoutMs) throws IOException { } req.append("\r\n"); } + appendRequestHeaders(req); req.append("\r\n"); out.write(req.toString().getBytes(StandardCharsets.ISO_8859_1)); out.flush(); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/MCPSocketTransport.java b/Ports/JavaSE/src/com/codename1/impl/javase/MCPSocketTransport.java index c43bf83b2df..715071089e4 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/MCPSocketTransport.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/MCPSocketTransport.java @@ -76,6 +76,31 @@ public MCPTransport createSocketTransport(int port) { @Override public void open() throws IOException { + // Reopening this instance starts a session; it does not resume a closed one. + // + // close() set `closed` permanently and nothing cleared it, so a stop()/start() pair + // over one transport -- which MCPServer supports, and which is how a caller holding + // a single transport restarts -- bound a socket, saw the retained flag below, closed + // that socket and threw, stopping the restarted server on its first breath. Cleared + // here rather than in close(), because a transport that has been closed and not + // reopened must keep refusing reads. Same fix, same reasoning, as + // MCPLoopbackSocketTransport. + // + // Refusing a second open while one is live is part of it: the listening socket is + // reachable only through this field, so overwriting it strands a listener that + // survives close() and keeps the port bound. + synchronized (lock) { + if (serverSocket != null) { + throw new IOException("This MCP transport is already listening on port " + + port); + } + closed = false; + // A previous session's streams belong to a socket close() already shut. Left + // in place, readMessage() would read the old client before accepting the new + // one -- it recovers, but only by way of a read failure on a dead socket. + reader = null; + writer = null; + } // Bind to the loopback interface only so the MCP control channel is never exposed // to the local network. Backlog of one: a single agent attaches at a time, but the // listening socket stays bound across client sessions so an agent can disconnect and diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWebSocketImpl.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWebSocketImpl.java index a2fe4e80f36..f1d0e8a412e 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWebSocketImpl.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWebSocketImpl.java @@ -137,6 +137,7 @@ private void doHandshake(int connectTimeoutMs) throws IOException { req.append("Connection: Upgrade\r\n"); req.append("Sec-WebSocket-Key: ").append(key).append("\r\n"); req.append("Sec-WebSocket-Version: 13\r\n"); + appendRequestHeaders(req); req.append("\r\n"); out.write(bytes(req.toString())); out.flush(); diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsWebSocketImpl.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsWebSocketImpl.java index 921caa72a6a..3aacd07582f 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsWebSocketImpl.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsWebSocketImpl.java @@ -152,6 +152,7 @@ private void doHandshake(int connectTimeoutMs) throws IOException { req.append("Connection: Upgrade\r\n"); req.append("Sec-WebSocket-Key: ").append(key).append("\r\n"); req.append("Sec-WebSocket-Version: 13\r\n"); + appendRequestHeaders(req); req.append("\r\n"); out.write(bytes(req.toString())); out.flush(); diff --git a/Ports/iOSPort/nativeSources/CN1JailbreakDetector.h b/Ports/iOSPort/nativeSources/CN1JailbreakDetector.h index 96fa9e7ba05..5468066b864 100644 --- a/Ports/iOSPort/nativeSources/CN1JailbreakDetector.h +++ b/Ports/iOSPort/nativeSources/CN1JailbreakDetector.h @@ -20,7 +20,25 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ +#import + //#define CN1_DETECT_JAILBREAK 1 + +/** + * Runs every jailbreak / hooking probe and returns the ones that fired as a + * comma separated list of stable codes, or an empty string on a clean device. + * Codes: dyldInsert, hookLib, jailbreakFile, restrictedWrite, traced. + * + * Always compiled, independent of CN1_DETECT_JAILBREAK, because + * DeviceIntegrity.getCompromiseReasons() surfaces these at runtime without + * terminating the app. Returns an empty string on the simulator. + */ +NSString *cn1JailbreakSignals(void); + #ifdef CN1_DETECT_JAILBREAK -void cn1DetectJailbreakBypassesAndExit(); +/** + * Legacy hard gate kept for the ios.detectJailbreak build hint: runs + * cn1JailbreakSignals() and terminates the process if anything fired. + */ +void cn1DetectJailbreakBypassesAndExit(void); #endif diff --git a/Ports/iOSPort/nativeSources/CN1JailbreakDetector.m b/Ports/iOSPort/nativeSources/CN1JailbreakDetector.m index 1dfee47b79f..f2c695de4fd 100644 --- a/Ports/iOSPort/nativeSources/CN1JailbreakDetector.m +++ b/Ports/iOSPort/nativeSources/CN1JailbreakDetector.m @@ -22,25 +22,31 @@ */ #import "CN1JailbreakDetector.h" -#ifdef CN1_DETECT_JAILBREAK -#import +#import #import #import #import #import #import -void cn1DetectJailbreakBypassesAndExit() { +// Note: there is deliberately no fork() probe here. The classic form, +// "if (fork() == 0) { exit(0); }", terminates the *child* and lets the parent +// sail on, so it never did what it claimed. Reinstating it correctly would +// mean calling a restricted syscall that trips App Review static analysis, for +// a signal the dyld-image and restricted-path probes already carry. + +NSString *cn1JailbreakSignals(void) { #if (TARGET_IPHONE_SIMULATOR) - return; -#endif - // Detect common dynamic library injection used by Frida/Objection and similar tools + return @""; +#else + NSMutableArray *signals = [NSMutableArray array]; + + // Dynamic library injection, as used by Frida/Objection and friends. if (getenv("DYLD_INSERT_LIBRARIES") != NULL) { - NSLog(@"DYLD_INSERT_LIBRARIES detected."); - exit(0); + [signals addObject:@"dyldInsert"]; } - // List of known libraries used by bypass tools like Liberty Lite and Substrate + // Known hooking / jailbreak-bypass libraries loaded into the process. NSArray *bypassLibraries = @[ @"LibertyLite.dylib", @"Substrate.dylib", @@ -49,23 +55,29 @@ void cn1DetectJailbreakBypassesAndExit() { @"tsProtector.dylib", @"FridaGadget" ]; - - // Check all loaded dynamic libraries - for (int i = 0; i < _dyld_image_count(); i++) { + for (uint32_t i = 0; i < _dyld_image_count(); i++) { const char *imageName = _dyld_get_image_name(i); + if (imageName == NULL) { + continue; + } NSString *libraryName = [NSString stringWithUTF8String:imageName]; - - // Check if the library name matches any known bypass tool libraries for (NSString *bypassLibrary in bypassLibraries) { if ([libraryName containsString:bypassLibrary]) { - // Jailbreak bypass detected, exit the app - NSLog(@"Bypass library detected: %@", bypassLibrary); - exit(0); // Exit the app if a bypass tool is detected + [signals addObject:@"hookLib"]; + i = _dyld_image_count(); + break; } } } - - // Additional check for file access to system areas (indicates potential bypass) + + // The two filesystem probes below describe an iOS sandbox that has been broken out + // of, and a Mac is not that sandbox. /bin/bash and /usr/sbin/sshd ship with macOS and + // /private is writable there, so on Mac Catalyst both fire on a stock machine -- and + // an app that asks isJailbrokenDevice() at startup, as ours does, refuses to launch on + // every Mac. The instrumentation probes on either side of this stay, because an + // injected dylib or a hooking library means the same thing wherever it is loaded. +#if !TARGET_OS_MACCATALYST && !TARGET_OS_OSX + // Files that only exist once the sandbox has been broken out of. NSArray *restrictedPaths = @[ @"/Applications/Cydia.app", @"/Library/MobileSubstrate/MobileSubstrate.dylib", @@ -74,45 +86,68 @@ void cn1DetectJailbreakBypassesAndExit() { @"/etc/apt", @"/private/var/lib/apt/" ]; - NSFileManager *fileManager = [NSFileManager defaultManager]; for (NSString *path in restrictedPaths) { if ([fileManager fileExistsAtPath:path]) { - // Jailbreak files detected, exit the app - NSLog(@"Jailbreak-related file detected: %@", path); - exit(0); // Exit the app if a jailbreak-related file is found + [signals addObject:@"jailbreakFile"]; + break; } } - - // Check if we can write to a restricted area (bypasses may allow this) - NSString *testPath = @"/private/jailbreakTest.txt"; - NSError *error; - BOOL wroteFile = [@"Test" writeToFile:testPath atomically:YES encoding:NSUTF8StringEncoding error:&error]; - if (wroteFile && !error) { + + // Writing outside the sandbox should be impossible. + NSString *testPath = @"/private/cn1JailbreakTest.txt"; + NSError *error = nil; + BOOL wroteFile = [@"Test" writeToFile:testPath atomically:YES + encoding:NSUTF8StringEncoding error:&error]; + if (wroteFile && error == nil) { [fileManager removeItemAtPath:testPath error:nil]; - // Able to write to restricted area, exit the app - NSLog(@"Write access to restricted area detected."); - exit(0); // Exit the app if write access to restricted areas is detected - } - - // Check for abnormal system behavior like successful fork() - if (fork() == 0) { - // fork() should not succeed on non-jailbroken devices, exit if it does - NSLog(@"Fork succeeded, indicating jailbreak bypass."); - exit(0); // Exit the app if fork() succeeds + [signals addObject:@"restrictedWrite"]; } - - // Check for process tracing (which could indicate Liberty Lite tampering) +#endif + + // A debugger or instrumentation tool attached to the process. struct kinfo_proc info; size_t size = sizeof(info); int name[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()}; - if (sysctl(name, 4, &info, &size, NULL, 0) == 0 && (info.kp_proc.p_flag & P_TRACED) != 0) { - // Process is being traced, likely due to a jailbreak bypass - NSLog(@"Process tracing detected, indicating jailbreak bypass."); - exit(0); // Exit the app if process tracing is detected + if (sysctl(name, 4, &info, &size, NULL, 0) == 0 + && (info.kp_proc.p_flag & P_TRACED) != 0) { + [signals addObject:@"traced"]; + } + + return [signals componentsJoinedByString:@","]; +#endif +} + +#ifdef CN1_DETECT_JAILBREAK +/** + * Whether a signal says the DEVICE is compromised, as opposed to saying somebody is + * looking at the app. + * + * The probe list grew a `traced` signal so DeviceIntegrity could report a debugger, + * which is worth reporting -- attaching one is how a build gets instrumented. Exiting on + * it is a different matter: a clean physical device launched from Xcode is traced, so the + * launch gate terminated every ordinary debug session on a project that leaves + * ios.detectJailbreak on. That reads as "the app crashes on device", and the usual fix a + * developer reaches for is turning the protection off. + */ +static BOOL cn1IsJailbreakSignal(NSString *signal) { + return [signal isEqualToString:@"dyldInsert"] + || [signal isEqualToString:@"hookLib"] + || [signal isEqualToString:@"jailbreakFile"] + || [signal isEqualToString:@"restrictedWrite"]; +} + +void cn1DetectJailbreakBypassesAndExit(void) { + NSString *signals = cn1JailbreakSignals(); + NSMutableArray *fatal = [NSMutableArray array]; + for (NSString *signal in [signals componentsSeparatedByString:@","]) { + if (cn1IsJailbreakSignal(signal)) { + [fatal addObject:signal]; + } + } + if (fatal.count > 0) { + NSLog(@"Jailbreak bypass detected: %@", [fatal componentsJoinedByString:@","]); + exit(0); } - - // If no jailbreak bypass was detected, the app continues as normal - NSLog(@"No jailbreak bypass detected."); } #endif diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 797d7259761..f939cea4084 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -28,6 +28,7 @@ #include "xmlvm.h" #include "java_lang_String.h" #import "CN1ES2compat.h" +#import "CN1JailbreakDetector.h" #if TARGET_OS_WATCH #import "CN1CGGraphics.h" #import "CN1WatchHost.h" @@ -14596,7 +14597,7 @@ void com_codename1_impl_ios_IOSNative_stopBiometricAuthentication__(CN1_THREAD_S #import #import -JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppAttestSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppAttestSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { #if !TARGET_OS_TV && !TARGET_OS_WATCH if (@available(iOS 14.0, *)) { if (NSClassFromString(@"DCAppAttestService") == NULL) { @@ -14610,66 +14611,164 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppAttestSupported__(CN1_THREAD_ #endif // !TARGET_OS_TV && !TARGET_OS_WATCH } -void com_codename1_impl_ios_IOSNative_requestAppAttestToken___int_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT nonce) { +// Reports a failure back to Java. errorCode carries the raw DCError value so the +// Java side can tell "the key is invalid, throw it away and re-attest" (2) from +// "Apple is throttling us, back off" (4) -- treating those the same is how an +// app burns its attestation budget in a retry loop. +static void cn1AppAttestFail(JAVA_INT requestId, NSError *err, NSString *fallback) { + NSString *m = err != nil ? err.localizedDescription : fallback; + JAVA_INT code = err != nil ? (JAVA_INT)err.code : -1; + JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), m); + com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_int_java_lang_String(getThreadLocalData(), requestId, code, jmsg); +} + +// DCAppAttestService completion handlers run on an arbitrary dispatch queue. +// Hopping to the main queue means every re-entry into the VM comes from a known +// thread, matching what the biometrics block above does. +#define CN1_APP_ATTEST_ON_MAIN(block) dispatch_async(dispatch_get_main_queue(), block) + +void com_codename1_impl_ios_IOSNative_appAttestGenerateKey___int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId) { #if !TARGET_OS_TV && !TARGET_OS_WATCH POOL_BEGIN(); if (@available(iOS 14.0, *)) { DCAppAttestService *service = [DCAppAttestService sharedService]; if (!service.isSupported) { - JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), @"App Attest not supported"); - com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_java_lang_String(getThreadLocalData(), requestId, jmsg); + cn1AppAttestFail(requestId, nil, @"App Attest not supported"); POOL_END(); return; } - NSString *nsNonce = (nonce == JAVA_NULL) ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG nonce); - NSData *nonceData = [nsNonce dataUsingEncoding:NSUTF8StringEncoding]; - unsigned char hashBytes[CC_SHA256_DIGEST_LENGTH]; - CC_SHA256(nonceData.bytes, (CC_LONG)nonceData.length, hashBytes); - NSData *clientDataHash = [NSData dataWithBytes:hashBytes length:CC_SHA256_DIGEST_LENGTH]; [service generateKeyWithCompletionHandler:^(NSString *keyId, NSError *genErr) { - if (genErr != nil || keyId == nil) { - NSString *m = genErr ? genErr.localizedDescription : @"App Attest key generation failed"; - JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), m); - com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_java_lang_String(getThreadLocalData(), requestId, jmsg); - return; - } - [service attestKey:keyId clientDataHash:clientDataHash completionHandler:^(NSData *attestationObject, NSError *attErr) { + CN1_APP_ATTEST_ON_MAIN(^{ + if (genErr != nil || keyId == nil) { + cn1AppAttestFail(requestId, genErr, @"App Attest key generation failed"); + return; + } + JAVA_OBJECT jkey = fromNSString(getThreadLocalData(), keyId); + com_codename1_impl_ios_IOSDeviceIntegrity_nativeKeyGenerated___int_java_lang_String(getThreadLocalData(), requestId, jkey); + }); + }]; + } else { + cn1AppAttestFail(requestId, nil, @"App Attest requires iOS 14+"); + } + POOL_END(); +#else + cn1AppAttestFail(requestId, nil, @"App Attest not available on this platform"); +#endif // !TARGET_OS_TV && !TARGET_OS_WATCH +} + +void com_codename1_impl_ios_IOSNative_appAttestAttestKey___int_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT keyId, JAVA_OBJECT clientDataHashB64) { +#if !TARGET_OS_TV && !TARGET_OS_WATCH + POOL_BEGIN(); + if (@available(iOS 14.0, *)) { + DCAppAttestService *service = [DCAppAttestService sharedService]; + NSString *nsKeyId = (keyId == JAVA_NULL) ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG keyId); + NSString *nsHash = (clientDataHashB64 == JAVA_NULL) ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG clientDataHashB64); + NSData *clientDataHash = nsHash == nil ? nil + : [[NSData alloc] initWithBase64EncodedString:nsHash options:0]; +#ifndef CN1_USE_ARC + // initWithBase64EncodedString returns an owned object. The block below + // retains it for the duration of the call, so hand ownership to the pool + // rather than leaking one decoded hash per request. Guarded because ARC + // forbids an explicit autorelease, and this file builds both ways. + [clientDataHash autorelease]; +#endif + if (nsKeyId == nil || clientDataHash == nil) { + cn1AppAttestFail(requestId, nil, @"App Attest attestation missing key or hash"); + POOL_END(); + return; + } + [service attestKey:nsKeyId clientDataHash:clientDataHash completionHandler:^(NSData *attestationObject, NSError *attErr) { + CN1_APP_ATTEST_ON_MAIN(^{ if (attErr != nil || attestationObject == nil) { - NSString *m = attErr ? attErr.localizedDescription : @"App Attest attestation failed"; - JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), m); - com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_java_lang_String(getThreadLocalData(), requestId, jmsg); + cn1AppAttestFail(requestId, attErr, @"App Attest attestation failed"); return; } - NSData *keyIdData = [keyId dataUsingEncoding:NSUTF8StringEncoding]; - NSString *b64Key = [keyIdData base64EncodedStringWithOptions:0]; NSString *b64Att = [attestationObject base64EncodedStringWithOptions:0]; - NSString *token = [NSString stringWithFormat:@"%@:%@", b64Key, b64Att]; - JAVA_OBJECT jtoken = fromNSString(getThreadLocalData(), token); - com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestSuccess___int_java_lang_String(getThreadLocalData(), requestId, jtoken); - }]; + JAVA_OBJECT jatt = fromNSString(getThreadLocalData(), b64Att); + com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestationReady___int_java_lang_String(getThreadLocalData(), requestId, jatt); + }); + }]; + } else { + cn1AppAttestFail(requestId, nil, @"App Attest requires iOS 14+"); + } + POOL_END(); +#else + cn1AppAttestFail(requestId, nil, @"App Attest not available on this platform"); +#endif // !TARGET_OS_TV && !TARGET_OS_WATCH +} + +void com_codename1_impl_ios_IOSNative_appAttestGenerateAssertion___int_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT keyId, JAVA_OBJECT clientDataHashB64) { +#if !TARGET_OS_TV && !TARGET_OS_WATCH + POOL_BEGIN(); + if (@available(iOS 14.0, *)) { + DCAppAttestService *service = [DCAppAttestService sharedService]; + NSString *nsKeyId = (keyId == JAVA_NULL) ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG keyId); + NSString *nsHash = (clientDataHashB64 == JAVA_NULL) ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG clientDataHashB64); + NSData *clientDataHash = nsHash == nil ? nil + : [[NSData alloc] initWithBase64EncodedString:nsHash options:0]; +#ifndef CN1_USE_ARC + // initWithBase64EncodedString returns an owned object. The block below + // retains it for the duration of the call, so hand ownership to the pool + // rather than leaking one decoded hash per request. Guarded because ARC + // forbids an explicit autorelease, and this file builds both ways. + [clientDataHash autorelease]; +#endif + if (nsKeyId == nil || clientDataHash == nil) { + cn1AppAttestFail(requestId, nil, @"App Attest assertion missing key or hash"); + POOL_END(); + return; + } + [service generateAssertion:nsKeyId clientDataHash:clientDataHash completionHandler:^(NSData *assertion, NSError *assertErr) { + CN1_APP_ATTEST_ON_MAIN(^{ + if (assertErr != nil || assertion == nil) { + cn1AppAttestFail(requestId, assertErr, @"App Attest assertion failed"); + return; + } + NSString *b64 = [assertion base64EncodedStringWithOptions:0]; + JAVA_OBJECT jassert = fromNSString(getThreadLocalData(), b64); + com_codename1_impl_ios_IOSDeviceIntegrity_nativeAssertionReady___int_java_lang_String(getThreadLocalData(), requestId, jassert); + }); }]; } else { - JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), @"App Attest requires iOS 14+"); - com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_java_lang_String(getThreadLocalData(), requestId, jmsg); + cn1AppAttestFail(requestId, nil, @"App Attest requires iOS 14+"); } POOL_END(); #else - com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_java_lang_String(getThreadLocalData(), requestId, JAVA_NULL); + cn1AppAttestFail(requestId, nil, @"App Attest not available on this platform"); #endif // !TARGET_OS_TV && !TARGET_OS_WATCH } #else // CN1_USE_APP_ATTEST // App Attest not enabled (ios.appAttest build hint off): DeviceCheck.framework // is neither imported nor linked. Report unsupported / fail the request. -JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppAttestSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppAttestSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { return JAVA_FALSE; } -void com_codename1_impl_ios_IOSNative_requestAppAttestToken___int_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT nonce) { - com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_java_lang_String(getThreadLocalData(), requestId, JAVA_NULL); +void com_codename1_impl_ios_IOSNative_appAttestGenerateKey___int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId) { + com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_int_java_lang_String(getThreadLocalData(), requestId, -1, JAVA_NULL); +} + +void com_codename1_impl_ios_IOSNative_appAttestAttestKey___int_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT keyId, JAVA_OBJECT clientDataHashB64) { + com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_int_java_lang_String(getThreadLocalData(), requestId, -1, JAVA_NULL); +} + +void com_codename1_impl_ios_IOSNative_appAttestGenerateAssertion___int_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT keyId, JAVA_OBJECT clientDataHashB64) { + com_codename1_impl_ios_IOSDeviceIntegrity_nativeAttestError___int_int_java_lang_String(getThreadLocalData(), requestId, -1, JAVA_NULL); } #endif // CN1_USE_APP_ATTEST +// Jailbreak/instrumentation signals. Always compiled, independent of both +// CN1_USE_APP_ATTEST and CN1_DETECT_JAILBREAK, because DeviceIntegrity reports +// these at runtime without terminating the app. +JAVA_OBJECT com_codename1_impl_ios_IOSNative_iosJailbreakSignals___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + NSString *signals = cn1JailbreakSignals(); + JAVA_OBJECT result = fromNSString(CN1_THREAD_STATE_PASS_ARG (signals == nil ? @"" : signals)); + POOL_END(); + return result; +} + // --- CarPlay (CarPlay.framework) ------------------------------------------ // Gated by CN1_USE_CARPLAY: the builder uncomments the define, links // CarPlay.framework, injects the CarPlay scene into the Info.plist scene manifest diff --git a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m index 192987c3314..4e14f462453 100644 --- a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m +++ b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m @@ -166,11 +166,12 @@ -(void) connection: (NSURLConnection*)connection willSendRequestForAuthenticatio SecTrustRef trustRef = [[challenge protectionSpace] serverTrust]; SecTrustEvaluate(trustRef, NULL); NSMutableString* certs = [NSMutableString string]; - if (insecure) { - [[challenge sender] useCredential:[NSURLCredential credentialForTrust:[[challenge protectionSpace] serverTrust]] forAuthenticationChallenge:challenge]; - return; - } - //[connection cancel]; + // The chain is collected and offered to Java even for an insecure request. An + // insecure request asks us to accept a certificate the OS would reject -- a + // self-signed development server -- and that is a decision about OS trust + // evaluation, not a decision to stop looking. Returning here meant a host with + // enforced pins accepted any certificate at all as long as the request happened + // to be insecure, which is the opposite of what pinning is for. CFIndex count = SecTrustGetCertificateCount(trustRef); for (int i=0; i0) { [certs appendString:@","]; } + // CHAIN: opens each certificate's group; index 0 is the leaf. The + // Java side keeps this and the SPKI entry out of the legacy flat list + // so existing checkSSLCertificates overrides see unchanged data. + [certs appendFormat:@"CHAIN:%d,", i]; [certs appendString:@"SHA-256:"]; [certs appendString:[self getFingerprint256:certRef]]; [certs appendString:@",SHA1:"]; [certs appendString:[self getFingerprint:certRef]]; - + NSString* spki = [self getPublicKeyDigest:certRef]; + if (spki != nil) { + [certs appendString:@",SPKI-SHA-256:"]; + [certs appendString:spki]; + } } + // Released first, and only under manual retain/release. The delegate is entered once + // per challenge and a connection can face several -- a redirect to another host + // presents its own chain -- so overwriting the field leaked the previous string. The + // rest of this file already keeps its retains behind this guard; an unconditional one + // here does not compile under ARC at all. +#ifdef CN1_USE_ARC + sslCertificates = [NSString stringWithString:certs]; +#else + [sslCertificates release]; sslCertificates = [[NSString stringWithString:certs] retain]; - if (com_codename1_io_NetworkManager_checkCertificatesNativeCallback___int_R_boolean(CN1_THREAD_GET_STATE_PASS_ARG connectionId)) { - [challenge.sender performDefaultHandlingForAuthenticationChallenge:challenge]; - } else { +#endif + if (!com_codename1_io_NetworkManager_checkCertificatesNativeCallback___int_R_boolean(CN1_THREAD_GET_STATE_PASS_ARG connectionId)) { + // Java rejected the chain -- a per-request check or a guard pin mismatch. + // That veto applies to insecure requests too. [challenge.sender cancelAuthenticationChallenge:challenge]; + return; } + if (insecure) { + // Accepted despite whatever the OS thinks of the chain, which is what the + // caller asked for by setting it insecure. + [[challenge sender] useCredential:[NSURLCredential credentialForTrust:trustRef] forAuthenticationChallenge:challenge]; + return; + } + [challenge.sender performDefaultHandlingForAuthenticationChallenge:challenge]; } -(void)setConnectionId:(JAVA_INT)connId { @@ -197,9 +224,15 @@ -(void)setConnectionId:(JAVA_INT)connId { } - (NSString*) getFingerprint: (SecCertificateRef) cert { - NSData* certData = (__bridge NSData*) SecCertificateCopyData(cert); + // SecCertificateCopyData follows the Copy rule, so the result is owned here. + // The bridged cast alone leaked it on every handshake. + CFDataRef certData = SecCertificateCopyData(cert); + if (certData == NULL) { + return @""; + } unsigned char sha1Bytes[CC_SHA1_DIGEST_LENGTH]; - CC_SHA1(certData.bytes, (int)certData.length, sha1Bytes); + CC_SHA1(CFDataGetBytePtr(certData), (CC_LONG)CFDataGetLength(certData), sha1Bytes); + CFRelease(certData); NSMutableString *fingerprint = [NSMutableString stringWithCapacity:CC_SHA1_DIGEST_LENGTH * 3]; for (int i = 0; i < CC_SHA1_DIGEST_LENGTH; ++i) { [fingerprint appendFormat:@"%02x ", sha1Bytes[i]]; @@ -207,11 +240,136 @@ - (NSString*) getFingerprint: (SecCertificateRef) cert { return [fingerprint stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; } -- (NSString*) getFingerprint256: (SecCertificateRef) cert { - NSData* keyData = (__bridge NSData*) SecCertificateCopyData(cert); +/** + * Reads one DER TLV at `off`. Returns NO when the buffer is too short or the + * length encoding is one we do not handle (indefinite length, or a length that + * would not fit). `headerLen` is the tag plus length bytes, `totalLen` covers + * the whole TLV. + */ +static BOOL cn1ReadDerTlv(const uint8_t* buf, NSUInteger len, NSUInteger off, + uint8_t* tag, NSUInteger* headerLen, NSUInteger* totalLen) { + // off is walked forward by the caller, so an off past the end must not be able to + // make `off + 2` wrap back into range before the comparison. + if (off > len || len - off < 2) { + return NO; + } + *tag = buf[off]; + NSUInteger n = buf[off + 1]; + if (n < 0x80) { + *headerLen = 2; + // n < 0x80 and len - off >= 2, so this cannot overflow; the range check below + // is what decides whether the TLV actually fits. + *totalLen = 2 + n; + } else { + NSUInteger countBytes = n & 0x7f; + // 0x80 is indefinite length, which DER forbids; more than 4 length bytes + // would mean a certificate larger than anything we will ever be handed. + if (countBytes == 0 || countBytes > 4 || len - off - 2 < countBytes) { + return NO; + } + NSUInteger contentLen = 0; + for (NSUInteger i = 0; i < countBytes; i++) { + contentLen = (contentLen << 8) | buf[off + 2 + i]; + } + *headerLen = 2 + countBytes; + // Checked against what is actually left rather than by forming the sum first. + // A crafted length wraps NSUInteger, `off + *totalLen <= len` then passes, and + // the caller hands the wrapped value to CC_SHA256, which reads past the buffer. + // The certificate comes off the wire, so it is attacker-shaped by definition. + if (contentLen > len - off - *headerLen) { + return NO; + } + *totalLen = *headerLen + contentLen; + } + // Against what is left, not by forming off + *totalLen. That sum can wrap + // NSUInteger for a large off, and a wrapped sum compares small -- so the check + // that exists to stop an overread would be the thing that let it through. The + // subtraction cannot wrap: off <= len is this function's precondition and is + // re-established on the short-form path above. + return off <= len && *totalLen <= len - off; +} + +/** + * Base64 SHA-256 over the certificate's SubjectPublicKeyInfo, which is what a + * public-key pin is computed over. + * + * Walks the certificate DER rather than going through SecCertificateCopyKey + + * SecKeyCopyExternalRepresentation. That pair hands back the *raw* key, so + * reconstructing the SPKI means prepending a hand-maintained ASN.1 header chosen + * per key type and size -- a table that silently produces wrong digests for any + * key type it does not know about. The DER walk is algorithm-agnostic and + * matches `openssl x509 -pubkey | openssl pkey -pubin -outform der` exactly. + * + * Returns nil if the structure is not what we expect, in which case the caller + * simply omits the entry and pinning falls back to whole-certificate digests. + */ +- (NSString*) getPublicKeyDigest: (SecCertificateRef) cert { + // Plain CoreFoundation rather than a toll-free bridge cast: this file builds + // both with and without ARC, and the correct bridging annotation differs + // between the two. An explicit CFRelease is unambiguous in either mode. + CFDataRef certData = SecCertificateCopyData(cert); + if (certData == NULL) { + return nil; + } + const uint8_t* buf = CFDataGetBytePtr(certData); + NSUInteger len = (NSUInteger) CFDataGetLength(certData); + NSString* result = nil; + uint8_t tag; + NSUInteger headerLen, totalLen; + NSUInteger off; + int i; + + // Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature } + if (!cn1ReadDerTlv(buf, len, 0, &tag, &headerLen, &totalLen) || tag != 0x30) { + goto cleanup; + } + off = headerLen; + + // tbsCertificate ::= SEQUENCE { ... } + if (!cn1ReadDerTlv(buf, len, off, &tag, &headerLen, &totalLen) || tag != 0x30) { + goto cleanup; + } + off += headerLen; + + // [0] EXPLICIT Version is optional and absent in a v1 certificate. + if (!cn1ReadDerTlv(buf, len, off, &tag, &headerLen, &totalLen)) { + goto cleanup; + } + if (tag == 0xA0) { + off += totalLen; + } + // Skip serialNumber, signature, issuer, validity, subject. The next element + // is subjectPublicKeyInfo. + for (i = 0; i < 5; i++) { + if (!cn1ReadDerTlv(buf, len, off, &tag, &headerLen, &totalLen)) { + goto cleanup; + } + off += totalLen; + } + + if (cn1ReadDerTlv(buf, len, off, &tag, &headerLen, &totalLen) && tag == 0x30) { + uint8_t digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(buf + off, (CC_LONG) totalLen, digest); + NSData* digestData = [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH]; + result = [digestData base64EncodedStringWithOptions:0]; + } + +cleanup: + CFRelease(certData); + return result; +} + +- (NSString*) getFingerprint256: (SecCertificateRef) cert { + // Same ownership rule as getFingerprint: this was leaking one certificate's + // worth of data per digest, on every connection. + CFDataRef keyData = SecCertificateCopyData(cert); + if (keyData == NULL) { + return @""; + } uint8_t digest[CC_SHA256_DIGEST_LENGTH]={0}; - CC_SHA256(keyData.bytes, keyData.length, digest); + CC_SHA256(CFDataGetBytePtr(keyData), (CC_LONG)CFDataGetLength(keyData), digest); + CFRelease(keyData); NSData *out=[NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH]; NSString *hash=[out description]; hash = [hash stringByReplacingOccurrencesOfString:@" " withString:@""]; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java index 62ed7e02570..7119bc73ae1 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSBiometrics.java @@ -42,16 +42,28 @@ *

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

+ * stripping these (no Java caller exists), the static initializer invokes each + * once; they return immediately while that is happening.

*/ public final class IOSBiometrics extends Biometrics { + /** + * 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:

+ * + *
+ * cn1aa1:attest:<b64 keyId>:<b64 attestationObject>
+ * cn1aa1:assert:<b64 keyId>:<b64 assertion>:<b64 clientData>
+ * 
+ * + *

The client data for an assertion is a small JSON object carrying the + * server nonce, so the server can recompute the hash the assertion signed.

+ * + *

ParparVM note

+ * + *

The native side dispatches results back through the static callbacks + * below. As with {@link IOSBiometrics}, the static initializer invokes each 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 \