From ae40837827376dce6fb6688f7ebf858afcc65e00 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:46:55 +0700 Subject: [PATCH 001/110] Add cross-platform App Hardening (DexGuard-class), Enterprise-gated Adds a single hardening layer that renames classes/methods/fields, encrypts string constants and obfuscates control flow across every port (Android, iOS/ ParparVM, JavaScript, native desktop) from one bytecode transform, integrated with Crash Protection so obfuscated stack traces are still symbolicated. Engine (new maven/cn1-hardening, run as a forked process so it is single-sourced with the build daemon and carries its own ProGuard/ASM): demux the fat jar, rename with ProGuard using a prefixed dictionary that avoids the ParparVM NativeSymbolIndex culler pathology, encrypt LDC literals and static-final ConstantValue strings with a per-class decoder, opaque-predicate control flow on safe platforms, ParparVM mangle-collision guard, CheckClassAdapter verification, and a cross-platform mapping. Android keeps R8 as its sole renamer. Symbolication (new maven/cn1-retrace): ProGuard mapping parse/chain plus the ParparVM trace-string parser that java.lang.Throwable.getStackTrace() now mirrors on device, and a local retrace CLI. Crash payload gains rawStack/traceFormat/ mappingId/hardenLevel; PiiScrubber.scrubRawStack; cause-chain capture. Surface/entitlement: harden.* build hints, HardeningPreflight (fail the build on local/source targets, invalid level, on-device-debug), Executor.hardenSourceJar/ runBuild wiring, a read-only Hardening status API, and the App-Hardening developer guide chapter. Also fixes the invalid build_key literal, the BuildHintEditor grouped-Select values lookup, and the "obfuscates by default" overclaim in the security chapter. Tests: 25 unit tests across the two modules and the crash payload (full ProGuard round-trip, string round-trip + plaintext-absence, control-flow verification, mapping retrace, trace-format detection, pre-flight truth table). Co-Authored-By: Claude Opus 4.8 --- .../com/codename1/crash/CrashProtection.java | 25 +- .../codename1/crash/CrashReportPayload.java | 64 +++- .../src/com/codename1/crash/PiiScrubber.java | 17 + .../security/hardening/Hardening.java | 76 +++++ .../security/hardening/package-info.java | 34 ++ .../impl/javase/BuildHintEditor.java | 14 +- .../impl/javase/BuildHintSchemaDefaults.java | 52 +++ docs/developer-guide/App-Hardening.asciidoc | 125 +++++++ .../developer-guide/Crash-Protection.asciidoc | 9 +- docs/developer-guide/developer-guide.asciidoc | 2 + docs/developer-guide/security.asciidoc | 2 +- maven/cn1-hardening/pom.xml | 115 +++++++ .../codename1/hardening/BuiltinKeepRules.java | 108 ++++++ .../codename1/hardening/Cn1NameFactory.java | 111 ++++++ .../hardening/ControlFlowTransform.java | 178 ++++++++++ .../codename1/hardening/HardeningConfig.java | 212 ++++++++++++ .../codename1/hardening/HardeningEngine.java | 283 ++++++++++++++++ .../hardening/HardeningException.java | 34 ++ .../codename1/hardening/HardeningProfile.java | 78 +++++ .../codename1/hardening/HardeningRequest.java | 128 +++++++ .../codename1/hardening/HardeningResult.java | 126 +++++++ .../hardening/InputJarKeepScanner.java | 106 ++++++ .../com/codename1/hardening/JarDemuxer.java | 168 ++++++++++ .../java/com/codename1/hardening/Main.java | 171 ++++++++++ .../hardening/MangleCollisionCheck.java | 72 ++++ .../codename1/hardening/MappingWriter.java | 94 ++++++ .../codename1/hardening/OutputVerifier.java | 59 ++++ .../codename1/hardening/ProGuardRunner.java | 167 +++++++++ .../hardening/StringEncryptTransform.java | 316 ++++++++++++++++++ .../hardening/ControlFlowTransformTest.java | 73 ++++ .../hardening/HardeningEngineTest.java | Bin 0 -> 8782 bytes .../hardening/StringEncryptTransformTest.java | 111 ++++++ .../codename1/hardening/fixture/Helper.java | 30 ++ .../codename1/hardening/fixture/Secrets.java | 46 +++ maven/cn1-retrace/pom.xml | 66 ++++ .../java/com/codename1/retrace/Frame.java | 114 +++++++ .../com/codename1/retrace/MappingChain.java | 64 ++++ .../com/codename1/retrace/MappingFile.java | 192 +++++++++++ .../retrace/ParparVmTraceParser.java | 151 +++++++++ .../com/codename1/retrace/RetraceMain.java | 85 +++++ .../codename1/retrace/MappingFileTest.java | 80 +++++ .../retrace/ParparVmTraceParserTest.java | 103 ++++++ maven/codenameone-maven-plugin/pom.xml | 5 + .../builders/AndroidGradleBuilder.java | 18 +- .../java/com/codename1/builders/Executor.java | 248 +++++++++++++- .../com/codename1/builders/IPhoneBuilder.java | 5 + .../codename1/builders/JavaScriptBuilder.java | 5 + .../builders/LinuxNativeBuilder.java | 5 + .../builders/WindowsNativeBuilder.java | 5 + .../com/codename1/maven/CN1BuildMojo.java | 52 ++- .../codename1/maven/HardeningPreflight.java | 137 ++++++++ .../maven/HardeningPreflightTest.java | 70 ++++ maven/pom.xml | 11 +- .../crash/CrashReportPayloadTest.java | 83 +++++ vm/JavaAPI/src/java/lang/Throwable.java | 129 ++++++- 55 files changed, 4816 insertions(+), 18 deletions(-) create mode 100644 CodenameOne/src/com/codename1/security/hardening/Hardening.java create mode 100644 CodenameOne/src/com/codename1/security/hardening/package-info.java create mode 100644 docs/developer-guide/App-Hardening.asciidoc create mode 100644 maven/cn1-hardening/pom.xml create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningException.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningProfile.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningRequest.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningResult.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/MangleCollisionCheck.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java create mode 100644 maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java create mode 100644 maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java create mode 100644 maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java create mode 100644 maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Helper.java create mode 100644 maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Secrets.java create mode 100644 maven/cn1-retrace/pom.xml create mode 100644 maven/cn1-retrace/src/main/java/com/codename1/retrace/Frame.java create mode 100644 maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingChain.java create mode 100644 maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java create mode 100644 maven/cn1-retrace/src/main/java/com/codename1/retrace/ParparVmTraceParser.java create mode 100644 maven/cn1-retrace/src/main/java/com/codename1/retrace/RetraceMain.java create mode 100644 maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java create mode 100644 maven/cn1-retrace/src/test/java/com/codename1/retrace/ParparVmTraceParserTest.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/HardeningPreflight.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java create mode 100644 tests/core/test/com/codename1/crash/CrashReportPayloadTest.java diff --git a/CodenameOne/src/com/codename1/crash/CrashProtection.java b/CodenameOne/src/com/codename1/crash/CrashProtection.java index 9cd58e18991..5fb6c1291c4 100644 --- a/CodenameOne/src/com/codename1/crash/CrashProtection.java +++ b/CodenameOne/src/com/codename1/crash/CrashProtection.java @@ -149,7 +149,8 @@ public void exception(Throwable t) { "Process terminated by native fault", new ArrayList(0), null, - pendingNative); + pendingNative, + null); persistJson(synthetic.toJson()); } installed = true; @@ -240,8 +241,28 @@ static CrashReportPayload build(Throwable t) { String message = scrubber.scrubMessage(t.getMessage()); List frames = extractFrames(t); String nativeLog = safeNativeLog(); + String rawStack = scrubber.scrubRawStack(safeRawStack(t)); return new CrashReportPayload(newEventId(), exClass, message, - frames, nativeLog, null); + frames, nativeLog, null, rawStack); + } + + /// Renders the throwable (and its cause chain) as a pre-rendered stack + /// string via `printStackTrace`. This is the one trace API that behaves + /// identically on every port, and on the ParparVM C targets -- where + /// `getStackTrace()` may return the trace only as a formatted string -- + /// it is what keeps a Java crash readable, especially once obfuscated. + /// Swallows any failure: capturing a crash report must never itself crash. + private static String safeRawStack(Throwable t) { + try { + java.io.StringWriter sw = new java.io.StringWriter(); + java.io.PrintWriter pw = new java.io.PrintWriter(sw); + t.printStackTrace(pw); + pw.flush(); + String s = sw.toString(); + return s.length() == 0 ? null : s; + } catch (Throwable ignored) { + return null; + } } /// Pulls the platform log snapshot, swallowing any exception the diff --git a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java index d9903e91afb..a8defc4afbe 100644 --- a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java +++ b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java @@ -45,6 +45,18 @@ final class CrashReportPayload { /// signal handlers are usually compact (~64 frames * ~120 chars), /// but a corrupt stack can produce arbitrarily long output. static final int MAX_NATIVE_STACK_LEN = 16 * 1024; + /// Hard cap on the raw (pre-rendered) Java stack string. On the + /// ParparVM ports the trace arrives as a formatted string rather + /// than structured frames; on the JS port it is a JavaScript + /// engine stack. Mirrors {@link #MAX_NATIVE_STACK_LEN}. + static final int MAX_RAW_STACK_LEN = 16 * 1024; + + /// Trace-format discriminator values. Tells the server how to parse + /// {@link #rawStack} for this build. + static final String TRACE_STRUCTURED = "structured"; + static final String TRACE_PARPARVM = "parparvm-text"; + static final String TRACE_JS = "js-error"; + static final String TRACE_NONE = "none"; final String eventId; final String buildKey; @@ -56,6 +68,23 @@ final class CrashReportPayload { final String exceptionClass; final String messageScrubbed; final List frames; + /// The pre-rendered Java stack captured via `printStackTrace`, which + /// works identically on every port. On the ParparVM C targets this is + /// the only readable Java trace once obfuscated; the server parses it + /// with the mapping. `null` when no stack was available. + final String rawStack; + /// One of {@link #TRACE_STRUCTURED}, {@link #TRACE_PARPARVM}, + /// {@link #TRACE_JS} or {@link #TRACE_NONE}: how the server should read + /// {@link #rawStack}. Derived, never guessed. + final String traceFormat; + /// SHA-256 of the obfuscation mapping this build was hardened with, + /// stamped into the app so a report can be tied to the exact mapping. + /// Empty for unhardened builds. + final String mappingId; + /// The hardening level the build shipped with (`off` / `standard` / + /// `aggressive` / `paranoid`); lets the server answer "why can't I + /// retrace this?" with the honest reason. + final String hardenLevel; /// Recent platform-log output captured at crash time. Provides /// context the Java stack frame alone can't (NSLog/os_log on iOS, /// logcat on Android). `null` if the platform has no readable log @@ -71,13 +100,15 @@ final class CrashReportPayload { CrashReportPayload(String eventId, String exceptionClass, String messageScrubbed, List frames, - String nativeLog, String nativeStack) { + String nativeLog, String nativeStack, String rawStack) { this.eventId = eventId; this.exceptionClass = exceptionClass; this.messageScrubbed = trim(messageScrubbed, MAX_MESSAGE_LEN); this.frames = capFrames(frames); this.nativeLog = trim(nativeLog, MAX_NATIVE_LOG_LEN); this.nativeStack = trim(nativeStack, MAX_NATIVE_STACK_LEN); + this.rawStack = trim(rawStack, MAX_RAW_STACK_LEN); + this.traceFormat = deriveTraceFormat(this.frames, this.rawStack); Display d = Display.getInstance(); this.buildKey = d.getProperty("build_key", ""); this.packageName = d.getProperty("package_name", ""); @@ -85,11 +116,38 @@ final class CrashReportPayload { this.appVersion = d.getProperty("AppVersion", ""); this.platform = d.getPlatformName(); this.osVersion = d.getProperty("OSVer", ""); + this.mappingId = d.getProperty("cn1.mappingId", ""); + this.hardenLevel = d.getProperty("cn1.hardenLevel", ""); Locale loc = Locale.getDefault(); this.locale = loc == null ? "" : loc.toString(); this.clientTs = System.currentTimeMillis(); } + /// Derives the trace format from what we actually have. Structured + /// frames win; otherwise a raw stack whose first frame line begins + /// `" at "` is the ParparVM text format, and anything else with a + /// body is a JavaScript engine stack. Never a guess -- the server + /// relies on this to pick a parser. + private static String deriveTraceFormat(List frames, String rawStack) { + if (frames != null && !frames.isEmpty()) { + return TRACE_STRUCTURED; + } + if (rawStack == null || rawStack.length() == 0) { + return TRACE_NONE; + } + // A ParparVM frame line is exactly " at .:"; a V8/JS + // frame carries a '(' or a URL. Look at the first " at " line. + int at = rawStack.indexOf(" at "); + if (at >= 0) { + int lineEnd = rawStack.indexOf('\n', at); + String body = lineEnd < 0 ? rawStack.substring(at + 7) : rawStack.substring(at + 7, lineEnd); + if (body.indexOf('(') < 0 && body.indexOf('/') < 0 && body.indexOf('@') < 0) { + return TRACE_PARPARVM; + } + } + return TRACE_JS; + } + static final class Frame { final String className; final String methodName; @@ -124,6 +182,10 @@ String toJson() { appendString(b, "locale", locale, false); appendString(b, "nativeLog", nativeLog, false); appendString(b, "nativeStack", nativeStack, false); + appendString(b, "rawStack", rawStack, false); + appendString(b, "traceFormat", traceFormat, false); + appendString(b, "mappingId", mappingId, false); + appendString(b, "hardenLevel", hardenLevel, false); b.append(",\"clientTs\":").append(clientTs); b.append(",\"frames\":["); for (int i = 0; i < frames.size(); i++) { diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index f8b359b605c..abce5094b8b 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -78,6 +78,23 @@ public String scrubFrame(String className, String methodName) { return methodName; } + /// Scrubs a pre-rendered stack string. On the ParparVM ports the whole + /// Java trace arrives as one string rather than structured frames, so a + /// stricter application can override this to redact aggressively. The + /// default applies the same message scrubbing (emails, long digit runs), + /// which is harmless on class/method/line text. + /// + /// #### Parameters + /// + /// - `rawStack`: the pre-rendered stack string; may be `null`. + /// + /// #### Returns + /// + /// the scrubbed stack string, or `null` if `rawStack` is `null`. + public String scrubRawStack(String rawStack) { + return scrubMessage(rawStack); + } + /// Replaces all occurrences of an email-like substring with the form /// `***@`. Local parts shorter than three /// characters are not padded; the original prefix is preserved and diff --git a/CodenameOne/src/com/codename1/security/hardening/Hardening.java b/CodenameOne/src/com/codename1/security/hardening/Hardening.java new file mode 100644 index 00000000000..83c6b8f8be2 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/hardening/Hardening.java @@ -0,0 +1,76 @@ +/* + * 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.hardening; + +import com.codename1.ui.Display; + +/** + * Read-only reporting of whether this build was hardened, and with what. + * + *

App Hardening is an Enterprise, build-server transform: it renames classes, + * encrypts strings and obfuscates control flow in the shipped binary across every + * port. This class does not perform any of that -- it only reports what the build + * server stamped into the app, so app code (and the crash reporter) can tell an + * honestly-hardened build apart from an unhardened one such as a local or + * simulator build. + * + *

The values are stamped as display properties by the build; in the simulator + * and in local builds they report {@code false} / {@code "off"}, because those are + * never obfuscated. + * + * @author Shai Almog + */ +public final class Hardening { + + private Hardening() { + } + + /** + * Whether the shipped binary was hardened. Always {@code false} in the simulator and in + * local or source-project builds, which are never obfuscated. + * + * @return true if the build server applied hardening to this build + */ + public static boolean isHardened() { + return "true".equals(Display.getInstance().getProperty("cn1.hardened", "false")); + } + + /** + * The hardening level the build shipped with. + * + * @return one of {@code "off"}, {@code "standard"}, {@code "aggressive"}, {@code "paranoid"} + */ + public static String getLevel() { + return Display.getInstance().getProperty("cn1.hardenLevel", "off"); + } + + /** + * The id of the obfuscation mapping this build was hardened with, matching the mapping the + * build server retained for crash symbolication. Empty when the build was not hardened. + * + * @return the mapping id, or an empty string + */ + public static String getMappingId() { + return Display.getInstance().getProperty("cn1.mappingId", ""); + } +} diff --git a/CodenameOne/src/com/codename1/security/hardening/package-info.java b/CodenameOne/src/com/codename1/security/hardening/package-info.java new file mode 100644 index 00000000000..17ecf7274b8 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/hardening/package-info.java @@ -0,0 +1,34 @@ +/* + * 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. + */ + +/** + * Read-only reporting of Codename One App Hardening status for the current build. + * + *

App Hardening is an Enterprise, build-server transform that renames classes, + * encrypts strings and obfuscates control flow in the shipped binary across every + * port, integrated with Crash Protection so obfuscated stack traces are still + * symbolicated. The engine runs on the build server; this package only lets app + * code observe whether the current build was hardened. See the App Hardening + * chapter of the developer guide. + */ +package com.codename1.security.hardening; diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintEditor.java b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintEditor.java index ff556f3d86d..e25d3b6c4ee 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintEditor.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintEditor.java @@ -164,7 +164,19 @@ private void loadBuildHintModels() { model.type = BuildHintValueType.Checkbox; } else if ("select".equalsIgnoreCase(propertyValue)) { model.type = BuildHintValueType.Select; - String valuesString = System.getProperty("codename1.arg.{{ "+model.name+" }}.values"); + // Resolve the sibling ".values" property using the *exact* brace content of + // the ".type" property we're processing. model.name has already been stripped + // of its group prefix (a grouped hint registered as {{#group#name}} leaves + // model.name == "name"), and the registration side uses no spaces inside the + // braces, so the old "{{ "+model.name+" }}" lookup missed every grouped Select + // and every space-sensitive key. Deriving the key from propName keeps the two + // in lockstep regardless of grouping or spacing. Fall back to the historical + // spaced form for any cn1lib that registered its values key that way. + String valuesKey = propName.substring(0, propName.indexOf("}}.")+3) + "values"; + String valuesString = System.getProperty(valuesKey); + if (valuesString == null) { + valuesString = System.getProperty("codename1.arg.{{ "+model.name+" }}.values"); + } if (valuesString != null) { String separator = ""+valuesString.charAt(valuesString.length()-1); ArrayList values = new ArrayList(); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java index 6b35cb182ae..8e5e2b8012a 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java @@ -54,7 +54,59 @@ final class BuildHintSchemaDefaults { private BuildHintSchemaDefaults() { } + /** + * App Hardening (Enterprise). Grouped Select hints; note these rely on the grouped-Select + * value lookup in BuildHintEditor being keyed by the exact brace content (see the fix there). + */ + private static void registerHardening() { + set("{{@hardening}}.label", "App Hardening (Enterprise)"); + set("{{@hardening}}.description", + "Build-server transforms that make the shipped binary harder to reverse " + + "engineer -- class/method/field renaming, string encryption and control-flow " + + "obfuscation -- applied across every port, integrated with Crash Protection so " + + "obfuscated stack traces are still symbolicated. Runs on the Codename One build " + + "server only: the simulator is never obfuscated and a local or source-project " + + "build is not hardened. Requires an Enterprise subscription; a build that asks " + + "for it without one fails rather than shipping an unhardened binary."); + + set("{{#hardening#harden.level}}.label", "Hardening level"); + set("{{#hardening#harden.level}}.type", "Select"); + set("{{#hardening#harden.level}}.values", "off,standard,aggressive,paranoid"); + set("{{#hardening#harden.level}}.description", + "off = no hardening. standard = renaming + constant-string encryption. " + + "aggressive = + all-string encryption + control flow. paranoid = + opaque " + + "predicates + reflective-name hiding. Higher levels cost build time, size and " + + "startup; measure before choosing paranoid."); + + set("{{#hardening#harden.strings}}.label", "String encryption"); + set("{{#hardening#harden.strings}}.type", "Select"); + set("{{#hardening#harden.strings}}.values", "off,constants,all"); + set("{{#hardening#harden.strings}}.description", + "Override string encryption independently of the level."); + + set("{{#hardening#harden.controlFlow}}.label", "Control-flow obfuscation"); + set("{{#hardening#harden.controlFlow}}.type", "Select"); + set("{{#hardening#harden.controlFlow}}.values", "off,on"); + set("{{#hardening#harden.controlFlow}}.description", + "Override control-flow obfuscation. Applied on Android and desktop only; left off " + + "the ParparVM native ports where it fights the translator's optimizer."); + + set("{{#hardening#harden.keep}}.label", "Keep rules"); + set("{{#hardening#harden.keep}}.type", "TextArea"); + set("{{#hardening#harden.keep}}.description", + "ProGuard-syntax keep rules for classes resolved by name at runtime that the " + + "automatic analysis can't see. Same syntax as android.proguardKeep."); + + set("{{#hardening#harden.allowUnhardenedLocalBuild}}.label", "Allow unhardened local build"); + set("{{#hardening#harden.allowUnhardenedLocalBuild}}.type", "Select"); + set("{{#hardening#harden.allowUnhardenedLocalBuild}}.values", "false,true"); + set("{{#hardening#harden.allowUnhardenedLocalBuild}}.description", + "Let a local or source-project target build unhardened instead of failing the " + + "pre-flight. The output is NOT hardened."); + } + static void register() { + registerHardening(); // Group. set("{{@nativeTheme}}.label", "Native Theme"); set("{{@nativeTheme}}.description", diff --git a/docs/developer-guide/App-Hardening.asciidoc b/docs/developer-guide/App-Hardening.asciidoc new file mode 100644 index 00000000000..81451cb857f --- /dev/null +++ b/docs/developer-guide/App-Hardening.asciidoc @@ -0,0 +1,125 @@ +[[app-hardening]] +== App Hardening + +Every shipped app is a program someone else can read. The class and method names survive into the binary, the string constants sit in plain sight, and the control flow is exactly what you wrote. On Android a release build is run through R8, which renames the Java names -- but on the other ports even that much isn't true: the iOS and native builds translate your code to C through ParparVM and the class names, method names and every string literal end up in the binary as readable text. + +App Hardening closes that gap across *all* the ports from one place. It renames classes, methods and fields; encrypts string constants so they are not present as plaintext in the binary; and obfuscates control flow -- and it does this to the merged application before each platform build, so Android, iOS, JavaScript and the native desktop targets are all covered by one transform and one mapping. + +WARNING: App Hardening doesn't make an app impossible to reverse engineer, and no product does. What it changes is the cost: turning a class named `LoginController` with a string `"invalid password"` into a class named `zqab` with an encrypted constant moves the first afternoon of a reverse-engineering effort from "read it" to "reconstruct it." Be careful not to promise more than that, internally or in marketing. It is one layer; pair it with <> so the statement your backend trusts is made by hardware the attacker doesn't control. + +This is an *Enterprise* feature. A build that asks for it without an Enterprise subscription *fails with an explanation* rather than quietly producing an unhardened binary -- a binary that looks protected but isn't is worse than one that never claimed to be. + +=== What it changes, per port + +The transform runs on the merged application jar, at the bytecode level, before any platform-specific build step. That is why one implementation reaches every port: iOS/ParparVM translates the already-hardened bytecode to C (so the C constant pool never sees the plaintext), R8 consumes already-hardened classes on Android, and the JavaScript backend minifies already-hardened classes. + +[cols="2,1,4"] +|=== +|Transform |Ports |Notes + +|Class / method / field renaming +|iOS, JavaScript, Windows, Linux, desktop +|Android keeps R8 as its sole renamer -- renaming twice would only force a pointless mapping composition. The renamer uses a distinctive name dictionary on purpose: short names such as `a`/`b` are substrings of the ParparVM native identifiers and would defeat the translator's dead-code elimination, so a six-character prefixed name is used instead. + +|String constant encryption +|iOS, Android, Windows, Linux, desktop +|Both channels are handled: the `LDC` string literals in method bodies *and* the `ConstantValue` attribute of `static final String` fields, which would otherwise leak into the ParparVM C constant pool even after the readers were encrypted. The decoder is synthesized into each class with a per-class key, so there is no single framework method to hook. Not applied on the JavaScript port, where a string literal can be a live reference into the native bridge. + +|Control-flow obfuscation +|Android, desktop +|An opaque predicate guarded by a value the decompiler can't fold. Left off the ParparVM native ports, where it fights the translator's optimizer and the arithmetic reducer, and off JavaScript, where it inflates the bundle. Never applied to constructors. +|=== + +=== Turning it on + +Add the level to your `codenameone_settings.properties`: + +[source] +---- +codename1.arg.harden.level=standard +---- + +[cols="2,1,4"] +|=== +|Build hint |Default |Description + +|`harden.level` +|`off` +|Master switch: `off`, `standard`, `aggressive` or `paranoid`. An unrecognized value *fails the build* rather than being treated as `off`. + +|`harden.rename` +|_(level)_ +|Override renaming on/off independently of the level. + +|`harden.strings` +|_(level)_ +|`off`, `constants`, or `all`. + +|`harden.controlFlow` +|_(level)_ +|Override control-flow obfuscation on/off. + +|`harden.keep` +|_(none)_ +|Keep rules in ProGuard syntax (newline- or `;`-separated), for classes resolved by name at runtime that the automatic analysis can't see. Same syntax as `android.proguardKeep`, so existing rules port directly. + +|`harden..enabled` +|`true` +|Per-port opt-out (`and`, `ios`, `mac`, `linux`, `win`, `javascript`, `javase`), mirroring the Crash Protection opt-outs. Only an explicit `false` disables a platform. + +|`harden.requireSymbolUpload` +|`true` +|Fail the build if the symbol/mapping upload fails. Losing a hardened build's mapping makes its crash reports permanently unreadable, so this defaults to strict. + +|`harden.allowUnhardenedLocalBuild` +|`false` +|Escape hatch: allow a local or source-project target to build unhardened instead of failing the pre-flight. + +|`harden.seed` +|_(build id)_ +|Fixes the renaming seed for a reproducible mapping across rebuilds; leave unset for a fresh mapping per build. +|=== + +=== Levels + +The level is the one decision most projects need to make. The individual switches are overrides on top of it. + +[cols="2,1,1,1,1"] +|=== +| |`off` |`standard` |`aggressive` |`paranoid` + +|Class/method/field renaming |-- |yes |yes |yes +|String encryption |-- |constants |all |all + reflective names +|Control-flow obfuscation |-- |-- |yes |yes + opaque predicates +|Debug / line-number stripping |-- |yes |yes |yes +|Symbol/mapping upload |-- |required |required |required +|=== + +Higher levels cost build time, a little binary size and a little startup time. Measure the trade-off for your own app before committing to `paranoid`; the honest number for your codebase is the one that matters, not a headline figure. + +=== Keeping what must not be renamed + +Renaming is safe for code the compiler and runtime resolve by symbol, and unsafe for code resolved by *name*. The engine keeps the obvious cases automatically -- the main class and its generated stub, native-interface implementations and their peers, `enum` `values()`/`valueOf()`, serialization members, and any class named by a string constant that appears in the jar (a `Class.forName` target, a GUI-builder reference). + +Two categories deserve special attention: + +* *Name-bound persistence.* A `PropertyBusinessObject`'s property names *are* the JSON keys and the database column names. Renaming them would silently change the on-disk schema and the wire format, which corrupts data on the next app upgrade rather than throwing. The engine keeps these member names automatically. +* *Runtime reflection you construct dynamically.* If you build a class name at runtime from pieces the analysis can't follow, add a `harden.keep` rule for it. + +When you enable a hardening level, review your app for these name-bound patterns before the first hardened cloud build: reflective `Class.forName` targets built from dynamic strings, GUI-builder resources that reference components by class name, and any framework registration that resolves an implementation by name. The automatic keep analysis catches the common cases; a `harden.keep` rule covers anything it can't see. + +=== Crash reports from a hardened build + +Hardening and Crash Protection are designed together. The build server retains the obfuscation mapping and symbolicates incoming reports against it, so a crash from a hardened build still lands as a readable, correctly-lined GitHub issue -- see <>. Two consequences follow: a report whose mapping has aged out of retention can no longer be retraced, and a locally hardened build (whose mapping never reached the server) can't be symbolicated at all, which is why the pre-flight refuses to harden a local target by default. + +=== Local and source builds are not hardened + +Hardening runs on the Codename One build server. A local or source-project target (`*-source`, `local-*`) never reaches the server, so its output is not hardened; the build fails the pre-flight rather than mislead you, unless you set `harden.allowUnhardenedLocalBuild=true`. The simulator is never obfuscated either -- it runs your `target/classes` directly. App code can read `com.codename1.security.hardening.Hardening.isHardened()` to tell an honestly-hardened build from one of these. + +=== Hardening and App Shield + +These are two different Enterprise features and you can use either or both. App Hardening protects the *binary* -- it raises the cost of reading and modifying the app on the device. App Shield protects the *app-to-server relationship* -- it gives your backend a cryptographically verifiable statement that a request came from a genuine, unmodified app on an uncompromised device. Hardening makes an attacker work harder to patch out App Shield's checks; App Shield makes patching them out insufficient, because the statement your backend trusts is made by a party the attacker doesn't control. + +=== What this does not protect against + +Hardening raises the cost of static analysis and casual tampering. It does not stop a determined attacker with time, it does not protect a secret you embed in the client (put it on your server -- see the security chapter), and it is not a substitute for server-side authorization. Treat it as one layer of defense in depth, not a guarantee. diff --git a/docs/developer-guide/Crash-Protection.asciidoc b/docs/developer-guide/Crash-Protection.asciidoc index 44c892a9440..767d3de0e4d 100644 --- a/docs/developer-guide/Crash-Protection.asciidoc +++ b/docs/developer-guide/Crash-Protection.asciidoc @@ -82,9 +82,16 @@ The Codename One crash-protection client runs incoming messages through a scrubb - `exceptionClass` - `messageScrubbed` -- *scrubbed* - `frames[]` -- class / method / file / line / `native` flag per frame -- `deviceMeta` -- free memory + locale only; not device IDs +- `rawStack` -- the pre-rendered Java stack (via `printStackTrace`, including the cause chain). On the ParparVM ports this is the readable Java trace, since `getStackTrace()` there yields a formatted string rather than structured frames +- `traceFormat` -- how the server should read `rawStack`: `structured`, `parparvm-text`, `js-error`, or `none`. Derived, never guessed +- `mappingId` -- the id of the obfuscation mapping a hardened build shipped with, so a report ties to the exact mapping even if a rebuild reused the build key; empty for unhardened builds +- `hardenLevel` -- the hardening level of the build, so the server can explain an unretraceable report honestly - `clientTs` +=== Crash reports from a hardened build + +When a build is hardened (see <>), the build server retains the cross-platform obfuscation mapping and symbolicates incoming reports against it, so a hardened build's crashes still land as readable, correctly-lined issues. Two things follow from how the mapping is retained: a report whose mapping has aged out of retention can no longer be retraced, and a locally hardened build -- whose mapping never reached the server -- can't be symbolicated at all. On the ParparVM ports (iOS, tvOS, watchOS, mac-native, Windows, Linux) the Java trace arrives as `rawStack` in the `parparvm-text` format and is parsed server-side; on the JavaScript port it arrives as a JavaScript engine stack (`js-error`) and is symbolicated best-effort through the source map. + ==== Default scrubber rules `PiiScrubber` applies these by default: diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index f93450b6479..996ebc217b2 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -133,6 +133,8 @@ include::security.asciidoc[] include::App-Shield.asciidoc[] +include::App-Hardening.asciidoc[] + include::Biometric-Authentication.asciidoc[] include::Authentication-And-Identity.asciidoc[] diff --git a/docs/developer-guide/security.asciidoc b/docs/developer-guide/security.asciidoc index c03910e3b43..f3194275190 100644 --- a/docs/developer-guide/security.asciidoc +++ b/docs/developer-guide/security.asciidoc @@ -13,7 +13,7 @@ For most intents and purposes this will be enough, unless you're specifically co The restrictions laid on apps are here to make them extra secure and on top of that Codename One lays a few big advantages in security: - Codename One code is compiled (unlike for example, PhoneGap/Cordova) -- Codename One obfuscates by default which makes the binaries harder to reverse engineer +- Android release builds are obfuscated by default with R8, which makes the binaries harder to reverse engineer. On the other ports the shipped code is compiled or translated (the iOS/native ports go through ParparVM to C) rather than renamed. Enterprise accounts can turn on cross-platform hardening -- name obfuscation, string encryption and more -- for every port; see the App Hardening chapter - Codename One compiles the UI to native code too which means typical reverse engineering code will have a harder time following - Codename One disables debug flags so a hacker won't be able to debug your production app on the device diff --git a/maven/cn1-hardening/pom.xml b/maven/cn1-hardening/pom.xml new file mode 100644 index 00000000000..c0888487f04 --- /dev/null +++ b/maven/cn1-hardening/pom.xml @@ -0,0 +1,115 @@ + + + + + com.codenameone + codenameone + 8.0-SNAPSHOT + + 4.0.0 + + cn1-hardening + 8.0-SNAPSHOT + jar + cn1-hardening + + Cross-platform application hardening engine for Codename One (Enterprise). + Takes the merged application jar and produces a renamed, string-encrypted + jar plus a ProGuard-format mapping that covers every port, so a single + transform hardens Android, iOS/ParparVM, JavaScript and the native desktop + targets. Runs as a forked process behind a command-line contract so it is + single-sourced across the codenameone-maven-plugin and the cloud build + daemon and cannot drift between them. + + + + + 7.3.2 + 9.8 + + + + + com.guardsquare + proguard-base + ${proguard.version} + + + org.ow2.asm + asm + ${asm.version} + + + org.ow2.asm + asm-tree + ${asm.version} + + + org.ow2.asm + asm-commons + ${asm.version} + + + org.ow2.asm + asm-util + ${asm.version} + + + junit + junit + test + + + + + + + maven-compiler-plugin + + 1.8 + 1.8 + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + + + + com.codename1.hardening.Main + + + + false + true + standalone + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + + + + + + + + + + diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java new file mode 100644 index 00000000000..6cdbc192f79 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java @@ -0,0 +1,108 @@ +/* + * 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.hardening; + +import java.util.ArrayList; +import java.util.List; + +/** + * Tier 1 keep rules: the fixed set that must survive on every app, independent of + * what the input jar contains. These exist because the builders generate stub + * source after hardening that names classes literally and then compiles + * it against the hardened classes -- the main class and its {@code Stub}, the + * generated router and annotation bootstraps, native-interface peers, and the + * usual reflective seams (enums, serialization, {@code native} members). + */ +public final class BuiltinKeepRules { + + /** The seven generated bootstrap classes the builders splice into the app stub. */ + private static final String[] BOOTSTRAPS = { + "MapperBootstrap", "BinderBootstrap", "DaoBootstrap", "RestClientBootstrap", + "ProtoBootstrap", "GrpcClientBootstrap", "GraphQLClientBootstrap" + }; + + private BuiltinKeepRules() { + } + + /** + * The complete Tier-1 rule block for the main app class. Shared verbatim with R8 on Android + * via {@link #forR8(String)} so the same app-level seams are described once for both renamers. + */ + public static List rules(String mainClass) { + List r = new ArrayList(); + if (mainClass != null && !mainClass.isEmpty()) { + r.add("-keep class " + mainClass + " { *; }"); + r.add("-keep class " + mainClass + "Stub { *; }"); + } + // Generated registries the stub instantiates by literal name. + r.add("-keep class com.codename1.router.generated.Routes { *; }"); + for (String b : BOOTSTRAPS) { + r.add("-keep class cn1app." + b + " { *; }"); + } + // Native interfaces are matched to their implementation by name. + r.add("-keep class * implements com.codename1.system.NativeInterface { *; }"); + r.add("-keep class **Impl { *; }"); + r.add("-keep class **Stub { *; }"); + // JNI/native method names must not move. + r.add("-keepclasseswithmembernames,includedescriptorclasses class * { native ; }"); + // Reflective seams the JDK itself relies on. + r.add("-keepclassmembers enum * { public static **[] values(); public static ** valueOf(java.lang.String); }"); + r.add("-keepclassmembers class * implements java.io.Serializable { " + + "static final long serialVersionUID; " + + "private void writeObject(java.io.ObjectOutputStream); " + + "private void readObject(java.io.ObjectInputStream); " + + "java.lang.Object writeReplace(); java.lang.Object readResolve(); }"); + r.add("-keep class * implements java.io.Externalizable { *; }"); + // PropertyBusinessObject property/field names ARE the JSON/ORM column names; + // renaming them silently changes the on-disk schema and the wire format, which + // corrupts data on the next app upgrade rather than throwing. Keep the member + // names (the class itself may still be renamed). + r.add("-keepclassmembernames class * implements com.codename1.properties.PropertyBusinessObject { *; }"); + return r; + } + + /** The global ProGuard flags the engine always sets. Kept here so the Android R8 export can share them. */ + public static List flags() { + List r = new ArrayList(); + // ParparVM culls and R8 shrinks; shrinking/optimizing here only risks + // "works in debug, NPEs in release". Rename and encrypt, nothing else. + r.add("-dontshrink"); + r.add("-dontoptimize"); + r.add("-dontpreverify"); + // Class files are written to a directory and builds run on a case-insensitive + // filesystem, so mixed-case names would collide. + r.add("-dontusemixedcaseclassnames"); + r.add("-dontnote"); + r.add("-dontwarn"); + r.add("-keepattributes Exceptions,InnerClasses,Signature,EnclosingMethod,*Annotation*"); + return r; + } + + /** + * The app-level keep rules only, in R8/ProGuard syntax, so Android's generated {@code proguard.cfg} + * can append them. The flags are not included -- Android manages its own R8 flags. + */ + public static List forR8(String mainClass) { + return rules(mainClass); + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java new file mode 100644 index 00000000000..1a63938099c --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.hardening; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.Charset; + +/** + * Generates the obfuscation dictionaries ProGuard renames from. Every generated + * name starts with a distinctive prefix, is at least six characters, is lower-case + * and never contains an underscore. + * + *

This is not cosmetic -- it is the fix for a ParparVM build-killer. The + * translator decides whether a class is reachable from native code by asking + * whether the class name is a substring of any identifier in the native + * sources ({@code BytecodeMethod.isMethodUsedByNative} / + * {@code NativeSymbolIndex}). ProGuard's default names ({@code a}, {@code b}, + * {@code aa}) are substrings of almost every native identifier, so with default + * names nothing is ever culled: the iOS/Windows/Linux/JS binaries balloon and the + * translator runs out of heap. A {@code zq}-prefixed six-plus-character name is a + * substring of nothing in the native sources, so culling works normally. + * + *

The dictionary is also sized so ProGuard never exhausts it and falls back to + * its own short-name generator, which would reintroduce the pathology for the + * overflow names. + */ +public final class Cn1NameFactory { + + /** + * The name prefix. Chosen so it cannot occur inside a CN1 native identifier + * (which are {@code package_Class_method}-mangled Java names and C runtime + * symbols); ASCII, lower-case, underscore-free. + */ + static final String PREFIX = "zq"; + + private static final char[] ALPHABET = "abcdefghijklmnopqrstuvwxyz".toCharArray(); + private static final int MIN_BODY_WIDTH = 4; // PREFIX(2) + 4 => 6-char minimum + + private Cn1NameFactory() { + } + + /** The nth distinctive name: {@code zq} + a fixed-width base-26 body, e.g. {@code zqaaaa}. */ + public static String word(int index) { + if (index < 0) { + throw new IllegalArgumentException("index < 0"); + } + StringBuilder body = new StringBuilder(); + int n = index; + do { + body.append(ALPHABET[n % 26]); + n /= 26; + } while (n > 0); + while (body.length() < MIN_BODY_WIDTH) { + body.append('a'); + } + return PREFIX + body.reverse().toString(); + } + + /** + * Writes a dictionary of {@code count} distinct names to {@code out}. A build feeds the same + * file as the class, member and package obfuscation dictionary; sizing it above the number of + * names any one scope needs guarantees ProGuard never falls back to short names. + */ + public static void writeDictionary(File out, int count) throws IOException { + int safeCount = Math.max(count, 1); + FileOutputStream fo = new FileOutputStream(out); + try { + Writer w = new BufferedWriter(new OutputStreamWriter(fo, Charset.forName("UTF-8"))); + for (int i = 0; i < safeCount; i++) { + w.write(word(i)); + w.write('\n'); + } + w.flush(); + } finally { + fo.close(); + } + } + + /** + * The dictionary size to use for a jar with {@code classCount} classes: comfortably above the + * global class-naming scope (the largest single scope) with a floor for small apps. + */ + public static int dictionarySizeFor(int classCount) { + return Math.max(50000, classCount * 4); + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java new file mode 100644 index 00000000000..a569cbb3697 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java @@ -0,0 +1,178 @@ +/* + * 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.hardening; + +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.tree.FieldNode; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.InsnNode; +import org.objectweb.asm.tree.JumpInsnNode; +import org.objectweb.asm.tree.LabelNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.TypeInsnNode; + +/** + * Inserts an opaque predicate at the entry of each real method: a branch guarded by + * a value the renamer/decompiler cannot prove, so the disassembly grows a dead + * arm that a reader must rule out by hand. The guard reads a synthetic per-class + * field initialized at class-load from a non-constant runtime value + * ({@code System.getProperty("java.home").length()}, always positive), so neither + * javac, R8 nor a decompiler can fold it away. + * + *

This is deliberately conservative -- an entry guard, not control-flow + * flattening. Flattening fights the ParparVM devirtualizer and the arithmetic + * reducer, breaks the fused-constructor shape analysis, and must never touch + * {@code } or {@code @Fused} classes; the engine keeps it off the native + * ports entirely (see {@link HardeningEngine}). The behaviour is a strict no-op: + * the dead arm only ever throws and is never reached. + */ +public final class ControlFlowTransform { + + static final String GUARD_FIELD = "zq$cf"; + static final String GUARD_DESC = "I"; + + private int guardedMethods; + + public int getGuardedMethods() { + return guardedMethods; + } + + public byte[] transform(byte[] classBytes) { + ClassNode cn = new ClassNode(); + new ClassReader(classBytes).accept(cn, ClassReader.SKIP_FRAMES); + + if ((cn.access & Opcodes.ACC_INTERFACE) != 0) { + return classBytes; + } + if (hasGuardField(cn)) { + return classBytes; + } + + boolean changed = false; + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if (!isGuardable(mn)) { + continue; + } + prependGuard(cn, mn); + guardedMethods++; + changed = true; + } + } + if (!changed) { + return classBytes; + } + + addGuardField(cn); + initGuardField(cn); + + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES); + cn.accept(cw); + return cw.toByteArray(); + } + + private boolean isGuardable(MethodNode mn) { + if (mn.instructions == null || mn.instructions.size() == 0) { + return false; + } + if ((mn.access & (Opcodes.ACC_ABSTRACT | Opcodes.ACC_NATIVE)) != 0) { + return false; + } + // A guard before super()/this() in a constructor, or before a static field + // set in , is unsafe. Leave both alone. + if ("".equals(mn.name) || "".equals(mn.name)) { + return false; + } + return true; + } + + private void prependGuard(ClassNode cn, MethodNode mn) { + InsnList pre = new InsnList(); + LabelNode ok = new LabelNode(); + pre.add(new FieldInsnNode(Opcodes.GETSTATIC, cn.name, GUARD_FIELD, GUARD_DESC)); + // if (zq$cf > 0) goto ok; -- always taken at runtime, unprovable statically. + pre.add(new JumpInsnNode(Opcodes.IFGT, ok)); + // dead arm: throw new RuntimeException(); -- never reached. + pre.add(new TypeInsnNode(Opcodes.NEW, "java/lang/RuntimeException")); + pre.add(new InsnNode(Opcodes.DUP)); + pre.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, "java/lang/RuntimeException", "", "()V", false)); + pre.add(new InsnNode(Opcodes.ATHROW)); + pre.add(ok); + mn.instructions.insert(pre); + } + + private boolean hasGuardField(ClassNode cn) { + if (cn.fields == null) { + return false; + } + for (FieldNode fn : cn.fields) { + if (GUARD_FIELD.equals(fn.name)) { + return true; + } + } + return false; + } + + private void addGuardField(ClassNode cn) { + if (cn.fields == null) { + cn.fields = new java.util.ArrayList(); + } + cn.fields.add(new FieldNode(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC, + GUARD_FIELD, GUARD_DESC, null, null)); + } + + private void initGuardField(ClassNode cn) { + InsnList init = new InsnList(); + // zq$cf = System.getProperty("java.home").length(); -- always >= 1, never foldable. + init.add(new LdcInsnNode("java.home")); + init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", + "(Ljava/lang/String;)Ljava/lang/String;", false)); + init.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/String", "length", "()I", false)); + init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, GUARD_FIELD, GUARD_DESC)); + + MethodNode clinit = null; + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if ("".equals(mn.name) && "()V".equals(mn.desc)) { + clinit = mn; + break; + } + } + } + if (clinit == null) { + clinit = new MethodNode(Opcodes.ASM9, Opcodes.ACC_STATIC, "", "()V", null, null); + clinit.instructions = new InsnList(); + clinit.instructions.add(init); + clinit.instructions.add(new InsnNode(Opcodes.RETURN)); + cn.methods.add(clinit); + } else { + clinit.instructions.insert(init); + } + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java new file mode 100644 index 00000000000..ad6be0a9e91 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.hardening; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * The resolved hardening settings for one build, derived from the {@code harden.*} + * build hints. A level sets the defaults; individual switches override them; and a + * per-platform switch ({@code harden..enabled}) can turn the whole thing + * off for one target. Nothing here references a builder's {@code BuildRequest}: the + * caller hands over a plain map of already-resolved hint values so the same config + * is usable from both the maven plugin and the cloud daemon. + */ +public final class HardeningConfig { + private final HardeningProfile profile; + private final boolean renameEnabled; + private final boolean encryptConstantStrings; + private final boolean encryptAllStrings; + private final boolean controlFlow; + private final boolean platformEnabled; + private final String platform; + private final String seed; + private final List extraKeepRules; + + private HardeningConfig(HardeningProfile profile, boolean renameEnabled, + boolean encryptConstantStrings, boolean encryptAllStrings, + boolean controlFlow, boolean platformEnabled, String platform, + String seed, List extraKeepRules) { + this.profile = profile; + this.renameEnabled = renameEnabled; + this.encryptConstantStrings = encryptConstantStrings; + this.encryptAllStrings = encryptAllStrings; + this.controlFlow = controlFlow; + this.platformEnabled = platformEnabled; + this.platform = platform; + this.seed = seed; + this.extraKeepRules = extraKeepRules; + } + + /** + * Builds a config from resolved hint values. + * + * @param hints the {@code harden.*} keys (prefix included), already resolved to their + * string values, e.g. {@code harden.level -> "aggressive"} + * @param platform one of {@code and|ios|mac|linux|win|javascript|javase|watch|tv} + * @param renameSupported false for Android, where R8 remains the sole renamer + */ + public static HardeningConfig from(Map hints, String platform, boolean renameSupported) { + HardeningProfile level = HardeningProfile.parse(get(hints, "harden.level", "off")); + if (level == null) { + level = HardeningProfile.OFF; + } + boolean platformEnabled = boolTri(get(hints, "harden." + platform + ".enabled", "true"), true); + + boolean rename = renameSupported && boolTri(get(hints, "harden.rename", null), level.renamesByDefault()); + + String strings = get(hints, "harden.strings", null); + boolean encConst; + boolean encAll; + if (strings == null) { + encConst = level.encryptsConstantStringsByDefault(); + encAll = level.encryptsAllStringsByDefault(); + } else { + String v = strings.trim().toLowerCase(); + if ("off".equals(v) || "false".equals(v) || "0".equals(v)) { + encConst = false; + encAll = false; + } else if ("constants".equals(v) || "1".equals(v)) { + encConst = true; + encAll = false; + } else { + // "all", "true", "2", "3" + encConst = true; + encAll = true; + } + } + + boolean cf = boolTri(get(hints, "harden.controlFlow", null), level.controlFlowByDefault()); + + String seed = get(hints, "harden.seed", null); + + List keep = new ArrayList(); + String keepRaw = get(hints, "harden.keep", null); + if (keepRaw != null) { + for (String rule : keepRaw.split("[\\n;]")) { + String t = rule.trim(); + if (!t.isEmpty()) { + keep.add(t); + } + } + } + + return new HardeningConfig(level, rename, encConst, encAll, cf, platformEnabled, platform, seed, keep); + } + + private static String get(Map hints, String key, String def) { + if (hints == null) { + return def; + } + String v = hints.get(key); + return v == null ? def : v; + } + + private static boolean boolTri(String v, boolean def) { + if (v == null) { + return def; + } + String t = v.trim().toLowerCase(); + if (t.isEmpty()) { + return def; + } + if ("true".equals(t) || "1".equals(t) || "2".equals(t) || "3".equals(t) || "on".equals(t)) { + return true; + } + if ("false".equals(t) || "0".equals(t) || "off".equals(t)) { + return false; + } + return def; + } + + /** True when any transform should run: the level is on and this platform is not opted out. */ + public boolean isActive() { + return profile != HardeningProfile.OFF && platformEnabled; + } + + public HardeningProfile getProfile() { + return profile; + } + + public boolean isRenameEnabled() { + return renameEnabled; + } + + public boolean isEncryptConstantStrings() { + return encryptConstantStrings; + } + + public boolean isEncryptAllStrings() { + return encryptAllStrings; + } + + public boolean isAnyStringEncryption() { + return encryptConstantStrings || encryptAllStrings; + } + + public boolean isControlFlow() { + return controlFlow; + } + + public boolean isPlatformEnabled() { + return platformEnabled; + } + + public String getPlatform() { + return platform; + } + + public String getSeed() { + return seed; + } + + public List getExtraKeepRules() { + return extraKeepRules; + } + + /** The transforms actually enabled, for the report and the mapping header. */ + public List enabledTransforms() { + List t = new ArrayList(); + if (renameEnabled) { + t.add("rename"); + } + if (encryptConstantStrings || encryptAllStrings) { + t.add(encryptAllStrings ? "strings:all" : "strings:constants"); + } + if (controlFlow) { + t.add("controlFlow"); + } + return t; + } + + @Override + public String toString() { + return "HardeningConfig" + Arrays.asList( + "profile=" + profile, "platform=" + platform, "platformEnabled=" + platformEnabled, + "rename=" + renameEnabled, "encConst=" + encryptConstantStrings, + "encAll=" + encryptAllStrings, "controlFlow=" + controlFlow); + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java new file mode 100644 index 00000000000..4c315ce9a7a --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -0,0 +1,283 @@ +/* + * 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.hardening; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The one public entry point of the hardening engine. Given the merged application + * jar and a resolved config, it produces a hardened jar plus a cross-platform + * ProGuard mapping. + * + *

The pipeline is: demux the fat jar to a class-only jar (non-class entries + * preserved byte-for-byte); assemble keep rules; rename with ProGuard using the + * prefixed dictionary (skipped on Android, where R8 remains the sole renamer); + * encrypt strings; guard against ParparVM mangle collisions; verify every class; + * rebuild the output jar; and finalize the mapping. + */ +public final class HardeningEngine { + + public static final String ENGINE_VERSION = "1.0.0"; + public static final String PROGUARD_VERSION = "7.3.2"; + + private HardeningEngine() { + } + + public static String engineVersion() { + return ENGINE_VERSION; + } + + public static HardeningResult harden(HardeningRequest req) throws HardeningException { + HardeningConfig cfg = req.getConfig(); + require(req.getInputJar() != null && req.getInputJar().isFile(), "input jar is missing"); + require(req.getOutputJar() != null, "output jar path is missing"); + require(cfg != null, "config is missing"); + + if (cfg.getProfile() == HardeningProfile.OFF) { + return HardeningResult.skipped(HardeningResult.Outcome.SKIPPED_NOT_REQUESTED, req.getInputJar()); + } + if (!cfg.isPlatformEnabled()) { + return HardeningResult.skipped(HardeningResult.Outcome.SKIPPED_PLATFORM_DISABLED, req.getInputJar()); + } + + File workDir = req.getWorkDir(); + if (workDir == null) { + workDir = req.getOutputJar().getAbsoluteFile().getParentFile(); + } + workDir.mkdirs(); + + try { + return run(req, cfg, workDir); + } catch (IOException e) { + throw new HardeningException("Hardening failed: " + e.getMessage(), e); + } + } + + private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, File workDir) + throws HardeningException, IOException { + File classesJar = new File(workDir, "classes-in.jar"); + JarDemuxer.NonClassEntries nonClass = JarDemuxer.split(req.getInputJar(), classesJar); + Map inClasses = JarDemuxer.readClasses(classesJar); + int classesIn = inClasses.size(); + + List keepRules = new ArrayList(); + keepRules.addAll(BuiltinKeepRules.rules(req.getMainClass())); + InputJarKeepScanner scanner = new InputJarKeepScanner(); + scanner.scan(inClasses); + keepRules.addAll(scanner.keepRules()); + keepRules.addAll(cfg.getExtraKeepRules()); + + Map renamed; + int renamedCount = 0; + File mappingFile = req.getMappingFile(); + + if (cfg.isRenameEnabled()) { + File dict = new File(workDir, "cn1-dict.txt"); + Cn1NameFactory.writeDictionary(dict, Cn1NameFactory.dictionarySizeFor(classesIn)); + File renamedJar = new File(workDir, "renamed.jar"); + ProGuardRunner.rename(classesJar, renamedJar, mappingFile, + req.getLibraryJars(), keepRules, dict, workDir); + renamed = JarDemuxer.readClasses(renamedJar); + renamedCount = countRenamed(inClasses.keySet(), renamed.keySet()); + } else { + renamed = new LinkedHashMap(inClasses); + if (mappingFile != null) { + writeText(mappingFile, ""); + } + } + + int seed = deriveSeed(cfg, req.getBuildKey()); + int encryptedStrings = 0; + boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); + if (stringsApplied) { + for (Map.Entry e : renamed.entrySet()) { + StringEncryptTransform t = new StringEncryptTransform(cfg.isEncryptAllStrings(), seed); + byte[] out = t.transform(e.getValue()); + if (out != e.getValue()) { + e.setValue(out); + } + encryptedStrings += t.getEncryptedCount(); + } + } + + int guardedMethods = 0; + boolean controlFlowApplied = cfg.isControlFlow() && controlFlowSafeFor(cfg.getPlatform()); + if (controlFlowApplied) { + for (Map.Entry e : renamed.entrySet()) { + ControlFlowTransform t = new ControlFlowTransform(); + byte[] out = t.transform(e.getValue()); + if (out != e.getValue()) { + e.setValue(out); + } + guardedMethods += t.getGuardedMethods(); + } + } + + MangleCollisionCheck.check(renamed.keySet()); + OutputVerifier.verify(renamed); + + // Idempotence marker: a nested builder delegation must not harden twice. + nonClass.asMap().put("META-INF/CN1-HARDENED", + (ENGINE_VERSION + " " + cfg.getProfile().name().toLowerCase()) + .getBytes(java.nio.charset.Charset.forName("UTF-8"))); + + JarDemuxer.rebuild(req.getOutputJar(), renamed, nonClass); + + String mappingId = ""; + if (mappingFile != null) { + mappingId = MappingWriter.finalizeMapping(mappingFile, ENGINE_VERSION, PROGUARD_VERSION, + cfg.getPlatform(), req.getBuildKey()); + } + + HardeningResult result = HardeningResult.hardened(req.getOutputJar(), mappingFile); + result.setClassesIn(classesIn); + result.setClassesOut(renamed.size()); + result.setRenamedClasses(renamedCount); + result.setEncryptedStrings(encryptedStrings); + result.setMappingId(mappingId); + // Report only what actually ran, so a "half-hardened" build can never claim a + // transform it skipped. This is what the downstream verifier checks against. + if (cfg.isRenameEnabled()) { + result.getTransformsApplied().add("rename"); + } + if (stringsApplied && encryptedStrings > 0) { + result.getTransformsApplied().add(cfg.isEncryptAllStrings() ? "strings:all" : "strings:constants"); + } + if (controlFlowApplied && guardedMethods > 0) { + result.getTransformsApplied().add("controlFlow"); + } + if (cfg.isControlFlow() && !controlFlowApplied) { + result.getWarnings().add("control-flow obfuscation is not applied on platform '" + + cfg.getPlatform() + "' (unsafe for the ParparVM optimizer); skipped"); + } + if (cfg.isAnyStringEncryption() && !stringsApplied) { + result.getWarnings().add("string encryption is not applied on platform '" + + cfg.getPlatform() + "' (would break the JavaScript native bridge); skipped"); + } + if (req.getReportFile() != null) { + writeReport(req.getReportFile(), cfg, result); + } + return result; + } + + /** + * String encryption is disabled on the JavaScript port for now: the ParparVM JS backend's + * minifier treats certain string literals as live references into the CN1 native bridge, and + * encrypting one would break the bridge. Every other port is safe (the decoder is ordinary + * translated/compiled code). + */ + static boolean stringEncryptionSafeFor(String platform) { + return !"javascript".equals(platform); + } + + /** + * Control-flow obfuscation runs only on the JVM-bytecode ports (Android, desktop). The + * ParparVM native ports (ios/mac/watch/tv/win/linux) translate to C, where the opaque + * predicate fights the optimizer/devirtualizer and the arithmetic reducer, and the JavaScript + * port inflates the bundle and confuses the suspension analysis; those are left untouched. + */ + static boolean controlFlowSafeFor(String platform) { + return "and".equals(platform) || "android".equals(platform) + || "javase".equals(platform) || "desktop".equals(platform); + } + + private static int deriveSeed(HardeningConfig cfg, String buildKey) { + String basis = cfg.getSeed() != null ? cfg.getSeed() + : (buildKey == null || buildKey.isEmpty() ? "cn1-hardening" : buildKey); + int h = 0; + for (int i = 0; i < basis.length(); i++) { + h = h * 31 + basis.charAt(i); + } + return h; + } + + private static int countRenamed(java.util.Set before, java.util.Set after) { + int n = 0; + for (String b : before) { + if (!after.contains(b)) { + n++; + } + } + return n; + } + + private static void writeReport(File reportFile, HardeningConfig cfg, HardeningResult r) + throws IOException { + StringBuilder sb = new StringBuilder(); + sb.append("{\n"); + sb.append(" \"engine\": \"").append(ENGINE_VERSION).append("\",\n"); + sb.append(" \"proguard\": \"").append(PROGUARD_VERSION).append("\",\n"); + sb.append(" \"platform\": \"").append(json(cfg.getPlatform())).append("\",\n"); + sb.append(" \"profile\": \"").append(cfg.getProfile().name().toLowerCase()).append("\",\n"); + sb.append(" \"outcome\": \"").append(r.getOutcome().name()).append("\",\n"); + sb.append(" \"classesIn\": ").append(r.getClassesIn()).append(",\n"); + sb.append(" \"classesOut\": ").append(r.getClassesOut()).append(",\n"); + sb.append(" \"renamedClasses\": ").append(r.getRenamedClasses()).append(",\n"); + sb.append(" \"encryptedStrings\": ").append(r.getEncryptedStrings()).append(",\n"); + sb.append(" \"mappingId\": \"").append(json(r.getMappingId())).append("\",\n"); + sb.append(" \"transforms\": ["); + List t = r.getTransformsApplied(); + for (int i = 0; i < t.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append('"').append(json(t.get(i))).append('"'); + } + sb.append("]\n"); + sb.append("}\n"); + writeText(reportFile, sb.toString()); + } + + private static String json(String s) { + if (s == null) { + return ""; + } + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private static void writeText(File f, String text) throws IOException { + FileOutputStream fo = new FileOutputStream(f); + try { + Writer w = new OutputStreamWriter(fo, Charset.forName("UTF-8")); + w.write(text); + w.flush(); + } finally { + fo.close(); + } + } + + private static void require(boolean cond, String message) throws HardeningException { + if (!cond) { + throw new HardeningException(message); + } + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningException.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningException.java new file mode 100644 index 00000000000..c513ecc52b7 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningException.java @@ -0,0 +1,34 @@ +/* + * 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.hardening; + +/** Thrown when hardening cannot complete and the build must fail rather than ship a half-hardened app. */ +public class HardeningException extends Exception { + public HardeningException(String message) { + super(message); + } + + public HardeningException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningProfile.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningProfile.java new file mode 100644 index 00000000000..60c734b4835 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningProfile.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.hardening; + +/** + * The hardening level a build requested, from the {@code harden.level} build hint. + * The level is the primary control; individual {@code harden.*} switches override + * what a level turns on. Each level is a superset of the previous one. + */ +public enum HardeningProfile { + /** No transform runs; the input jar is returned untouched. */ + OFF, + /** Class/method/field renaming plus encryption of constant strings. */ + STANDARD, + /** Adds encryption of all strings and control-flow obfuscation. */ + AGGRESSIVE, + /** Adds opaque predicates and reflective-name hiding on top of aggressive. */ + PARANOID; + + /** Parses a level name case-insensitively; returns {@code null} for an unknown value. */ + public static HardeningProfile parse(String s) { + if (s == null) { + return null; + } + String v = s.trim().toUpperCase(); + if (v.isEmpty()) { + return null; + } + for (HardeningProfile p : values()) { + if (p.name().equals(v)) { + return p; + } + } + return null; + } + + public boolean isAtLeast(HardeningProfile other) { + return ordinal() >= other.ordinal(); + } + + /** Renaming applies at STANDARD and above. */ + public boolean renamesByDefault() { + return isAtLeast(STANDARD); + } + + /** STANDARD encrypts constant strings only; AGGRESSIVE and up encrypt all strings. */ + public boolean encryptsAllStringsByDefault() { + return isAtLeast(AGGRESSIVE); + } + + public boolean encryptsConstantStringsByDefault() { + return isAtLeast(STANDARD); + } + + public boolean controlFlowByDefault() { + return isAtLeast(AGGRESSIVE); + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningRequest.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningRequest.java new file mode 100644 index 00000000000..bcf77090d98 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningRequest.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.hardening; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Everything the engine needs for one run, assembled by the caller. No builder + * {@code BuildRequest} type crosses this boundary -- the plugin and the daemon each + * build one of these from their own request object and their resolved hint map, so + * the engine stays single-sourced across the two repositories. + */ +public final class HardeningRequest { + private File inputJar; + private File outputJar; + private File mappingFile; + private File reportFile; + private File workDir; + private HardeningConfig config; + private String mainClass; + private String buildKey = ""; + private final List libraryJars = new ArrayList(); + + public File getInputJar() { + return inputJar; + } + + public HardeningRequest inputJar(File f) { + this.inputJar = f; + return this; + } + + public File getOutputJar() { + return outputJar; + } + + public HardeningRequest outputJar(File f) { + this.outputJar = f; + return this; + } + + public File getMappingFile() { + return mappingFile; + } + + public HardeningRequest mappingFile(File f) { + this.mappingFile = f; + return this; + } + + public File getReportFile() { + return reportFile; + } + + public HardeningRequest reportFile(File f) { + this.reportFile = f; + return this; + } + + public File getWorkDir() { + return workDir; + } + + public HardeningRequest workDir(File f) { + this.workDir = f; + return this; + } + + public HardeningConfig getConfig() { + return config; + } + + public HardeningRequest config(HardeningConfig c) { + this.config = c; + return this; + } + + public String getMainClass() { + return mainClass; + } + + public HardeningRequest mainClass(String s) { + this.mainClass = s; + return this; + } + + public String getBuildKey() { + return buildKey; + } + + public HardeningRequest buildKey(String s) { + this.buildKey = s == null ? "" : s; + return this; + } + + public List getLibraryJars() { + return libraryJars; + } + + public HardeningRequest addLibraryJar(File f) { + if (f != null) { + libraryJars.add(f); + } + return this; + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningResult.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningResult.java new file mode 100644 index 00000000000..a53784fb8fe --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningResult.java @@ -0,0 +1,126 @@ +/* + * 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.hardening; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** Outcome of a hardening run. When skipped, {@link #getHardenedJar()} is the original input. */ +public final class HardeningResult { + + public enum Outcome { + HARDENED, + SKIPPED_NOT_REQUESTED, + SKIPPED_PLATFORM_DISABLED + } + + private final Outcome outcome; + private final File hardenedJar; + private final File mappingFile; + private final List warnings = new ArrayList(); + private final List transformsApplied = new ArrayList(); + private int classesIn; + private int classesOut; + private int renamedClasses; + private int encryptedStrings; + private String mappingId = ""; + + private HardeningResult(Outcome outcome, File hardenedJar, File mappingFile) { + this.outcome = outcome; + this.hardenedJar = hardenedJar; + this.mappingFile = mappingFile; + } + + public static HardeningResult skipped(Outcome outcome, File inputJar) { + return new HardeningResult(outcome, inputJar, null); + } + + public static HardeningResult hardened(File hardenedJar, File mappingFile) { + return new HardeningResult(Outcome.HARDENED, hardenedJar, mappingFile); + } + + public Outcome getOutcome() { + return outcome; + } + + public boolean isHardened() { + return outcome == Outcome.HARDENED; + } + + public File getHardenedJar() { + return hardenedJar; + } + + public File getMappingFile() { + return mappingFile; + } + + public List getWarnings() { + return warnings; + } + + public List getTransformsApplied() { + return transformsApplied; + } + + public int getClassesIn() { + return classesIn; + } + + public void setClassesIn(int classesIn) { + this.classesIn = classesIn; + } + + public int getClassesOut() { + return classesOut; + } + + public void setClassesOut(int classesOut) { + this.classesOut = classesOut; + } + + public int getRenamedClasses() { + return renamedClasses; + } + + public void setRenamedClasses(int renamedClasses) { + this.renamedClasses = renamedClasses; + } + + public int getEncryptedStrings() { + return encryptedStrings; + } + + public void setEncryptedStrings(int encryptedStrings) { + this.encryptedStrings = encryptedStrings; + } + + public String getMappingId() { + return mappingId; + } + + public void setMappingId(String mappingId) { + this.mappingId = mappingId; + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java new file mode 100644 index 00000000000..faa4db11b4e --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.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.hardening; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +/** + * Tier 2 keep rules, derived from the input classes with ASM. This covers what + * ProGuard cannot infer declaratively: a class named by a string constant that is + * then resolved by reflection ({@code Class.forName}, {@code UIBuilder}, the + * annotation-generated mappers). Over-keeping here is safe -- it costs a little + * obfuscation coverage; under-keeping would break the app at runtime -- so any app + * class whose name appears verbatim as a string constant anywhere in the jar is + * kept. + */ +public final class InputJarKeepScanner { + + private final Set classBinaryNames = new LinkedHashSet(); + private final Set stringConstants = new LinkedHashSet(); + + /** Scans every class in {@code classesByInternalName} (keyed {@code a/b/C}). */ + public void scan(Map classesByInternalName) { + for (Map.Entry e : classesByInternalName.entrySet()) { + classBinaryNames.add(e.getKey().replace('/', '.')); + } + for (byte[] classBytes : classesByInternalName.values()) { + ClassReader cr = new ClassReader(classBytes); + cr.accept(new ConstantCollector(), ClassReader.SKIP_FRAMES); + } + } + + /** The derived keep rules. */ + public List keepRules() { + List rules = new ArrayList(); + Set kept = new LinkedHashSet(); + for (String s : stringConstants) { + String candidate = s.trim(); + // Accept both dotted and slash forms of a reference. + String dotted = candidate.replace('/', '.'); + if (classBinaryNames.contains(dotted) && kept.add(dotted)) { + rules.add("-keep class " + dotted + " { *; }"); + } + } + return rules; + } + + /** Visible for testing: the class names that were kept for reflection safety. */ + List reflectivelyReferencedClasses() { + List out = new ArrayList(); + Set seen = new LinkedHashSet(); + for (String s : stringConstants) { + String dotted = s.trim().replace('/', '.'); + if (classBinaryNames.contains(dotted) && seen.add(dotted)) { + out.add(dotted); + } + } + return out; + } + + private final class ConstantCollector extends ClassVisitor { + ConstantCollector() { + super(Opcodes.ASM9); + } + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) { + return new MethodVisitor(Opcodes.ASM9) { + @Override + public void visitLdcInsn(Object value) { + if (value instanceof String) { + stringConstants.add((String) value); + } + } + }; + } + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java new file mode 100644 index 00000000000..4cb38c97392 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java @@ -0,0 +1,168 @@ +/* + * 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.hardening; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +/** + * Splits the merged application jar into a class-only jar (the only thing ProGuard + * and the ASM transforms ever see) and an ordered set of every non-class entry. + * The non-class entries -- {@code .aar}, {@code .a}, {@code .res} theme files, + * tarred native bundles, resources -- are carried across byte-for-byte and + * re-emitted into the hardened jar. Running ProGuard over the whole jar would + * recompress and mangle those, which is why the split exists. + */ +public final class JarDemuxer { + + /** The non-class entries of an input jar, kept in their original order and bytes. */ + public static final class NonClassEntries { + private final Map entries = new LinkedHashMap(); + + void put(String name, byte[] data) { + entries.put(name, data); + } + + public int size() { + return entries.size(); + } + + public Map asMap() { + return entries; + } + } + + private JarDemuxer() { + } + + /** + * Reads {@code input}, writes every {@code .class} entry into {@code classesJarOut}, and + * returns the remaining entries. Directory entries are dropped (the rebuild recreates the + * container). + * + * @return the non-class entries plus, via {@link #classCount}, how many classes were split + */ + public static NonClassEntries split(File input, File classesJarOut) throws IOException { + NonClassEntries nonClass = new NonClassEntries(); + FileInputStream fi = new FileInputStream(input); + try { + ZipInputStream zis = new ZipInputStream(fi); + FileOutputStream fo = new FileOutputStream(classesJarOut); + try { + ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(fo)); + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory()) { + continue; + } + byte[] data = readAll(zis); + String name = entry.getName(); + if (name.endsWith(".class")) { + ZipEntry out = new ZipEntry(name); + zos.putNextEntry(out); + zos.write(data); + zos.closeEntry(); + } else { + nonClass.put(name, data); + } + } + zos.finish(); + zos.flush(); + } finally { + fo.close(); + } + } finally { + fi.close(); + } + return nonClass; + } + + /** + * Writes {@code outJar} from the transformed classes (keyed by internal name, e.g. + * {@code a/b/C}) plus the preserved non-class entries, each copied byte-for-byte. + */ + public static void rebuild(File outJar, Map classesByInternalName, + NonClassEntries nonClass) throws IOException { + FileOutputStream fo = new FileOutputStream(outJar); + try { + ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(fo)); + for (Map.Entry e : classesByInternalName.entrySet()) { + ZipEntry entry = new ZipEntry(e.getKey() + ".class"); + zos.putNextEntry(entry); + zos.write(e.getValue()); + zos.closeEntry(); + } + for (Map.Entry e : nonClass.asMap().entrySet()) { + ZipEntry entry = new ZipEntry(e.getKey()); + zos.putNextEntry(entry); + zos.write(e.getValue()); + zos.closeEntry(); + } + zos.finish(); + zos.flush(); + } finally { + fo.close(); + } + } + + /** Reads every {@code .class} entry of a jar into a map keyed by internal name. */ + public static Map readClasses(File jar) throws IOException { + Map classes = new LinkedHashMap(); + FileInputStream fi = new FileInputStream(jar); + try { + ZipInputStream zis = new ZipInputStream(fi); + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory() || !entry.getName().endsWith(".class")) { + continue; + } + byte[] data = readAll(zis); + String internal = entry.getName().substring(0, entry.getName().length() - ".class".length()); + classes.put(internal, data); + } + } finally { + fi.close(); + } + return classes; + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream bout = new ByteArrayOutputStream(Math.max(1024, in.available())); + byte[] buf = new byte[8192]; + int r; + while ((r = in.read(buf)) >= 0) { + bout.write(buf, 0, r); + } + return bout.toByteArray(); + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java new file mode 100644 index 00000000000..1b1d1c2e946 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java @@ -0,0 +1,171 @@ +/* + * 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.hardening; + +import java.io.File; +import java.io.FileInputStream; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +/** + * Command-line front end. The engine runs as a forked process so it is single-sourced + * across the maven plugin and the cloud daemon and cannot drift between them, and so + * its ProGuard/ASM never share a classloader with either caller. + * + *

+ *   java -jar cn1-hardening.jar harden --in in.jar --out out.jar \
+ *        --mapping mapping.txt --report report.json --config config.properties
+ * 
+ * + *

The {@code config.properties} carries the resolved {@code harden.*} hints plus + * {@code cn1.platform}, {@code cn1.mainClass}, {@code cn1.renameSupported}, + * {@code cn1.entitled}, {@code cn1.buildKey} and {@code cn1.libraryJars}. Exit codes: + * {@code 0} hardened, {@code 3} declined by config (caller keeps the input jar), + * {@code 4} not entitled, anything else a failure. + */ +public final class Main { + + public static final int EXIT_HARDENED = 0; + public static final int EXIT_FAILED = 1; + public static final int EXIT_DECLINED = 3; + public static final int EXIT_NOT_ENTITLED = 4; + + private Main() { + } + + public static void main(String[] args) { + System.exit(run(args)); + } + + static int run(String[] args) { + try { + if (args.length == 0 || !"harden".equals(args[0])) { + System.err.println("usage: harden --in --out --mapping " + + "--report --config "); + return EXIT_FAILED; + } + Map opts = parseOptions(args); + File in = fileOpt(opts, "in"); + File out = fileOpt(opts, "out"); + File mapping = fileOpt(opts, "mapping"); + File report = opts.containsKey("report") ? new File(opts.get("report")) : null; + File configFile = fileOpt(opts, "config"); + + Properties props = new Properties(); + FileInputStream fi = new FileInputStream(configFile); + try { + props.load(fi); + } finally { + fi.close(); + } + + String platform = props.getProperty("cn1.platform", "unknown"); + String mainClass = props.getProperty("cn1.mainClass", ""); + boolean renameSupported = !"false".equalsIgnoreCase(props.getProperty("cn1.renameSupported", "true")); + boolean entitled = !"false".equalsIgnoreCase(props.getProperty("cn1.entitled", "true")); + String buildKey = props.getProperty("cn1.buildKey", ""); + + Map hints = new HashMap(); + for (String name : props.stringPropertyNames()) { + if (name.startsWith("harden.")) { + hints.put(name, props.getProperty(name)); + } + } + + HardeningConfig cfg = HardeningConfig.from(hints, platform, renameSupported); + + if (cfg.getProfile() != HardeningProfile.OFF && !entitled) { + System.err.println("App hardening is an Enterprise feature and this build is not " + + "entitled. Refusing to produce a half-hardened binary."); + return EXIT_NOT_ENTITLED; + } + + HardeningRequest req = new HardeningRequest() + .inputJar(in) + .outputJar(out) + .mappingFile(mapping) + .reportFile(report) + .workDir(out.getAbsoluteFile().getParentFile()) + .config(cfg) + .mainClass(mainClass) + .buildKey(buildKey); + for (File lib : libraryJars(props)) { + req.addLibraryJar(lib); + } + + HardeningResult result = HardeningEngine.harden(req); + if (!result.isHardened()) { + System.out.println("cn1-hardening: skipped (" + result.getOutcome() + ")"); + return EXIT_DECLINED; + } + System.out.println("cn1-hardening: hardened " + result.getClassesOut() + " classes, " + + "renamed " + result.getRenamedClasses() + ", encrypted " + + result.getEncryptedStrings() + " strings, transforms=" + + result.getTransformsApplied() + ", mappingId=" + result.getMappingId()); + for (String w : result.getWarnings()) { + System.out.println("cn1-hardening: warning: " + w); + } + return EXIT_HARDENED; + } catch (HardeningException e) { + System.err.println("cn1-hardening: " + e.getMessage()); + return EXIT_FAILED; + } catch (Exception e) { + System.err.println("cn1-hardening: unexpected failure: " + e); + e.printStackTrace(); + return EXIT_FAILED; + } + } + + private static java.util.List libraryJars(Properties props) { + java.util.List jars = new java.util.ArrayList(); + String raw = props.getProperty("cn1.libraryJars", ""); + if (raw != null && !raw.isEmpty()) { + for (String p : raw.split(java.util.regex.Pattern.quote(File.pathSeparator))) { + if (!p.trim().isEmpty()) { + jars.add(new File(p.trim())); + } + } + } + return jars; + } + + private static Map parseOptions(String[] args) { + Map opts = new HashMap(); + for (int i = 1; i < args.length - 1; i++) { + if (args[i].startsWith("--")) { + opts.put(args[i].substring(2), args[i + 1]); + i++; + } + } + return opts; + } + + private static File fileOpt(Map opts, String key) throws HardeningException { + String v = opts.get(key); + if (v == null) { + throw new HardeningException("missing --" + key); + } + return new File(v); + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/MangleCollisionCheck.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MangleCollisionCheck.java new file mode 100644 index 00000000000..80563c18168 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MangleCollisionCheck.java @@ -0,0 +1,72 @@ +/* + * 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.hardening; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Guards against a ParparVM name collision. The translator mangles a Java class + * name to a C symbol by turning {@code '.'}, {@code '/'} and {@code '$'} into + * {@code '_'} ({@code ByteCodeClass}), so two distinct classes whose names differ + * only in those separators -- {@code a.b_c} and {@code a.b.c} -- collapse to the + * same C symbol {@code a_b_c} and the native build fails confusingly. The + * {@link Cn1NameFactory} dictionary never emits {@code '_'}, so a collision should + * be impossible; this check makes that a guarantee rather than an assumption. + */ +public final class MangleCollisionCheck { + + private MangleCollisionCheck() { + } + + /** + * @param internalNames output class names in internal form ({@code a/b/C}) + * @throws HardeningException naming the two classes that collide + */ + public static void check(Set internalNames) throws HardeningException { + Map byMangled = new HashMap(); + for (String name : internalNames) { + String mangled = mangle(name); + String prev = byMangled.put(mangled, name); + if (prev != null) { + throw new HardeningException("Obfuscated class names '" + prev + "' and '" + name + + "' both mangle to the ParparVM C symbol '" + mangled + + "'. This would break the native build."); + } + } + } + + static String mangle(String internalName) { + StringBuilder b = new StringBuilder(internalName.length()); + for (int i = 0; i < internalName.length(); i++) { + char c = internalName.charAt(i); + if (c == '/' || c == '.' || c == '$') { + b.append('_'); + } else { + b.append(c); + } + } + return b.toString(); + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java new file mode 100644 index 00000000000..ec1d3a92023 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java @@ -0,0 +1,94 @@ +/* + * 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.hardening; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Finalizes the ProGuard-format mapping: prepends a provenance header (engine and + * ProGuard versions, platform, build key) so a support ticket can say exactly which + * engine produced it, and computes the {@code mappingId} -- the SHA-256 of the + * mapping body -- stamped into the app so a crash report can be tied to the exact + * mapping even when a rebuild reuses the build key. + */ +public final class MappingWriter { + + private MappingWriter() { + } + + /** + * Prepends the header to {@code mappingFile} in place and returns its {@code mappingId} + * computed over the ProGuard body (excluding the header, so the id is stable regardless of + * header text). + */ + public static String finalizeMapping(File mappingFile, String engineVersion, String proguardVersion, + String platform, String buildKey) throws HardeningException { + try { + byte[] body = mappingFile.isFile() + ? Files.readAllBytes(mappingFile.toPath()) + : new byte[0]; + String mappingId = sha256Hex(body); + StringBuilder header = new StringBuilder(); + header.append("# Codename One App Hardening mapping\n"); + header.append("# engine: ").append(engineVersion).append('\n'); + header.append("# proguard: ").append(proguardVersion).append('\n'); + header.append("# platform: ").append(platform).append('\n'); + header.append("# buildKey: ").append(buildKey == null ? "" : buildKey).append('\n'); + header.append("# mappingId: ").append(mappingId).append('\n'); + FileOutputStream fo = new FileOutputStream(mappingFile); + try { + OutputStream out = fo; + out.write(header.toString().getBytes(Charset.forName("UTF-8"))); + out.write(body); + out.flush(); + } finally { + fo.close(); + } + return mappingId; + } catch (IOException e) { + throw new HardeningException("Could not finalize the mapping file", e); + } + } + + static String sha256Hex(byte[] data) throws HardeningException { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(data); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(Character.forDigit((b >> 4) & 0xf, 16)); + sb.append(Character.forDigit(b & 0xf, 16)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new HardeningException("SHA-256 unavailable", e); + } + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java new file mode 100644 index 00000000000..e5bd113ba95 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java @@ -0,0 +1,59 @@ +/* + * 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.hardening; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Map; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.util.CheckClassAdapter; + +/** + * Verifies every class the engine is about to ship. A transform bug that produces + * invalid bytecode must fail the build here, not at first launch on a device: the + * same {@code CheckClassAdapter} data-flow verification the framework already uses + * elsewhere is run over each output class. + */ +public final class OutputVerifier { + + private OutputVerifier() { + } + + /** @throws HardeningException on the first class that fails verification, naming it. */ + public static void verify(Map classesByInternalName) throws HardeningException { + for (Map.Entry e : classesByInternalName.entrySet()) { + StringWriter sw = new StringWriter(); + try { + CheckClassAdapter.verify(new ClassReader(e.getValue()), false, new PrintWriter(sw)); + } catch (Throwable t) { + throw new HardeningException("Hardened class '" + e.getKey() + + "' failed bytecode verification: " + t.getMessage(), t); + } + String report = sw.toString(); + if (report.length() > 0) { + throw new HardeningException("Hardened class '" + e.getKey() + + "' failed bytecode verification:\n" + report); + } + } + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java new file mode 100644 index 00000000000..e5b9a943d0b --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java @@ -0,0 +1,167 @@ +/* + * 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.hardening; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; +import proguard.Configuration; +import proguard.ConfigurationParser; +import proguard.ProGuard; + +/** + * Drives ProGuard 7.3.x programmatically over the class-only jar to rename it and + * emit the mapping. Everything is expressed as a generated {@code .pro} config, the + * best-understood ProGuard interface. Shrinking and optimization are always off -- + * ParparVM culls and R8 shrinks, and enabling them here only risks release-only + * breakage. + */ +public final class ProGuardRunner { + + private ProGuardRunner() { + } + + /** + * Renames {@code classesJar} into {@code outJar} and writes {@code mappingFile}. + * + * @param libraryJars the app's compile-scope libraries and port jars, so overrides are not + * misrenamed; the JRE is added automatically + * @param keepRules the assembled Tier 1-3 keep rules + * @param dictionary the {@link Cn1NameFactory} dictionary used for classes, members and packages + */ + public static void rename(File classesJar, File outJar, File mappingFile, + List libraryJars, List keepRules, File dictionary, + File workDir) throws HardeningException { + File config = new File(workDir, "cn1-hardening.pro"); + try { + writeConfig(config, classesJar, outJar, mappingFile, libraryJars, keepRules, dictionary); + } catch (IOException e) { + throw new HardeningException("Could not write ProGuard configuration", e); + } + + Configuration configuration = new Configuration(); + ConfigurationParser parser = null; + try { + parser = new ConfigurationParser(config, System.getProperties()); + parser.parse(configuration); + } catch (Exception e) { + throw new HardeningException("ProGuard configuration is invalid: " + e.getMessage(), e); + } finally { + close(parser); + } + + try { + new ProGuard(configuration).execute(); + } catch (Exception e) { + throw new HardeningException("ProGuard failed while renaming the application: " + + e.getMessage(), e); + } + if (!outJar.isFile()) { + throw new HardeningException("ProGuard did not produce an output jar"); + } + } + + private static void writeConfig(File config, File classesJar, File outJar, File mappingFile, + List libraryJars, List keepRules, File dictionary) + throws IOException { + FileOutputStream fo = new FileOutputStream(config); + try { + Writer w = new OutputStreamWriter(fo, Charset.forName("UTF-8")); + w.write("-injars " + quote(classesJar) + "\n"); + w.write("-outjars " + quote(outJar) + "\n"); + for (File lib : runtimeLibraryJars()) { + w.write("-libraryjars " + quote(lib) + "\n"); + } + if (libraryJars != null) { + for (File lib : libraryJars) { + if (lib != null && lib.exists()) { + w.write("-libraryjars " + quote(lib) + "\n"); + } + } + } + w.write("-printmapping " + quote(mappingFile) + "\n"); + w.write("-classobfuscationdictionary " + quote(dictionary) + "\n"); + w.write("-obfuscationdictionary " + quote(dictionary) + "\n"); + w.write("-packageobfuscationdictionary " + quote(dictionary) + "\n"); + for (String flag : BuiltinKeepRules.flags()) { + w.write(flag + "\n"); + } + if (keepRules != null) { + for (String rule : keepRules) { + w.write(rule + "\n"); + } + } + w.flush(); + } finally { + fo.close(); + } + } + + /** rt.jar on a JDK 8 runtime, or every jmod on a JDK 9+ runtime. */ + static List runtimeLibraryJars() { + List jars = new ArrayList(); + String javaHome = System.getProperty("java.home"); + if (javaHome == null) { + return jars; + } + File home = new File(javaHome); + File rt = new File(home, "lib/rt.jar"); + if (rt.isFile()) { + jars.add(rt); + File jce = new File(home, "lib/jce.jar"); + if (jce.isFile()) { + jars.add(jce); + } + return jars; + } + File jmods = new File(home, "jmods"); + File[] mods = jmods.listFiles(); + if (mods != null) { + for (File m : mods) { + if (m.getName().endsWith(".jmod")) { + jars.add(m); + } + } + } + return jars; + } + + private static String quote(File f) { + return "'" + f.getAbsolutePath() + "'"; + } + + private static void close(ConfigurationParser parser) { + if (parser != null) { + try { + parser.close(); + } catch (IOException ignore) { + // best effort + } + } + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java new file mode 100644 index 00000000000..4c01b9e15ae --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -0,0 +1,316 @@ +/* + * 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.hardening; + +import java.util.List; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.tree.FieldNode; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.InsnNode; +import org.objectweb.asm.tree.IntInsnNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.VarInsnNode; + +/** + * Encrypts the string literals in a class so they are not readable in the shipped + * binary, and are never present as plaintext in the ParparVM C constant pool. + * + *

Two channels are neutralized, which is the point often missed: the + * {@code LDC "..."} literals in method bodies, and the {@code ConstantValue} + * attribute of {@code static final String} fields. javac inlines a constant into + * every reader as its own LDC (caught by the first channel), but the defining + * field still carries the plaintext in its {@code ConstantValue} slot, which + * ParparVM emits into the same C table -- so we also strip that attribute and move + * the initialization into {@code } as a decode call. + * + *

The decoder is synthesized into each class with a per-class key baked in, so + * there is no single named framework method to hook. (Scattering, split keys and + * inlining are further hardening layers the design calls for; a per-class keyed + * decoder already removes the single-hook weakness and is what ships first.) + */ +public final class StringEncryptTransform { + + /** Synthesized per-class decoder; the {@code $} keeps it clear of any real app member. */ + static final String DECODER_NAME = "zqdec$"; + static final String DECODER_DESC = "(Ljava/lang/String;)Ljava/lang/String;"; + + private final boolean encryptAllStrings; + private final int seed; + private int encryptedCount; + + public StringEncryptTransform(boolean encryptAllStrings, int seed) { + this.encryptAllStrings = encryptAllStrings; + this.seed = seed; + } + + public int getEncryptedCount() { + return encryptedCount; + } + + /** Encrypts {@code classBytes}, returning the transformed bytes (or the input if nothing changed). */ + public byte[] transform(byte[] classBytes) { + ClassNode cn = new ClassNode(); + new ClassReader(classBytes).accept(cn, ClassReader.SKIP_FRAMES); + + // Interfaces (including annotations) are skipped: their fields are implicitly + // constant, they have no place for a decode call in a Java-5-compatible way, + // and their methods carry no encryptable literals. + if ((cn.access & Opcodes.ACC_INTERFACE) != 0) { + return classBytes; + } + // If the class already defines a member colliding with the decoder, leave it alone. + if (hasDecoderCollision(cn)) { + return classBytes; + } + + int base = keyBase(cn.name); + boolean changed = false; + + // Channel 1: LDC string literals in method bodies. + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + if (DECODER_NAME.equals(mn.name)) { + continue; + } + changed |= encryptMethodLiterals(cn, mn, base); + } + } + + // Channel 2: static final String ConstantValue attributes. + changed |= encryptStaticFinalStrings(cn, base); + + if (!changed) { + return classBytes; + } + + addDecoder(cn, base); + + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES); + cn.accept(cw); + return cw.toByteArray(); + } + + private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base) { + boolean changed = false; + AbstractInsnNode insn = mn.instructions.getFirst(); + while (insn != null) { + AbstractInsnNode next = insn.getNext(); + if (insn instanceof LdcInsnNode) { + LdcInsnNode ldc = (LdcInsnNode) insn; + if (ldc.cst instanceof String && shouldEncrypt((String) ldc.cst)) { + String plain = (String) ldc.cst; + ldc.cst = encode(plain, base); + mn.instructions.insert(ldc, new MethodInsnNode( + Opcodes.INVOKESTATIC, cn.name, DECODER_NAME, DECODER_DESC, false)); + encryptedCount++; + changed = true; + } + } + insn = next; + } + return changed; + } + + private boolean encryptStaticFinalStrings(ClassNode cn, int base) { + if (cn.fields == null) { + return false; + } + InsnList init = new InsnList(); + boolean changed = false; + for (FieldNode fn : cn.fields) { + boolean isStatic = (fn.access & Opcodes.ACC_STATIC) != 0; + if (isStatic && fn.value instanceof String && shouldEncrypt((String) fn.value)) { + String plain = (String) fn.value; + // Strip the ConstantValue so the plaintext leaves the class file entirely + // (this is the slot ParparVM would otherwise dump into the C constant pool). + fn.value = null; + init.add(new LdcInsnNode(encode(plain, base))); + init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, DECODER_NAME, DECODER_DESC, false)); + init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, fn.name, fn.desc)); + encryptedCount++; + changed = true; + } + } + if (changed) { + prependToClinit(cn, init); + } + return changed; + } + + private void prependToClinit(ClassNode cn, InsnList init) { + MethodNode clinit = null; + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if ("".equals(mn.name) && "()V".equals(mn.desc)) { + clinit = mn; + break; + } + } + } + if (clinit == null) { + clinit = new MethodNode(Opcodes.ASM9, + Opcodes.ACC_STATIC, "", "()V", null, null); + clinit.instructions = new InsnList(); + clinit.instructions.add(init); + clinit.instructions.add(new InsnNode(Opcodes.RETURN)); + cn.methods.add(clinit); + } else { + clinit.instructions.insert(init); + } + } + + private void addDecoder(ClassNode cn, int base) { + MethodNode m = new MethodNode(Opcodes.ASM9, + Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC, + DECODER_NAME, DECODER_DESC, null, null); + InsnList in = m.instructions; + // char[] c = s.toCharArray(); (local 1) + in.add(new VarInsnNode(Opcodes.ALOAD, 0)); + in.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/String", "toCharArray", "()[C", false)); + in.add(new VarInsnNode(Opcodes.ASTORE, 1)); + // int i = 0; (local 2) + in.add(new InsnNode(Opcodes.ICONST_0)); + in.add(new VarInsnNode(Opcodes.ISTORE, 2)); + org.objectweb.asm.tree.LabelNode loop = new org.objectweb.asm.tree.LabelNode(); + org.objectweb.asm.tree.LabelNode end = new org.objectweb.asm.tree.LabelNode(); + in.add(loop); + // if (i >= c.length) goto end; + in.add(new VarInsnNode(Opcodes.ILOAD, 2)); + in.add(new VarInsnNode(Opcodes.ALOAD, 1)); + in.add(new InsnNode(Opcodes.ARRAYLENGTH)); + in.add(new org.objectweb.asm.tree.JumpInsnNode(Opcodes.IF_ICMPGE, end)); + // c[i] = (char)(c[i] ^ ((base + i*31) & 0xFFFF)); + in.add(new VarInsnNode(Opcodes.ALOAD, 1)); // arrayref + in.add(new VarInsnNode(Opcodes.ILOAD, 2)); // index + in.add(new VarInsnNode(Opcodes.ALOAD, 1)); // c + in.add(new VarInsnNode(Opcodes.ILOAD, 2)); // i + in.add(new InsnNode(Opcodes.CALOAD)); // c[i] + in.add(new VarInsnNode(Opcodes.ILOAD, 2)); // i + in.add(new IntInsnNode(Opcodes.BIPUSH, 31)); + in.add(new InsnNode(Opcodes.IMUL)); // i*31 + in.add(new LdcInsnNode(Integer.valueOf(base))); + in.add(new InsnNode(Opcodes.IADD)); // base + i*31 + in.add(new LdcInsnNode(Integer.valueOf(0xFFFF))); + in.add(new InsnNode(Opcodes.IAND)); // & 0xFFFF + in.add(new InsnNode(Opcodes.IXOR)); // c[i] ^ key + in.add(new InsnNode(Opcodes.I2C)); + in.add(new InsnNode(Opcodes.CASTORE)); + // i++; + in.add(new org.objectweb.asm.tree.IincInsnNode(2, 1)); + in.add(new org.objectweb.asm.tree.JumpInsnNode(Opcodes.GOTO, loop)); + in.add(end); + // return new String(c); + in.add(new org.objectweb.asm.tree.TypeInsnNode(Opcodes.NEW, "java/lang/String")); + in.add(new InsnNode(Opcodes.DUP)); + in.add(new VarInsnNode(Opcodes.ALOAD, 1)); + in.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, "java/lang/String", "", "([C)V", false)); + in.add(new InsnNode(Opcodes.ARETURN)); + if (cn.methods == null) { + cn.methods = new java.util.ArrayList(); + } + cn.methods.add(m); + } + + private boolean hasDecoderCollision(ClassNode cn) { + if (cn.methods == null) { + return false; + } + for (MethodNode mn : cn.methods) { + if (DECODER_NAME.equals(mn.name) && DECODER_DESC.equals(mn.desc)) { + return true; + } + } + return false; + } + + /** Strings too short to be worth the decoder overhead, or trivially empty, are left alone. */ + private boolean shouldEncrypt(String s) { + if (s == null || s.length() <= 2) { + return false; + } + return true; + } + + /** Encodes a string by XORing each char with a position-dependent key derived from {@code base}. */ + static String encode(String plain, int base) { + char[] c = plain.toCharArray(); + for (int i = 0; i < c.length; i++) { + int key = (base + i * 31) & 0xFFFF; + c[i] = (char) (c[i] ^ key); + } + return new String(c); + } + + /** Decodes; the inverse of {@link #encode}. Used by tests to mirror the synthesized decoder. */ + static String decode(String enc, int base) { + return encode(enc, base); + } + + private int keyBase(String internalName) { + int h = seed; + for (int i = 0; i < internalName.length(); i++) { + h = h * 31 + internalName.charAt(i); + } + int base = h & 0xFFFF; + // Avoid a zero key, which would leave one-char-per-position untouched at i==0. + return base == 0 ? 0x2f : base; + } + + /** True if a transformed method still holds a plaintext copy of {@code needle} as an LDC. */ + static boolean containsStringLiteral(byte[] classBytes, final String needle) { + final boolean[] found = new boolean[1]; + new ClassReader(classBytes).accept(new org.objectweb.asm.ClassVisitor(Opcodes.ASM9) { + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int a, String n, String d, String s, String[] e) { + return new org.objectweb.asm.MethodVisitor(Opcodes.ASM9) { + @Override + public void visitLdcInsn(Object value) { + if (needle.equals(value)) { + found[0] = true; + } + } + }; + } + + @Override + public org.objectweb.asm.FieldVisitor visitField(int a, String n, String d, String s, Object value) { + if (needle.equals(value)) { + found[0] = true; + } + return null; + } + }, 0); + return found[0]; + } +} diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java new file mode 100644 index 00000000000..f278dafd1c1 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java @@ -0,0 +1,73 @@ +/* + * 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.hardening; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import org.junit.Test; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.util.CheckClassAdapter; + +/** The opaque-predicate guard must verify and leave behaviour a strict no-op. */ +public class ControlFlowTransformTest { + + private static final String CLASS = "com.codename1.hardening.fixture.Secrets"; + + private byte[] original() throws Exception { + InputStream in = getClass().getResourceAsStream( + "/com/codename1/hardening/fixture/Secrets.class"); + ByteArrayOutputStream b = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int r; + while ((r = in.read(buf)) >= 0) { + b.write(buf, 0, r); + } + in.close(); + return b.toByteArray(); + } + + @Test + public void guardsVerifyAndPreserveBehaviour() throws Exception { + ControlFlowTransform t = new ControlFlowTransform(); + byte[] out = t.transform(original()); + assertTrue("expected several methods guarded", t.getGuardedMethods() >= 3); + + CheckClassAdapter.verify(new ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + + Class c = new ByteLoader().define(CLASS, out); + assertEquals("hello secret world", c.getMethod("greet").invoke(null)); + assertEquals(5, c.getMethod("compute", int.class, int.class).invoke(null, 2, 3)); + assertEquals("welcome, Bo, to the club", + c.getMethod("concat", String.class).invoke(null, "Bo")); + } + + private static final class ByteLoader extends ClassLoader { + Class define(String name, byte[] b) { + return defineClass(name, b, 0, b.length); + } + } +} diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java new file mode 100644 index 0000000000000000000000000000000000000000..57dcf7141d7174ec10b321d54ce1c147d88e9ea4 GIT binary patch literal 8782 zcmb_hZFAyA62ANDUopp3C1MYRy?Lq4I^GMmVNKR+IKas{sZ>fx186NY+8N0r)Nel2+TUE;|nnA>FMcy`c<802kaR;6UkbJi)G3jzsa5-9zAbiE#R@Y;%pdm z=EXrrNEW7wdGmP~gOczrRYr-QTJgq}?=1{2mFT=v*SuY2AbYX;M0sF)847{2F{bEd>Rz4s(PW^0i# z--~f#5UNy$vn&N!n#;HnK{#Jyfdt9ofJ>GxIZL@*DJJIn&+Dse_L|3BdJ!9Cvncf0 zW$5!*adyY03PsGGGZ9DY7V{MGNr*@-dBA3C&7n^+O{`3;FL02TVxP-46&G+7E@ILa zwuOe5C)3c+B2O}C61r7PWwUqOPgyE7^Tt`^DV2EXvcdR+&lBQIbcqyqVZZ|t34#oz z=Yv5Q>s^=7=+&fWn5Ih)8u5LxO1wCP+;qCuDCw8>Z>YG`WRSC4iUe9CHqh;T2usg6 z%M_nyQHw$6?B~JsVt73zUfru3_H%bU?p{rAjzOF*1qk>ZH`BqAALKk3Ekn#}OF zH$J-nefM;5IhfusA&F6cFum$cfCm7h%SPQXU~+xg9kbE(cr={!pxy}-PX>M5hQz2e z;~cnM3CUT&Q!k7Zb3EuF0YVh6>PH#$ncsO!g~V#77Mr;mZ-7RH>qY@vdUqVC^mzzB zVD^+JBaV)G&b&y(3#~z8v3ntJk6Acpu}E9&UWUNBMag3jY!RnH?7J=Y%@ONE{O^!#4Yx=%MPC(9UZohzB)W&*OP93wNb=9c!V$F)B`v-0z$f! zB3mrkGEI}0ozDIJy-V&&0Yk(bzgHcS8oo->#GDio1R((#J{QKY%1S#73~v``SS{RlSsbQrS0NguF1!d| zU7&8=d!KocI)2)YKSbOIU;aeKQ<;G@7vi0F=eeP9PuD5eLJqSu$H6or*S(1D1pdhxbwbQRz#>pjGzJVk0SDXMi!H1$j&h0G0qW zcAYadY|EN=VXJ;Nhq?zQM z3HK#WRpa=xOfPzuBNPlz;CaELgv)&_XYi?Ce?#Rl`Tg`}ic$ky&#sQzSQuW9&wB0C z%i(GJFMs{-e;V$Br{sdlv5>x^IiL>(X6Y55ROC`MuA4(kDhdz z2-;HIE7mKr_D6kdQm3YWhpc}>x_6;@T5%dMu8ZXc_!Aj@=s^{&Y%Z{iY*l~aU}t3` zJY{3w2chi7+7o9kD%%huw97l7Z{@aq^tmxpNs0r_p9Zq^in}Nf zwGPb<8KX@)pE-q%YApRoC~kPvo&bud#t5i6w>jbxkyqD%x=h9l8ZvaBHer1yLilHX z^O}B0JZQpzJ&aL30{nZ;TM0o_PMk2H$|$u-02^~9GU@ZKGU456bQvoc);DrIrfo5<4?D6@?xg^3hy8qS;1J z4Fg30g?X|lMI}PDg;OjeP|rypD7@*rKtvLGDU~p&goQwvMBNBQ@=2CZLBfM(AqUCx zxu~G=QVS*JO~~h&Kta77INC;Fv|T$QNS-(9`|@;>KBQH!>0^*}sGzEyWLDjGO2pM2 z$k^W-7s(a#iY;NfJm%P3U#F=;RFhNia2n<+ZiKcWYP52dyQEg92{T`cdoIsVk#3l| zFx8oe=i!3+^97`@t-5n5R*pUkj0@Y0bL-eBlo}N&9U;E%fVQc~& zl>)m8b=Q^xZe9v(OXzC!zu{}Afn+^>)4Qnx%|`M<^`_jhoqz+|SiiU4{R0(3T2Nd2 z=unR)0ZqCL441LKkLsC>{V0vT)likmyH$~e&Ub2gzqkg@xA1b% z)UsQQH<}m?6cA(Hy|+Ftu<8&24L=BHO$bci)F9fA5lJ^;P9w){r6HejOd%|Kq4-i^ zc{PpBa+_c(g2NR@Re(F9l`a%* ziy<4cY&0~B4n;1div{41^06`?v}fwTMlpPZnHD6&bP=9HO=MANUr+n(7aIj)#V72U zg~goDMj8u*_N%9}{Cl1+2f@o7yUa|$`Sxp6IfQR}wE>(IIV)c1i`mY}y4f2-u7L^R zTj^>NZJ}=Rc<_=^5mh=5DO+G_f?|SWh7_Vsq`Hu1F851-m!fku@*3Y~$_De3DyGTt zc3`c{Y*lwL#v4 z#TztbQW!INN$Bzi5935LVlpyOqn!w7Op8(M#&ARXhM8l)4i>VT>$+FL(yzaL+zy*S z?Dcq{s}GPk%}M#)ykc?!J?!`QXKI7)tyG<3V?%W&5hL=NI>8-gDa_2> z$(zAw)I0wj??Qeb_x^Q_vZsdvL8H*p6u8-|8ANZ1UiMsYbtTYfQb<3gF(Qh$yuR75 z8>D2D35x#ZWKL5}49)|NkvSAiCtv`*&>-9!P5X z;4M_|E{$@1BNi7;hKY`Uq%z96=)wr^Npz0X)$U8QcNm!a)R&{ex}_Q#*AAsFledZx zEo3DV|Cs{gk?lKT`j(EY=TjR_Zf<}O=m>*+Ty<^S6acXaNipN0#22BOfg1vy)04TfQhj z4QqY+mGQRf>3jeGsUzcCa0xP@5Ts*+*Lluy!> zVO7w(duOL1At`aAe#?t z2Y>-dg_?%bWzk>3OgY+M-Kitg(6rvP=>1ihGVHhsp*`I%+WGqGJ-egFb)D; zjfAvMp*V~mw+Ycu-?H@@?&;H3Vqr`3{S#LrM73I-y3$gv0Lqr6fX|FxY0JLxD?Klf kM*m=UJ!#`ww}7*QM(ptcYPp%D*&5(l?1E{I+)oGp19= 0) { + b.write(buf, 0, r); + } + in.close(); + return b.toByteArray(); + } + + private byte[] transformed() throws Exception { + StringEncryptTransform t = new StringEncryptTransform(true, 12345); + byte[] out = t.transform(original()); + assertTrue("expected some strings encrypted", t.getEncryptedCount() >= 3); + return out; + } + + @Test + public void plaintextIsGone() throws Exception { + byte[] out = transformed(); + assertFalse("LDC / field plaintext greeting survived", + StringEncryptTransform.containsStringLiteral(out, GREETING)); + assertFalse("static final API plaintext survived", + StringEncryptTransform.containsStringLiteral(out, API)); + } + + @Test + public void transformedClassVerifies() throws Exception { + // CheckClassAdapter with data-flow verification; throws on invalid bytecode. + byte[] out = transformed(); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + } + + @Test + public void behaviourIsPreserved() throws Exception { + Class c = new ByteLoader().define(CLASS, transformed()); + assertEquals(GREETING, c.getMethod("greet").invoke(null)); + assertEquals(API, c.getMethod("api").invoke(null)); + assertEquals("welcome, Ada, to the club", + c.getMethod("concat", String.class).invoke(null, "Ada")); + assertEquals(5, c.getMethod("compute", int.class, int.class).invoke(null, 2, 3)); + } + + @Test + public void shortStringsAreNotEncrypted() throws Exception { + // The control integer method has no strings; encryption count comes only from + // the real secrets, and the transform stays a no-op on classes with nothing to do. + StringEncryptTransform t = new StringEncryptTransform(true, 7); + byte[] out = t.transform(original()); + assertTrue(t.getEncryptedCount() >= 3); + // Round-trips under a different seed too. + Class c = new ByteLoader().define(CLASS, out); + assertEquals(GREETING, c.getMethod("greet").invoke(null)); + } + + /** Defines transformed bytes as a fresh class distinct from the already-loaded fixture. */ + private static final class ByteLoader extends ClassLoader { + Class define(String name, byte[] b) { + return defineClass(name, b, 0, b.length); + } + } +} diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Helper.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Helper.java new file mode 100644 index 00000000000..b7e625ddc6b --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Helper.java @@ -0,0 +1,30 @@ +/* + * 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.hardening.fixture; + +/** A fixture with no string literals, so the end-to-end test can confirm it gets renamed. */ +public class Helper { + public static int square(int x) { + return x * x; + } +} diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Secrets.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Secrets.java new file mode 100644 index 00000000000..43474511d5a --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Secrets.java @@ -0,0 +1,46 @@ +/* + * 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.hardening.fixture; + +/** A fixture the string-encryption test transforms and loads. */ +public class Secrets { + /** static final String constant -- carries a ConstantValue attribute. */ + public static final String API = "https://api.example.com/secret-endpoint"; + + public static String greet() { + return "hello secret world"; + } + + public static String api() { + return API; + } + + public static String concat(String who) { + return "welcome, " + who + ", to the club"; + } + + /** Control: no strings; must be byte-for-byte unaffected in behaviour. */ + public static int compute(int a, int b) { + return a + b; + } +} diff --git a/maven/cn1-retrace/pom.xml b/maven/cn1-retrace/pom.xml new file mode 100644 index 00000000000..fe6b2144e1a --- /dev/null +++ b/maven/cn1-retrace/pom.xml @@ -0,0 +1,66 @@ + + + + + com.codenameone + codenameone + 8.0-SNAPSHOT + + 4.0.0 + + cn1-retrace + 8.0-SNAPSHOT + jar + cn1-retrace + + Zero-dependency symbolication for Codename One app hardening. Parses the + ProGuard mapping and the per-build synthetics map, reconstructs original + stack frames from an obfuscated crash report across every port, and + provides the ParparVM trace-string parser that the on-device + Throwable.getStackTrace() implementation mirrors. Consumed both by the + cloud crash service and as a standalone retrace CLI so a developer can + symbolicate a report without the server. + + + + + junit + junit + test + + + + + + + maven-compiler-plugin + + 1.8 + 1.8 + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + + + com.codename1.retrace.RetraceMain + + + false + true + standalone + + + + + + + diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/Frame.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/Frame.java new file mode 100644 index 00000000000..30f8b45541f --- /dev/null +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/Frame.java @@ -0,0 +1,114 @@ +/* + * 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.retrace; + +/** + * One stack frame: fully qualified class name, method name, an optional source + * file (may be {@code null}) and a line number ({@code -1} when unknown). This is + * the common currency the retrace pipeline speaks in, independent of which port + * produced the crash and whether the report arrived as structured frames, a + * ParparVM trace string, or a native backtrace. + */ +public final class Frame { + private final String className; + private final String methodName; + private final String fileName; + private final int lineNumber; + + public Frame(String className, String methodName, String fileName, int lineNumber) { + if (className == null || methodName == null) { + throw new NullPointerException("className and methodName are required"); + } + this.className = className; + this.methodName = methodName; + this.fileName = fileName; + this.lineNumber = lineNumber; + } + + public String getClassName() { + return className; + } + + public String getMethodName() { + return methodName; + } + + /** May be {@code null} when the source file is unknown. */ + public String getFileName() { + return fileName; + } + + /** {@code -1} when the line number is unknown. */ + public int getLineNumber() { + return lineNumber; + } + + /** Renders the frame in the conventional {@code at pkg.Class.method(File.java:line)} form. */ + @Override + public String toString() { + StringBuilder b = new StringBuilder("at "); + b.append(className).append('.').append(methodName).append('('); + if (fileName != null) { + b.append(fileName); + if (lineNumber >= 0) { + b.append(':').append(lineNumber); + } + } else if (lineNumber >= 0) { + b.append(lineNumber); + } else { + b.append("Unknown Source"); + } + b.append(')'); + return b.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Frame)) { + return false; + } + Frame f = (Frame) o; + if (lineNumber != f.lineNumber) { + return false; + } + if (!className.equals(f.className)) { + return false; + } + if (!methodName.equals(f.methodName)) { + return false; + } + return fileName == null ? f.fileName == null : fileName.equals(f.fileName); + } + + @Override + public int hashCode() { + int result = className.hashCode(); + result = 31 * result + methodName.hashCode(); + result = 31 * result + (fileName == null ? 0 : fileName.hashCode()); + result = 31 * result + lineNumber; + return result; + } +} diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingChain.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingChain.java new file mode 100644 index 00000000000..6523771413f --- /dev/null +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingChain.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.retrace; + +import java.util.ArrayList; +import java.util.List; + +/** + * Applies mappings in order. On Android a device frame is doubly renamed -- R8 over + * the hardening engine's rename -- so it is inverted through the R8 mapping first + * and the cross-platform mapping second. On every other port the chain is a single + * mapping. Chaining at query time is the robust alternative to pre-composing the two + * files, which is lossy where the stages' line ranges do not nest. + */ +public final class MappingChain { + + private final List mappings = new ArrayList(); + + /** @param inOrder the mappings to apply, device-nearest first (e.g. R8 then cross-platform). */ + public MappingChain(List inOrder) { + if (inOrder != null) { + mappings.addAll(inOrder); + } + } + + public MappingChain add(MappingFile m) { + if (m != null) { + mappings.add(m); + } + return this; + } + + public Frame retrace(Frame frame) { + Frame f = frame; + for (MappingFile m : mappings) { + f = m.retrace(f); + } + return f; + } + + public boolean isEmpty() { + return mappings.isEmpty(); + } +} diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java new file mode 100644 index 00000000000..f668255765b --- /dev/null +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -0,0 +1,192 @@ +/* + * 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.retrace; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Parses a ProGuard-format {@code mapping.txt} and inverts an obfuscated frame back + * to its original class and method. This is the same format the hardening engine's + * cross-platform mapping and Android's R8 mapping both use, so one parser serves + * every port. + * + *

Comment lines (the engine's provenance header, {@code # ...}) are ignored. + */ +public final class MappingFile { + + private static final class MethodMapping { + final String originalName; + final int startLine; + final int endLine; + + MethodMapping(String originalName, int startLine, int endLine) { + this.originalName = originalName; + this.startLine = startLine; + this.endLine = endLine; + } + } + + private static final class ClassMapping { + final String originalName; + // obfuscated member name -> candidate original methods (multiple when line ranges differ) + final Map> methods = new HashMap>(); + + ClassMapping(String originalName) { + this.originalName = originalName; + } + } + + // obfuscated class binary name -> mapping + private final Map byObfuscated = new HashMap(); + + public static MappingFile parse(String text) throws IOException { + return parse(new StringReader(text)); + } + + public static MappingFile parse(Reader reader) throws IOException { + MappingFile mf = new MappingFile(); + BufferedReader r = new BufferedReader(reader); + String line; + ClassMapping current = null; + while ((line = r.readLine()) != null) { + if (line.isEmpty() || line.charAt(0) == '#') { + continue; + } + if (!Character.isWhitespace(line.charAt(0))) { + // Class line: "original -> obfuscated:" + current = mf.parseClassLine(line); + } else if (current != null) { + mf.parseMemberLine(current, line.trim()); + } + } + return mf; + } + + private ClassMapping parseClassLine(String line) { + int arrow = line.indexOf(" -> "); + if (arrow < 0 || !line.endsWith(":")) { + return null; + } + String original = line.substring(0, arrow).trim(); + String obf = line.substring(arrow + 4, line.length() - 1).trim(); + ClassMapping cm = new ClassMapping(original); + byObfuscated.put(obf, cm); + return cm; + } + + private void parseMemberLine(ClassMapping cm, String line) { + int arrow = line.indexOf(" -> "); + if (arrow < 0) { + return; + } + String left = line.substring(0, arrow); + String obfName = line.substring(arrow + 4).trim(); + // Fields have no '(' ; only methods matter for frame retrace. + if (left.indexOf('(') < 0) { + return; + } + int startLine = 0; + int endLine = 0; + // Optional "start:end:" prefix. + int firstColon = left.indexOf(':'); + if (firstColon >= 0) { + int secondColon = left.indexOf(':', firstColon + 1); + if (secondColon > firstColon) { + startLine = parseIntSafe(left.substring(0, firstColon)); + endLine = parseIntSafe(left.substring(firstColon + 1, secondColon)); + left = left.substring(secondColon + 1); + } + } + // left is now "returnType methodName(args)"; extract the method name. + int paren = left.indexOf('('); + String beforeParen = left.substring(0, paren).trim(); + int sp = beforeParen.lastIndexOf(' '); + String originalMethod = sp < 0 ? beforeParen : beforeParen.substring(sp + 1); + List list = cm.methods.get(obfName); + if (list == null) { + list = new ArrayList(); + cm.methods.put(obfName, list); + } + list.add(new MethodMapping(originalMethod, startLine, endLine)); + } + + /** + * Inverts one frame. If the class is unknown, the frame is returned unchanged (an unmapped + * frame is better than a dropped one). Line numbers pass through -- ParparVM reports true + * source lines on real frames. + */ + public Frame retrace(Frame obfuscated) { + ClassMapping cm = byObfuscated.get(obfuscated.getClassName()); + if (cm == null) { + return obfuscated; + } + String originalMethod = obfuscated.getMethodName(); + List candidates = cm.methods.get(obfuscated.getMethodName()); + if (candidates != null && !candidates.isEmpty()) { + originalMethod = pickByLine(candidates, obfuscated.getLineNumber()); + } + String originalClass = cm.originalName; + String file = simpleSourceFile(originalClass); + return new Frame(originalClass, originalMethod, file, obfuscated.getLineNumber()); + } + + private String pickByLine(List candidates, int line) { + // Prefer a candidate whose obfuscated line range contains the frame's line. + for (MethodMapping m : candidates) { + if (m.startLine != 0 && line >= m.startLine && line <= m.endLine) { + return m.originalName; + } + } + return candidates.get(0).originalName; + } + + private static String simpleSourceFile(String fqcn) { + int d = fqcn.lastIndexOf('.'); + String simple = d < 0 ? fqcn : fqcn.substring(d + 1); + int dollar = simple.indexOf('$'); + if (dollar > 0) { + simple = simple.substring(0, dollar); + } + return simple + ".java"; + } + + private static int parseIntSafe(String s) { + try { + return Integer.parseInt(s.trim()); + } catch (NumberFormatException e) { + return 0; + } + } + + /** Number of classes in the mapping. */ + public int size() { + return byObfuscated.size(); + } +} diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/ParparVmTraceParser.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/ParparVmTraceParser.java new file mode 100644 index 00000000000..1d0d4df7eb5 --- /dev/null +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/ParparVmTraceParser.java @@ -0,0 +1,151 @@ +/* + * 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.retrace; + +import java.util.ArrayList; +import java.util.List; + +/** + * Parses the pre-rendered stack string ParparVM produces for a {@code Throwable} + * on the C targets (iOS, tvOS, watchOS, mac-native, win32, linux). The format, + * emitted by {@code java_lang_Throwable_getStack} in the translator's + * {@code nativeMethods.m}, is: + * + *

+ * <throwable class name>
+ *     at <fqcn>.<method>:<line>
+ *     at <fqcn>.<method>:<line>
+ *     ...
+ * 
+ * + *

This is the canonical, unit-tested reference for that grammar. The on-device + * {@code java.lang.Throwable.getStackTrace()} in {@code vm/JavaAPI} carries a + * hand-inlined copy of the same logic (it cannot depend on this module), so the + * two must stay in lockstep -- change them together and keep this class's tests + * green. + * + *

On the ParparVM JavaScript port the same {@code stack} field instead holds a + * JavaScript engine's {@code Error().stack}, whose frames carry {@code '('}, + * {@code '/'} or {@code '@'} -- characters a Java class or method name never + * contains. The parser rejects the whole trace in that case (returning no frames) + * rather than fabricate bogus frames from a foreign format. Parsing is + * {@code indexOf}-based and never throws: on device this code runs while another + * failure is already being reported. + */ +public final class ParparVmTraceParser { + + private ParparVmTraceParser() { + } + + /** + * Parses a ParparVM trace string into structured frames. Returns an empty + * list for {@code null}/empty input, a header-only trace, or any input that + * is not the ParparVM text format (e.g. a JavaScript {@code Error().stack}). + */ + public static List parse(String stack) { + List frames = new ArrayList(); + if (stack == null || stack.length() == 0) { + return frames; + } + int pos = 0; + int len = stack.length(); + while (pos < len) { + String line; + int nl = stack.indexOf('\n', pos); + if (nl < 0) { + line = stack.substring(pos); + pos = len; + } else { + line = stack.substring(pos, nl); + pos = nl + 1; + } + // Only " at ..." lines are frames; the class-name header and blank + // lines are skipped. + if (line.indexOf(" at ") != 0) { + continue; + } + String body = line.substring(7); + // Any of these characters means the trace is a JavaScript Error().stack + // (URLs, parentheses, or '@'), not the ParparVM text format. Bail on the + // whole trace rather than emit a made-up frame. + if (body.indexOf('(') >= 0 || body.indexOf('/') >= 0 + || body.indexOf('@') >= 0 || body.indexOf(' ') >= 0) { + return new ArrayList(); + } + int colon = body.lastIndexOf(':'); + if (colon < 0) { + continue; + } + int dot = body.lastIndexOf('.', colon - 1); + if (dot < 0) { + continue; + } + String cls = body.substring(0, dot); + String method = body.substring(dot + 1, colon); + if (cls.length() == 0 || method.length() == 0) { + continue; + } + int lineNumber = parseLineNumber(body, colon + 1); + // Synthesize a source file from the simple class name so the frame is + // not flagged native (fileName == null). ParparVM does not carry the + // original source file, so this is best-effort, not authoritative. + String fileName = simpleClassName(cls) + ".java"; + frames.add(new Frame(cls, method, fileName, lineNumber)); + } + return frames; + } + + static int parseLineNumber(String s, int from) { + int len = s.length(); + int i = from; + boolean negative = false; + if (i < len && s.charAt(i) == '-') { + negative = true; + i++; + } + int value = 0; + boolean any = false; + for (; i < len; i++) { + char c = s.charAt(i); + if (c < '0' || c > '9') { + break; + } + value = value * 10 + (c - '0'); + any = true; + } + if (!any) { + return -1; + } + return negative ? -value : value; + } + + static String simpleClassName(String fqcn) { + int d = fqcn.lastIndexOf('.'); + String simple = d < 0 ? fqcn : fqcn.substring(d + 1); + int dollar = simple.indexOf('$'); + if (dollar > 0) { + simple = simple.substring(0, dollar); + } + return simple; + } +} diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/RetraceMain.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/RetraceMain.java new file mode 100644 index 00000000000..65eae16570c --- /dev/null +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/RetraceMain.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.retrace; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; + +/** + * Standalone retrace CLI. Reads a crash trace on stdin and prints the structured + * frames, so a developer can symbolicate a report without the cloud service. + * + *

This is the entry point wired as the shaded jar's {@code Main-Class}. Mapping + * and synthetics de-obfuscation (via {@code MappingFile}/{@code SyntheticIndex}) + * are layered on as those pieces land in this module; today it parses and prints + * the ParparVM trace domain, which is the format on-device crash reports carry on + * the C targets. + */ +public final class RetraceMain { + + private RetraceMain() { + } + + public static void main(String[] args) throws Exception { + // Optional mappings: --mapping may repeat (device-nearest first, e.g. R8 then + // the cross-platform mapping). The trace is read from stdin. + MappingChain chain = loadMappings(args); + + StringBuilder in = new StringBuilder(); + BufferedReader r = new BufferedReader( + new InputStreamReader(System.in, Charset.forName("UTF-8"))); + String line; + while ((line = r.readLine()) != null) { + in.append(line).append('\n'); + } + List frames = ParparVmTraceParser.parse(in.toString()); + if (frames.isEmpty()) { + System.err.println("No ParparVM frames recognized in the input."); + return; + } + for (Frame f : frames) { + Frame out = chain.isEmpty() ? f : chain.retrace(f); + System.out.println(" " + out); + } + } + + private static MappingChain loadMappings(String[] args) throws Exception { + List files = new ArrayList(); + for (int i = 0; i < args.length - 1; i++) { + if ("--mapping".equals(args[i])) { + FileReader fr = new FileReader(new File(args[i + 1])); + try { + files.add(MappingFile.parse(fr)); + } finally { + fr.close(); + } + } + } + return new MappingChain(files); + } +} diff --git a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java new file mode 100644 index 00000000000..2480248e10c --- /dev/null +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -0,0 +1,80 @@ +/* + * 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.retrace; + +import static org.junit.Assert.assertEquals; + +import java.util.Arrays; +import org.junit.Test; + +/** Retrace of obfuscated frames through a ProGuard mapping, single and chained. */ +public class MappingFileTest { + + private static final String MAPPING = + "# Codename One App Hardening mapping\n" + + "# engine: 1.0.0\n" + + "com.example.MyForm -> zqaaaa:\n" + + " int counter -> a\n" + + " void onClick() -> b\n" + + " 142:145:java.lang.String render(int) -> c\n" + + "com.example.util.Helper -> zqaaab:\n" + + " int square(int) -> a\n"; + + @Test + public void retracesClassAndMethod() throws Exception { + MappingFile mf = MappingFile.parse(MAPPING); + assertEquals(2, mf.size()); + Frame in = new Frame("zqaaaa", "b", "zqaaaa.java", 5); + Frame out = mf.retrace(in); + assertEquals("com.example.MyForm", out.getClassName()); + assertEquals("onClick", out.getMethodName()); + assertEquals("MyForm.java", out.getFileName()); + } + + @Test + public void retracesMethodByLineRange() throws Exception { + MappingFile mf = MappingFile.parse(MAPPING); + Frame out = mf.retrace(new Frame("zqaaaa", "c", "zqaaaa.java", 143)); + assertEquals("render", out.getMethodName()); + assertEquals("com.example.MyForm", out.getClassName()); + } + + @Test + public void unknownClassPassesThroughUnchanged() throws Exception { + MappingFile mf = MappingFile.parse(MAPPING); + Frame in = new Frame("some.Other", "x", "Other.java", 9); + assertEquals(in, mf.retrace(in)); + } + + @Test + public void chainAppliesInOrder() throws Exception { + // Stage 1 (device-nearest, e.g. R8): b0 -> zqaaaa ; Stage 2 (cross-platform): zqaaaa -> MyForm. + MappingFile stage1 = MappingFile.parse("zqaaaa -> b0:\n void b() -> a\n"); + MappingFile stage2 = MappingFile.parse(MAPPING); + MappingChain chain = new MappingChain(Arrays.asList(stage1, stage2)); + Frame device = new Frame("b0", "a", "b0.java", 5); + Frame out = chain.retrace(device); + assertEquals("com.example.MyForm", out.getClassName()); + assertEquals("onClick", out.getMethodName()); + } +} diff --git a/maven/cn1-retrace/src/test/java/com/codename1/retrace/ParparVmTraceParserTest.java b/maven/cn1-retrace/src/test/java/com/codename1/retrace/ParparVmTraceParserTest.java new file mode 100644 index 00000000000..33e9273cc43 --- /dev/null +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/ParparVmTraceParserTest.java @@ -0,0 +1,103 @@ +/* + * 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.retrace; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.List; +import org.junit.Test; + +/** + * Golden tests for {@link ParparVmTraceParser}. This is the shared reference the + * on-device {@code java.lang.Throwable.getStackTrace()} in {@code vm/JavaAPI} + * mirrors, so these cases double as the contract for that hand-inlined copy. + */ +public class ParparVmTraceParserTest { + + @Test + public void parsesStandardTrace() { + String s = "java.lang.NullPointerException\n" + + " at com.example.MyForm.onClick:142\n" + + " at com.codename1.ui.Button.released:88\n"; + List frames = ParparVmTraceParser.parse(s); + assertEquals(2, frames.size()); + assertEquals("com.example.MyForm", frames.get(0).getClassName()); + assertEquals("onClick", frames.get(0).getMethodName()); + assertEquals("MyForm.java", frames.get(0).getFileName()); + assertEquals(142, frames.get(0).getLineNumber()); + assertEquals("com.codename1.ui.Button", frames.get(1).getClassName()); + assertEquals(88, frames.get(1).getLineNumber()); + } + + @Test + public void parsesInitClinitInnerClassAndNegativeLine() { + String s = "java.lang.RuntimeException\n" + + " at com.example.Foo.:42\n" + + " at com.example.Bar.:-1\n" + + " at a.b$c.run:7\n"; + List frames = ParparVmTraceParser.parse(s); + assertEquals(3, frames.size()); + assertEquals("", frames.get(0).getMethodName()); + assertEquals("", frames.get(1).getMethodName()); + assertEquals(-1, frames.get(1).getLineNumber()); + // Inner class a.b$c resolves its source file to the outer simple name. + assertEquals("a.b$c", frames.get(2).getClassName()); + assertEquals("b.java", frames.get(2).getFileName()); + assertEquals(7, frames.get(2).getLineNumber()); + } + + @Test + public void framesAreNeverFlaggedNative() { + List frames = ParparVmTraceParser.parse( + "E\n at com.example.A.b:1\n"); + assertEquals(1, frames.size()); + assertFalse("a ParparVM frame must not look native", + frames.get(0).getFileName() == null); + } + + @Test + public void rejectsV8JavaScriptStack() { + // V8 frames carry parentheses and URLs; a no-function frame is a bare URL. + String s = "Error: boom\n" + + " at onClick (http://localhost/app.js:100:5)\n" + + " at http://localhost/app.js:1:2\n"; + assertTrue("V8 Error().stack must yield no frames, never fabricated ones", + ParparVmTraceParser.parse(s).isEmpty()); + } + + @Test + public void rejectsSpiderMonkeyJavaScriptStack() { + String s = "onClick@http://localhost/app.js:100:5\n" + + "run@http://localhost/app.js:1:2\n"; + assertTrue(ParparVmTraceParser.parse(s).isEmpty()); + } + + @Test + public void emptyNullAndHeaderOnlyYieldNoFrames() { + assertTrue(ParparVmTraceParser.parse(null).isEmpty()); + assertTrue(ParparVmTraceParser.parse("").isEmpty()); + assertTrue(ParparVmTraceParser.parse("java.lang.IllegalStateException\n").isEmpty()); + } +} diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml index 5c3e29fa2e9..687bd1df13b 100644 --- a/maven/codenameone-maven-plugin/pom.xml +++ b/maven/codenameone-maven-plugin/pom.xml @@ -380,6 +380,11 @@ + + diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 6e6c8a6b06d..596717fc7dc 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -753,6 +753,18 @@ private static String escape(String str, String chars) { return str; } + @Override + protected String hardeningPlatform() { + return "and"; + } + + @Override + protected boolean hardeningRenameSupported() { + // R8 remains the sole renamer on Android; the engine only encrypts strings here and + // exports its keep rules to the generated proguard.cfg. + return false; + } + @Override public boolean build(File sourceZip, final BuildRequest request) throws BuildException { boolean facebookSupported = request.getArg("facebook.appId", null) != null; @@ -4730,7 +4742,8 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + "\n\n" + "public class " + request.getMainClass() + "Stub extends " + request.getArg("android.customActivity", "CodenameOneActivity") + "{\n"; stubSourceCode += decodeFunction(); - stubSourceCode += " public static final String BUILD_KEY = \"LOCAL_BUILD\";\n" + stubSourceCode += " public static final String BUILD_KEY = \"" + buildKeyEncoded(request) + "\";\n" + + " public static final String CN1_MAPPING_ID = \"" + resolveMappingId(request) + "\";\n" + " public static final String PACKAGE_NAME = \"" + request.getPackageName() + "\";\n" + " public static final String BUILT_BY_USER = \"" + xorEncode(request.getUserName()) + "\";\n" + " public static final String LICENSE_KEY = \"" + xorEncode(licenseKey) + "\";\n" @@ -4791,6 +4804,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + gcmSenderId + nativeThemeStubProps + " Display.getInstance().setProperty(\"build_key\", d(BUILD_KEY));\n" + + " Display.getInstance().setProperty(\"cn1.mappingId\", CN1_MAPPING_ID);\n" + " Display.getInstance().setProperty(\"package_name\", PACKAGE_NAME);\n" + " Display.getInstance().setProperty(\"built_by_user\", d(BUILT_BY_USER));\n" + useBackgroundPermissionSnippet @@ -5106,7 +5120,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + " public static final String C2DM_MESSAGE_EXTRA = \"message\";\n" + " public static final String C2DM_MESSAGE_IMAGE = \"image\";\n" + " public static final String C2DM_MESSAGE_CATEGORY = \"category\";\n" - + " public static final String BUILD_KEY = \"LOCAL_BUILD\"\n;" + + " public static final String BUILD_KEY = \"" + buildKeyEncoded(request) + "\"\n;" + " public static final String PACKAGE_NAME = \"" + request.getPackageName() + "\"\n;" + " public static final String BUILT_BY_USER = \"" + xorEncode(request.getUserName()) + "\"\n;" + " private static String KEY = \"c2dmPref\";\n" diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 147fab3cc07..ff2e29aac8c 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -1218,7 +1218,7 @@ public boolean buildNoException(final File sourceZip, final BuildRequest request Thread t = new Thread() { public void run() { try { - File s = sourceZip; + File s = hardenSourceJar(sourceZip, request); result[0] = build(s, request); } catch (Throwable err) { @@ -2340,6 +2340,252 @@ public String xorEncode(String s) { return Base64.encodeNoNewline(dat); } + /** + * The platform id this builder targets, for the hardening engine ({@code ios}, {@code and}, + * {@code javascript}, {@code win}, {@code linux}, {@code mac}, ...). Subclasses override. + */ + protected String hardeningPlatform() { + return "unknown"; + } + + /** + * Whether the hardening engine should rename for this platform. Android returns false: R8 + * remains the sole renamer there, and the engine only encrypts strings and exports keep rules. + */ + protected boolean hardeningRenameSupported() { + return true; + } + + /** Extra library jars the hardening engine should see so it does not misrename overrides. */ + protected java.util.List hardeningLibraryJars(BuildRequest request) { + return new java.util.ArrayList(); + } + + private File lastHardeningMapping; + private String lastHardeningMappingId = ""; + + /** The cross-platform obfuscation mapping produced by the last {@link #hardenSourceJar} call, or null. */ + public File getLastHardeningMapping() { + return lastHardeningMapping; + } + + /** The mapping id produced by the last {@link #hardenSourceJar} call, or empty. */ + public String getLastHardeningMappingId() { + return lastHardeningMappingId; + } + + /** + * Runs the build with hardening applied first: {@code build(hardenSourceJar(sourceZip, request), + * request)}. Callers that bypass {@link #buildNoException} (the local build paths in the maven + * plugin) invoke this instead of {@code build} directly, so hardening reaches every path. + */ + public boolean runBuild(File sourceZip, BuildRequest request) throws BuildException { + return build(hardenSourceJar(sourceZip, request), request); + } + + /** + * Applies the app-hardening transform to the merged application jar and returns the jar the + * build should proceed with. When hardening is not requested (or already applied, or declined + * by the engine) the input jar is returned unchanged; when the engine reports the build is not + * entitled, the build fails. The engine runs as a forked process so it is single-sourced across + * the plugin and the cloud daemon and never shares a classloader with the caller. + */ + public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildException { + String level = request.getArg("harden.level", "off"); + if (level == null || level.trim().length() == 0 || "off".equalsIgnoreCase(level.trim())) { + return sourceZip; + } + // The client-side pre-flight (Check 1) sets this when a local/source target opted into + // an unhardened build via harden.allowUnhardenedLocalBuild; honor it as a single point. + if ("true".equals(System.getProperty("cn1.harden.forceOff"))) { + log("cn1-hardening: forced off for this local build; building unhardened"); + return sourceZip; + } + if (isAlreadyHardened(sourceZip)) { + log("cn1-hardening: input already hardened; skipping"); + return sourceZip; + } + try { + File engine = getResourceAsFile("/cn1-hardening.jar", ".jar"); + File workDir = new File(sourceZip.getParentFile(), "cn1-harden-work"); + workDir.mkdirs(); + File hardened = new File(workDir, "hardened.jar"); + File mapping = new File(workDir, "cn1-mapping.txt"); + File report = new File(workDir, "cn1-harden-report.json"); + File config = new File(workDir, "config.properties"); + writeHardeningConfig(config, request); + + String javaBin = new File(System.getProperty("java.home"), "bin/java").getAbsolutePath(); + java.util.List cmd = new java.util.ArrayList(); + cmd.add(javaBin); + cmd.add("-jar"); + cmd.add(engine.getAbsolutePath()); + cmd.add("harden"); + cmd.add("--in"); + cmd.add(sourceZip.getAbsolutePath()); + cmd.add("--out"); + cmd.add(hardened.getAbsolutePath()); + cmd.add("--mapping"); + cmd.add(mapping.getAbsolutePath()); + cmd.add("--report"); + cmd.add(report.getAbsolutePath()); + cmd.add("--config"); + cmd.add(config.getAbsolutePath()); + + int exit = runForked(cmd, workDir); + if (exit == 0) { + lastHardeningMapping = mapping.isFile() ? mapping : null; + lastHardeningMappingId = readMappingId(mapping); + log("cn1-hardening: applied, mappingId=" + lastHardeningMappingId); + return hardened; + } + if (exit == 4) { + throw new BuildException("App hardening is an Enterprise feature and this build " + + "is not entitled. Upgrade at https://www.codenameone.com/pricing.html " + + "or set codename1.arg.harden.level=off."); + } + if (exit == 3) { + log("cn1-hardening: declined by engine; building unhardened"); + return sourceZip; + } + throw new BuildException("App hardening failed (engine exit code " + exit + + "). This build has been stopped rather than shipping a partially hardened binary."); + } catch (BuildException be) { + throw be; + } catch (Exception e) { + throw new BuildException("App hardening failed: " + e.getMessage()); + } + } + + private void writeHardeningConfig(File config, BuildRequest request) throws IOException { + java.util.Properties p = new java.util.Properties(); + for (String key : request.getArgs()) { + if (key.startsWith("harden.")) { + p.setProperty(key, request.getArg(key, "")); + } + } + p.setProperty("cn1.platform", hardeningPlatform()); + p.setProperty("cn1.mainClass", request.getMainClass() == null ? "" : request.getMainClass()); + p.setProperty("cn1.renameSupported", Boolean.toString(hardeningRenameSupported())); + // Local plugin builds are ungated: the engine is open source and a developer must be able + // to reproduce a cloud failure locally. The cloud daemon sets this from the account tier. + p.setProperty("cn1.entitled", request.getArg("cn1.entitled", "true")); + p.setProperty("cn1.buildKey", resolveBuildKey(request)); + StringBuilder libs = new StringBuilder(); + for (File lib : hardeningLibraryJars(request)) { + if (lib != null && lib.exists()) { + if (libs.length() > 0) { + libs.append(File.pathSeparator); + } + libs.append(lib.getAbsolutePath()); + } + } + p.setProperty("cn1.libraryJars", libs.toString()); + FileOutputStream fo = new FileOutputStream(config); + try { + p.store(fo, "Codename One hardening configuration"); + } finally { + fo.close(); + } + } + + private int runForked(java.util.List cmd, File workDir) throws IOException, InterruptedException { + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.directory(workDir); + pb.redirectErrorStream(true); + Process proc = pb.start(); + java.io.BufferedReader r = new java.io.BufferedReader( + new java.io.InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8)); + String line; + while ((line = r.readLine()) != null) { + log(line); + } + return proc.waitFor(); + } + + private boolean isAlreadyHardened(File jar) { + if (jar == null || !jar.isFile()) { + return false; + } + java.util.zip.ZipFile zf = null; + try { + zf = new java.util.zip.ZipFile(jar); + return zf.getEntry("META-INF/CN1-HARDENED") != null; + } catch (IOException e) { + return false; + } finally { + if (zf != null) { + try { + zf.close(); + } catch (IOException ignore) { + // best effort + } + } + } + } + + private String readMappingId(File mapping) { + if (mapping == null || !mapping.isFile()) { + return ""; + } + java.io.BufferedReader r = null; + try { + r = new java.io.BufferedReader(new java.io.InputStreamReader( + new FileInputStream(mapping), StandardCharsets.UTF_8)); + String line; + while ((line = r.readLine()) != null) { + if (line.startsWith("# mappingId:")) { + return line.substring("# mappingId:".length()).trim(); + } + } + } catch (IOException e) { + return ""; + } finally { + if (r != null) { + try { + r.close(); + } catch (IOException ignore) { + // best effort + } + } + } + return ""; + } + + /** + * The per-build key the cloud stamps into the app and that crash reports echo back so the + * server can match a report to its uploaded symbol bundle. The cloud passes it in the + * {@code cn1.buildKey} argument; when it is absent (local builds) we fall back to the + * literal {@code LOCAL_BUILD}. Historically Android hard-coded {@code "LOCAL_BUILD"} as the + * encoded constant and then ran it through {@code d()} / {@code Util.xorDecode}, + * which is not valid Base64 and decoded to junk -- always encode through this pair. + */ + public String resolveBuildKey(BuildRequest request) { + String bk = request.getArg("cn1.buildKey", null); + if(bk == null || bk.length() == 0) { + bk = "LOCAL_BUILD"; + } + return bk; + } + + /** + * The {@link #resolveBuildKey(BuildRequest) build key} in the {@code d()}-decodable encoded + * form the generated stubs embed, i.e. what a stub assigns to its {@code BUILD_KEY} constant + * before stamping {@code Display.setProperty("build_key", d(BUILD_KEY))} at runtime. + */ + public String buildKeyEncoded(BuildRequest request) { + return xorEncode(resolveBuildKey(request)); + } + + /** + * Identifier of the obfuscation mapping this build was hardened with, stamped alongside the + * build key so a crash report can be tied to the exact mapping even if a rebuilt app reused + * the build key. Empty for unhardened builds. Passed by the cloud in {@code cn1.mappingId}. + */ + public String resolveMappingId(BuildRequest request) { + return request.getArg("cn1.mappingId", ""); + } + /** * Loads global local builder properties from user's home directory. */ 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 2cd3bdff39f..bc1923e6145 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 @@ -477,6 +477,11 @@ private String podVersionRequirement(String hint, String fallback) { + @Override + protected String hardeningPlatform() { + return "ios"; + } + @Override public boolean build(File sourceZip, BuildRequest request) throws BuildException { // Builder instances are normally single-use, but keep scan-derived diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java index 36bfe743fc9..d718a6d11c1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java @@ -89,6 +89,11 @@ public File getJavaScriptDeployableArtifact() { return jsDeployableArtifact; } + @Override + protected String hardeningPlatform() { + return "javascript"; + } + @Override public boolean build(File sourceZip, BuildRequest request) throws BuildException { debug("Request Args: "); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java index 5a46a913e23..c9e0f6e60be 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java @@ -181,6 +181,11 @@ static String detectHostArch() { return ARCH_X64; } + @Override + protected String hardeningPlatform() { + return "linux"; + } + @Override public boolean build(File sourceZip, BuildRequest request) throws BuildException { String arch = normalizeArch(request.getArg("linux.arch", ARCH_X64)); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java index e4ee216b7a3..dda6718cb52 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java @@ -163,6 +163,11 @@ static String detectHostArch() { return ARCH_X64; } + @Override + protected String hardeningPlatform() { + return "win"; + } + @Override public boolean build(File sourceZip, BuildRequest request) throws BuildException { String arch = normalizeArch(request.getArg("windows.arch", ARCH_X64)); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 7ed13995086..5bad93f35f2 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -162,6 +162,8 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException } } + applyHardeningPreflight(); + try { createAntProject(); } catch (IOException ex) { @@ -173,6 +175,42 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException } } + /** + * App-hardening pre-flight (Check 1). Validates {@code harden.level} and refuses targets that + * cannot be hardened before a build is spent. Runs for every target: for cloud targets it fails + * fast client-side before submission; for local/source targets it stops (or, with the escape + * hatch, forces hardening off) because a locally built binary never reaches the server and its + * mapping would be orphaned from the crash-symbolication service. + */ + private void applyHardeningPreflight() throws MojoFailureException { + Properties settings = new Properties(); + File settingsFile = new File(getCN1ProjectDir(), "codenameone_settings.properties"); + if (settingsFile.isFile()) { + try (FileInputStream fis = new FileInputStream(settingsFile)) { + settings.load(fis); + } catch (IOException ex) { + getLog().debug("Could not read codenameone_settings.properties for hardening pre-flight", ex); + } + } + String level = settings.getProperty("codename1.arg.harden.level", "off"); + boolean allowLocal = "true".equalsIgnoreCase( + settings.getProperty("codename1.arg.harden.allowUnhardenedLocalBuild", "false").trim()); + boolean onDeviceDebug = "true".equalsIgnoreCase( + settings.getProperty("codename1.arg.android.onDeviceDebug", "false").trim()) + || (buildTarget != null && buildTarget.contains("on-device-debug")); + + HardeningPreflight.Result r = HardeningPreflight.check(level, buildTarget, allowLocal, onDeviceDebug); + if (r.isFailed()) { + throw new MojoFailureException(r.getMessage()); + } + if (r.isForceOff()) { + getLog().warn(r.getMessage()); + System.setProperty("cn1.harden.forceOff", "true"); + } else { + System.clearProperty("cn1.harden.forceOff"); + } + } + /** * Merge a set of jars into a single jar file. * @param dest The destination jar file. Also the first source if it already exists. @@ -1318,7 +1356,7 @@ private void doAndroidLocalBuild(File tmpProjectDir, Properties props, File dist try { getLog().info("Starting android project builder..."); - boolean result = e.build(distJar, request); + boolean result = e.runBuild(distJar, request); getLog().info("Android project builder completed with result "+result); if (!result) { getLog().error("Received false return value from build()"); @@ -1522,7 +1560,7 @@ private void doIOSLocalBuild(File tmpProjectDir, Properties props, File distJar) } try { - boolean result = e.build(distJar, request); + boolean result = e.runBuild(distJar, request); if (!result) { String builderLog = e.getErrorMessage(); if (builderLog != null && builderLog.trim().length() > 0) { @@ -1653,7 +1691,7 @@ private void doWindowsNativeLocalBuild(File tmpProjectDir, Properties props, Fil r.setIncludeSource(true); try { - boolean result = e.build(distJar, r); + boolean result = e.runBuild(distJar, r); if (!result) { String builderLog = e.getErrorMessage(); if (builderLog != null && builderLog.trim().length() > 0) { @@ -1664,6 +1702,8 @@ private void doWindowsNativeLocalBuild(File tmpProjectDir, Properties props, Fil if (e.getWindowsExecutable() != null) { getLog().info("Built native Windows executable: " + e.getWindowsExecutable().getAbsolutePath()); } + } catch (com.codename1.builders.BuildException hardeningEx) { + throw new MojoExecutionException(hardeningEx.getMessage(), hardeningEx); } catch (org.apache.tools.ant.BuildException ex) { String builderLog = e.getErrorMessage(); if (builderLog != null && builderLog.trim().length() > 0) { @@ -1734,7 +1774,7 @@ private void doLinuxNativeLocalBuild(File tmpProjectDir, Properties props, File r.setIncludeSource(true); try { - boolean result = e.build(distJar, r); + boolean result = e.runBuild(distJar, r); if (!result) { String builderLog = e.getErrorMessage(); if (builderLog != null && builderLog.trim().length() > 0) { @@ -1745,6 +1785,8 @@ private void doLinuxNativeLocalBuild(File tmpProjectDir, Properties props, File if (e.getLinuxExecutable() != null) { getLog().info("Built native Linux executable: " + e.getLinuxExecutable().getAbsolutePath()); } + } catch (com.codename1.builders.BuildException hardeningEx) { + throw new MojoExecutionException(hardeningEx.getMessage(), hardeningEx); } catch (org.apache.tools.ant.BuildException ex) { String builderLog = e.getErrorMessage(); if (builderLog != null && builderLog.trim().length() > 0) { @@ -1827,7 +1869,7 @@ private void doJavaScriptLocalBuild(File tmpProjectDir, Properties props, File d r.setIncludeSource(true); try { - boolean result = e.build(distJar, r); + boolean result = e.runBuild(distJar, r); if (!result) { String builderLog = e.getErrorMessage(); if (builderLog != null && builderLog.trim().length() > 0) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/HardeningPreflight.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/HardeningPreflight.java new file mode 100644 index 00000000000..e54589e64ca --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/HardeningPreflight.java @@ -0,0 +1,137 @@ +/* + * 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.maven; + +import java.util.Arrays; +import java.util.List; + +/** + * Client-side hardening pre-flight (Check 1 of three). Runs before the build is + * dispatched and catches the cases the server can never see: a local or + * source-project target that cannot be hardened, an on-device-debug build that + * must not be hardened, and an invalid {@code harden.level}. It fails loudly rather + * than let a build silently ship unhardened when the developer asked for hardening. + * + *

Pure and side-effect free so it is trivially unit-testable; the mojo feeds it + * the resolved hint values and acts on the {@link Result}. + */ +public final class HardeningPreflight { + + private static final List LEVELS = Arrays.asList("off", "standard", "aggressive", "paranoid"); + + /** The pre-flight decision. */ + public static final class Result { + private final boolean failed; + private final boolean forceOff; + private final String message; + + private Result(boolean failed, boolean forceOff, String message) { + this.failed = failed; + this.forceOff = forceOff; + this.message = message; + } + + /** True when the build must be stopped. {@link #getMessage()} explains why. */ + public boolean isFailed() { + return failed; + } + + /** True when the build may proceed but hardening must be forced off (a warning applies). */ + public boolean isForceOff() { + return forceOff; + } + + /** The failure or warning message, or {@code null} when there is nothing to say. */ + public String getMessage() { + return message; + } + + static Result ok() { + return new Result(false, false, null); + } + + static Result fail(String m) { + return new Result(true, false, m); + } + + static Result forceOff(String m) { + return new Result(false, true, m); + } + } + + private HardeningPreflight() { + } + + /** + * @param level the {@code harden.level} value (may be null / "off") + * @param buildTarget the resolved build target (e.g. {@code ios-device}, {@code local-javascript}) + * @param allowUnhardenedLocalBuild the {@code harden.allowUnhardenedLocalBuild} escape hatch + * @param onDeviceDebug whether this is an on-device-debug build + */ + public static Result check(String level, String buildTarget, + boolean allowUnhardenedLocalBuild, boolean onDeviceDebug) { + String normalized = level == null ? "off" : level.trim().toLowerCase(); + if (normalized.length() == 0) { + normalized = "off"; + } + if (!LEVELS.contains(normalized)) { + return Result.fail("Invalid harden.level '" + level + "'. Valid values are: " + + "off, standard, aggressive, paranoid. The build was stopped rather than " + + "silently treating an unrecognized value as 'off'."); + } + if ("off".equals(normalized)) { + return Result.ok(); + } + if (onDeviceDebug) { + return Result.fail("App hardening cannot be combined with an on-device-debug build: a " + + "debuggable, hardened binary is a contradiction. Remove harden.level or build " + + "a normal device target."); + } + if (isLocalOrSourceTarget(buildTarget)) { + if (allowUnhardenedLocalBuild) { + return Result.forceOff("App hardening runs on the Codename One build server; the " + + "target '" + buildTarget + "' is built locally, so this output is NOT " + + "hardened. Proceeding unhardened because " + + "harden.allowUnhardenedLocalBuild=true."); + } + return Result.fail("App hardening cannot run for the build target '" + buildTarget + + "'. Hardening runs on the Codename One build server, on the merged application " + + "jar, before translation -- a local or source-project build never reaches the " + + "server, so the project this produces would NOT be hardened. Build a cloud " + + "target (e.g. ios-device / android-device), set harden.level=off, or -- if you " + + "understand the output is unhardened -- set " + + "codename1.arg.harden.allowUnhardenedLocalBuild=true."); + } + return Result.ok(); + } + + /** True for the {@code *-source} and {@code local-*} targets, which never reach the build server. */ + public static boolean isLocalOrSourceTarget(String buildTarget) { + if (buildTarget == null) { + return false; + } + String t = buildTarget.trim().toLowerCase(); + return t.startsWith("local-") || t.endsWith("-source") || t.equals("mac-source") + || t.equals("windows-source") || t.equals("ios-source") || t.equals("android-source"); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java new file mode 100644 index 00000000000..78fb17edb16 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java @@ -0,0 +1,70 @@ +/* + * 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. + */ +package com.codename1.maven; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** The Check-1 truth table: local targets, invalid levels, on-device-debug, and the escape hatch. */ +public class HardeningPreflightTest { + + @Test + public void offIsAlwaysOk() { + assertFalse(HardeningPreflight.check("off", "ios-source", false, false).isFailed()); + assertFalse(HardeningPreflight.check(null, "local-javascript", false, false).isFailed()); + assertFalse(HardeningPreflight.check("", "android-source", false, false).isFailed()); + } + + @Test + public void invalidLevelFails() { + HardeningPreflight.Result r = HardeningPreflight.check("stanadrd", "ios-device", false, false); + assertTrue(r.isFailed()); + assertTrue(r.getMessage().contains("Invalid harden.level")); + } + + @Test + public void cloudTargetWithValidLevelIsOk() { + assertFalse(HardeningPreflight.check("standard", "ios-device", false, false).isFailed()); + assertFalse(HardeningPreflight.check("aggressive", "android-device", false, false).isFailed()); + } + + @Test + public void localTargetWithHardeningFailsUnlessAllowed() { + HardeningPreflight.Result blocked = + HardeningPreflight.check("standard", "local-javascript", false, false); + assertTrue(blocked.isFailed()); + assertTrue(blocked.getMessage().contains("build server")); + + HardeningPreflight.Result allowed = + HardeningPreflight.check("standard", "local-javascript", true, false); + assertFalse(allowed.isFailed()); + assertTrue(allowed.isForceOff()); + assertTrue(allowed.getMessage().contains("NOT")); + } + + @Test + public void sourceTargetsAreLocal() { + assertTrue(HardeningPreflight.isLocalOrSourceTarget("ios-source")); + assertTrue(HardeningPreflight.isLocalOrSourceTarget("android-source")); + assertTrue(HardeningPreflight.isLocalOrSourceTarget("mac-source")); + assertTrue(HardeningPreflight.isLocalOrSourceTarget("local-windows-device")); + assertFalse(HardeningPreflight.isLocalOrSourceTarget("ios-device")); + assertFalse(HardeningPreflight.isLocalOrSourceTarget("android-device")); + } + + @Test + public void onDeviceDebugWithHardeningFails() { + HardeningPreflight.Result r = HardeningPreflight.check("standard", "android-device", false, true); + assertTrue(r.isFailed()); + assertTrue(r.getMessage().contains("on-device-debug")); + } +} diff --git a/maven/pom.xml b/maven/pom.xml index 0ed276c4ed9..87eede3b6a7 100644 --- a/maven/pom.xml +++ b/maven/pom.xml @@ -101,11 +101,17 @@ windows linux parparvm + + cn1-hardening designer codenameone-maven-plugin cn1app-archetype cn1lib-archetype cn1-debug-proxy + cn1-retrace cn1-ai-whisper cn1-ai-stablediffusion cn1-admob @@ -371,7 +377,10 @@ com.guardsquare proguard-base - 7.2.0-beta2 + + 7.3.2 javax.xml.bind diff --git a/tests/core/test/com/codename1/crash/CrashReportPayloadTest.java b/tests/core/test/com/codename1/crash/CrashReportPayloadTest.java new file mode 100644 index 00000000000..09f4768a46c --- /dev/null +++ b/tests/core/test/com/codename1/crash/CrashReportPayloadTest.java @@ -0,0 +1,83 @@ +/* + * 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. + */ +package com.codename1.crash; + +import com.codename1.testing.AbstractTest; +import java.util.ArrayList; +import java.util.List; + +/** + * Verifies the crash payload's trace-format discriminator and that the hardening / + * raw-stack fields are emitted in the JSON. The discriminator is what tells the + * server how to parse the raw stack, so getting it exactly right matters -- a V8 + * JavaScript stack that happens to contain " at " must NOT be mistaken for the + * ParparVM text format. + */ +public class CrashReportPayloadTest extends AbstractTest { + + @Override + public boolean shouldExecuteOnEDT() { + return false; + } + + private CrashReportPayload payload(List frames, String rawStack) { + return new CrashReportPayload("evt", "java.lang.NullPointerException", + "boom", frames, null, null, rawStack); + } + + @Override + public boolean runTest() throws Exception { + List empty = new ArrayList(); + + // Structured frames present -> "structured". + List withFrame = new ArrayList(); + withFrame.add(new CrashReportPayload.Frame("com.example.A", "b", "A.java", 5, false)); + assertTrue(payload(withFrame, null).traceFormat.equals(CrashReportPayload.TRACE_STRUCTURED), + "frames present should be structured"); + + // No frames, ParparVM text raw stack -> "parparvm-text". + String parpar = "java.lang.NullPointerException\n" + + " at com.example.MyForm.onClick:142\n"; + assertTrue(payload(empty, parpar).traceFormat.equals(CrashReportPayload.TRACE_PARPARVM), + "parparvm text should be detected"); + + // No frames, V8 JS stack (has " at " but with parens/URL) -> "js-error", NOT parparvm. + String v8 = "Error: boom\n at onClick (http://localhost/app.js:100:5)\n"; + assertTrue(payload(empty, v8).traceFormat.equals(CrashReportPayload.TRACE_JS), + "a V8 stack must not be mistaken for parparvm-text"); + + // SpiderMonkey JS stack (uses '@') -> "js-error". + String sm = "onClick@http://localhost/app.js:100:5\n"; + assertTrue(payload(empty, sm).traceFormat.equals(CrashReportPayload.TRACE_JS), + "a SpiderMonkey stack is js-error"); + + // Nothing at all -> "none". + assertTrue(payload(empty, null).traceFormat.equals(CrashReportPayload.TRACE_NONE), + "no frames and no raw stack is none"); + + // JSON carries the new fields. + String json = payload(empty, parpar).toJson(); + assertTrue(json.contains("\"traceFormat\":\"parparvm-text\""), "traceFormat in json: " + json); + assertTrue(json.contains("\"rawStack\":"), "rawStack in json"); + assertTrue(json.contains("\"mappingId\":"), "mappingId in json"); + assertTrue(json.contains("\"hardenLevel\":"), "hardenLevel in json"); + + // Raw stack is capped. + StringBuilder big = new StringBuilder(); + for (int i = 0; i < CrashReportPayload.MAX_RAW_STACK_LEN + 5000; i++) { + big.append('x'); + } + CrashReportPayload capped = payload(empty, big.toString()); + assertTrue(capped.rawStack.length() == CrashReportPayload.MAX_RAW_STACK_LEN, + "raw stack capped to max length"); + + return true; + } +} diff --git a/vm/JavaAPI/src/java/lang/Throwable.java b/vm/JavaAPI/src/java/lang/Throwable.java index d87b9e11171..d26cef0ff06 100644 --- a/vm/JavaAPI/src/java/lang/Throwable.java +++ b/vm/JavaAPI/src/java/lang/Throwable.java @@ -38,6 +38,8 @@ public class Throwable{ private Throwable cause; private String stack; private java.util.List suppressed; + private StackTraceElement[] parsedStack; + private boolean stackParsed; /** @@ -114,11 +116,132 @@ public void printStackTrace(PrintWriter s) { public StackTraceElement[] getStackTrace() { - return new StackTraceElement[0]; + if(!stackParsed) { + parsedStack = parseStackString(stack); + stackParsed = true; + } + if(parsedStack == null || parsedStack.length == 0) { + return new StackTraceElement[0]; + } + StackTraceElement[] copy = new StackTraceElement[parsedStack.length]; + System.arraycopy(parsedStack, 0, copy, 0, parsedStack.length); + return copy; } - + public void setStackTrace(StackTraceElement[] el) { - + if(el == null) { + throw new NullPointerException(); + } + StackTraceElement[] copy = new StackTraceElement[el.length]; + for(int i = 0 ; i < el.length ; i++) { + if(el[i] == null) { + throw new NullPointerException(); + } + copy[i] = el[i]; + } + parsedStack = copy; + stackParsed = true; + } + + /** + * Parses the pre-rendered stack string produced by the native getStack() into + * structured frames. The format emitted on the C targets (see + * nativeMethods.m java_lang_Throwable_getStack) is a class-name header line + * followed by one " at <fqcn>.<method>:<line>" line per frame. + * + * On the ParparVM JavaScript port the same field instead holds a JavaScript + * engine's Error().stack, whose frames carry '(', '/' or '@' -- characters a + * Java class or method name never contains. We reject the whole parse in that + * case (returning no frames, the historical behaviour) rather than fabricate + * bogus frames from a foreign format. Parsing is indexOf-based on purpose: it + * runs while another failure is being reported, so it avoids regex and never + * throws. + */ + private static StackTraceElement[] parseStackString(String s) { + if(s == null || s.length() == 0) { + return new StackTraceElement[0]; + } + java.util.ArrayList frames = new java.util.ArrayList(); + int pos = 0; + int len = s.length(); + while(pos < len) { + String line; + int nl = s.indexOf('\n', pos); + if(nl < 0) { + line = s.substring(pos); + pos = len; + } else { + line = s.substring(pos, nl); + pos = nl + 1; + } + // Only " at ..." lines are frames; the class-name header and any + // blank line are skipped. + if(line.indexOf(" at ") != 0) { + continue; + } + String body = line.substring(7); + // Any of these characters means this is not the ParparVM text format + // (it is a JavaScript Error().stack, whose frames use URLs, parens or + // '@'). Bail on the whole trace rather than emit a made-up frame. + if(body.indexOf('(') >= 0 || body.indexOf('/') >= 0 + || body.indexOf('@') >= 0 || body.indexOf(' ') >= 0) { + return new StackTraceElement[0]; + } + int colon = body.lastIndexOf(':'); + if(colon < 0) { + continue; + } + int dot = body.lastIndexOf('.', colon - 1); + if(dot < 0) { + continue; + } + String cls = body.substring(0, dot); + String method = body.substring(dot + 1, colon); + if(cls.length() == 0 || method.length() == 0) { + continue; + } + int lineNumber = parseLineNumber(body, colon + 1); + // Synthesize a source file name from the simple class name so + // isNativeMethod() (fileName == null) stays false -- ParparVM does not + // carry the original source file, so this is best-effort, not authoritative. + String fileName = simpleClassName(cls) + ".java"; + frames.add(new StackTraceElement(cls, method, fileName, lineNumber)); + } + return frames.toArray(new StackTraceElement[frames.size()]); + } + + private static int parseLineNumber(String s, int from) { + int len = s.length(); + int i = from; + boolean negative = false; + if(i < len && s.charAt(i) == '-') { + negative = true; + i++; + } + int value = 0; + boolean any = false; + for(; i < len ; i++) { + char c = s.charAt(i); + if(c < '0' || c > '9') { + break; + } + value = value * 10 + (c - '0'); + any = true; + } + if(!any) { + return -1; + } + return negative ? -value : value; + } + + private static String simpleClassName(String fqcn) { + int d = fqcn.lastIndexOf('.'); + String simple = d < 0 ? fqcn : fqcn.substring(d + 1); + int dollar = simple.indexOf('$'); + if(dollar > 0) { + simple = simple.substring(0, dollar); + } + return simple; } /** From 010fe08877ade00100b54bb596d4d5978f883402 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:15:06 +0700 Subject: [PATCH 002/110] CI: complete copyright headers, /// docs, and force engine build order - Complete GPLv2+Classpath header on the 4 files the copyright gate flagged (BuildHintEditor had none; BuildHintSchemaDefaults + the two new tests were short). - Convert Hardening.java/package-info.java to /// markdown comments (core src gate). - Declare cn1-hardening:standalone as a runtime-scope plugin dependency so the reactor builds the engine before the plugin embeds it (fixes the antrun copy failing in CI); fix an illegal -- inside the new XML comment. Co-Authored-By: Claude Opus 4.8 --- .../security/hardening/Hardening.java | 58 ++++++++----------- .../security/hardening/package-info.java | 18 +++--- .../impl/javase/BuildHintEditor.java | 22 +++++++ .../impl/javase/BuildHintSchemaDefaults.java | 16 ++++- maven/codenameone-maven-plugin/pom.xml | 13 +++++ .../maven/HardeningPreflightTest.java | 13 +++++ .../crash/CrashReportPayloadTest.java | 13 +++++ 7 files changed, 108 insertions(+), 45 deletions(-) diff --git a/CodenameOne/src/com/codename1/security/hardening/Hardening.java b/CodenameOne/src/com/codename1/security/hardening/Hardening.java index 83c6b8f8be2..d1be8376a28 100644 --- a/CodenameOne/src/com/codename1/security/hardening/Hardening.java +++ b/CodenameOne/src/com/codename1/security/hardening/Hardening.java @@ -24,52 +24,44 @@ import com.codename1.ui.Display; -/** - * Read-only reporting of whether this build was hardened, and with what. - * - *

App Hardening is an Enterprise, build-server transform: it renames classes, - * encrypts strings and obfuscates control flow in the shipped binary across every - * port. This class does not perform any of that -- it only reports what the build - * server stamped into the app, so app code (and the crash reporter) can tell an - * honestly-hardened build apart from an unhardened one such as a local or - * simulator build. - * - *

The values are stamped as display properties by the build; in the simulator - * and in local builds they report {@code false} / {@code "off"}, because those are - * never obfuscated. - * - * @author Shai Almog - */ +/// Read-only reporting of whether this build was hardened, and with what. +/// +/// App Hardening is an Enterprise, build-server transform: it renames classes, +/// encrypts strings and obfuscates control flow in the shipped binary across every +/// port. This class does not perform any of that -- it only reports what the build +/// server stamped into the app, so app code (and the crash reporter) can tell an +/// honestly-hardened build apart from an unhardened one such as a local or +/// simulator build. +/// +/// The values are stamped as display properties by the build; in the simulator +/// and in local builds they report `false` / `"off"`, because those are never +/// obfuscated. +/// +/// @author Shai Almog public final class Hardening { private Hardening() { } - /** - * Whether the shipped binary was hardened. Always {@code false} in the simulator and in - * local or source-project builds, which are never obfuscated. - * - * @return true if the build server applied hardening to this build - */ + /// Whether the shipped binary was hardened. Always `false` in the simulator and in + /// local or source-project builds, which are never obfuscated. + /// + /// @return true if the build server applied hardening to this build public static boolean isHardened() { return "true".equals(Display.getInstance().getProperty("cn1.hardened", "false")); } - /** - * The hardening level the build shipped with. - * - * @return one of {@code "off"}, {@code "standard"}, {@code "aggressive"}, {@code "paranoid"} - */ + /// The hardening level the build shipped with. + /// + /// @return one of `"off"`, `"standard"`, `"aggressive"`, `"paranoid"` public static String getLevel() { return Display.getInstance().getProperty("cn1.hardenLevel", "off"); } - /** - * The id of the obfuscation mapping this build was hardened with, matching the mapping the - * build server retained for crash symbolication. Empty when the build was not hardened. - * - * @return the mapping id, or an empty string - */ + /// The id of the obfuscation mapping this build was hardened with, matching the mapping the + /// build server retained for crash symbolication. Empty when the build was not hardened. + /// + /// @return the mapping id, or an empty string public static String getMappingId() { return Display.getInstance().getProperty("cn1.mappingId", ""); } diff --git a/CodenameOne/src/com/codename1/security/hardening/package-info.java b/CodenameOne/src/com/codename1/security/hardening/package-info.java index 17ecf7274b8..cad7c75ee00 100644 --- a/CodenameOne/src/com/codename1/security/hardening/package-info.java +++ b/CodenameOne/src/com/codename1/security/hardening/package-info.java @@ -21,14 +21,12 @@ * need additional information or have any questions. */ -/** - * Read-only reporting of Codename One App Hardening status for the current build. - * - *

App Hardening is an Enterprise, build-server transform that renames classes, - * encrypts strings and obfuscates control flow in the shipped binary across every - * port, integrated with Crash Protection so obfuscated stack traces are still - * symbolicated. The engine runs on the build server; this package only lets app - * code observe whether the current build was hardened. See the App Hardening - * chapter of the developer guide. - */ +/// Read-only reporting of Codename One App Hardening status for the current build. +/// +/// App Hardening is an Enterprise, build-server transform that renames classes, +/// encrypts strings and obfuscates control flow in the shipped binary across every +/// port, integrated with Crash Protection so obfuscated stack traces are still +/// symbolicated. The engine runs on the build server; this package only lets app +/// code observe whether the current build was hardened. See the App Hardening +/// chapter of the developer guide. package com.codename1.security.hardening; diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintEditor.java b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintEditor.java index e25d3b6c4ee..f1562717a28 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintEditor.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintEditor.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.impl.javase; import javax.swing.*; diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java index 8e5e2b8012a..02afb2983a2 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java @@ -1,12 +1,24 @@ /* * 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. Oracle designates this + * 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; diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml index 687bd1df13b..bd1fbdb3b60 100644 --- a/maven/codenameone-maven-plugin/pom.xml +++ b/maven/codenameone-maven-plugin/pom.xml @@ -257,6 +257,19 @@ runtime + + + com.codenameone + cn1-hardening + ${project.version} + standalone + runtime + + diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java index 78fb17edb16..4592577bc9a 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java @@ -6,6 +6,19 @@ * 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.maven; diff --git a/tests/core/test/com/codename1/crash/CrashReportPayloadTest.java b/tests/core/test/com/codename1/crash/CrashReportPayloadTest.java index 09f4768a46c..3269aeabb4f 100644 --- a/tests/core/test/com/codename1/crash/CrashReportPayloadTest.java +++ b/tests/core/test/com/codename1/crash/CrashReportPayloadTest.java @@ -6,6 +6,19 @@ * 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.crash; From fc8822afdeb15f36d2e8b2de433a2973d4fd66f7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:18:49 +0700 Subject: [PATCH 003/110] cn1-hardening: guard ProGuard against JDK 21+ class files ProGuard 7.3.2 cannot read class files newer than JDK 20 (it fails on the JDK's own module classes), so the renamer must run on JDK 8-20 -- the cloud daemon forks the engine on JDK 17. The engine now fails with a clear message instead of a cryptic ProGuard error when renaming is requested on a too-new JVM, and the ProGuard-dependent tests skip (JUnit assumption) on JDK 21+ so the PR CI JDK-21 leg stays green. String encryption and control-flow tests have no such limit. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/HardeningEngine.java | 34 ++++++++++++++++++ .../hardening/HardeningEngineTest.java | Bin 8782 -> 9104 bytes 2 files changed, 34 insertions(+) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 4c315ce9a7a..e0b2f1d76f0 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -48,10 +48,38 @@ public final class HardeningEngine { public static final String ENGINE_VERSION = "1.0.0"; public static final String PROGUARD_VERSION = "7.3.2"; + /** Highest Java feature version whose class files ProGuard 7.3.2 can read. */ + public static final int PROGUARD_MAX_JDK = 20; private HardeningEngine() { } + /** + * Whether ProGuard can run on the current JVM. 7.3.2 cannot read class files newer than + * JDK 20 (it fails on the JDK's own module classes), so renaming must run on JDK 8-20 -- + * the cloud daemon forks the engine on JDK 17. String encryption and control flow have no + * such limit. + */ + public static boolean proguardCanRunHere() { + return currentJdkFeature() <= PROGUARD_MAX_JDK; + } + + static int currentJdkFeature() { + String v = System.getProperty("java.specification.version", "1.8"); + if (v.startsWith("1.")) { + v = v.substring(2); + } + int dot = v.indexOf('.'); + if (dot >= 0) { + v = v.substring(0, dot); + } + try { + return Integer.parseInt(v.trim()); + } catch (NumberFormatException e) { + return 8; + } + } + public static String engineVersion() { return ENGINE_VERSION; } @@ -101,6 +129,12 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi File mappingFile = req.getMappingFile(); if (cfg.isRenameEnabled()) { + if (!proguardCanRunHere()) { + throw new HardeningException("App hardening's renamer (ProGuard " + PROGUARD_VERSION + + ") must run on JDK 8-" + PROGUARD_MAX_JDK + ", but this JVM is JDK " + + currentJdkFeature() + ". The Codename One build server runs the engine on " + + "JDK 17; for a local hardened build, run it on JDK 8-" + PROGUARD_MAX_JDK + "."); + } File dict = new File(workDir, "cn1-dict.txt"); Cn1NameFactory.writeDictionary(dict, Cn1NameFactory.dictionarySizeFor(classesIn)); File renamedJar = new File(workDir, "renamed.jar"); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index 57dcf7141d7174ec10b321d54ce1c147d88e9ea4..425274394b8491042b62e9aed6108d6e3322ae09 100644 GIT binary patch delta 332 zcmX@-GQoX=wP3xzzCu7zzI$n6QHp}Op0S>hLULlBdWk|&YGR6lmy5T8k)gIia!z7# zu|isAPHM5WLPHis5GxwAwLhS(8kutKp`_vp`a)~r8K!DGe1v{O92Y< zi_-P7O7k*H^c;(eOLJ58faU?IkRqSbR1GD#17ODHrKYA7!wpr^QSbmdFEuYSFWogS wJu@#=4`@d^$jQ!$c|oOl9;rpC8k(BclOIZ$ZcZ1x#GQ)MKB`-nD*KWJ0MR;ed;kCd delta 25 ecmbQ>e$HiswczA^k~*8Mg)VV}8O!BfvH$>x#0pdZ From efa163b074a4314a52c1e0a7907330320ba167a8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:27:35 +0700 Subject: [PATCH 004/110] Address Codex P1 review: frame hierarchy, FQ main class, reactor dep - FrameClassWriter: COMPUTE_FRAMES resolved common superclasses through the engine's own classloader, which lacks the app/library classes when run as a forked jar, so any class with a merge between application types aborted hardening. Resolve the hierarchy from a classloader over the (renamed) input classes plus the library jars, falling back to Object. Threaded through the string-encryption and control-flow transforms; unit-tested. (Codex P1) - Pass the FULLY QUALIFIED main class to the keep rules: getMainClass() is the simple name, so a bare value kept a default-package class and let ProGuard rename the real application class out from under the generated stub. Fixed in both the plugin and daemon config writers. (Codex P1) - The reactor dependency forcing cn1-hardening to build before the plugin (so the engine jar exists for the embed step) already landed in the prior commit. (Codex P1) Co-Authored-By: Claude Opus 4.8 --- .../hardening/ControlFlowTransform.java | 15 +++- .../codename1/hardening/FrameClassWriter.java | 86 +++++++++++++++++++ .../codename1/hardening/HardeningEngine.java | 38 +++++++- .../hardening/StringEncryptTransform.java | 13 ++- .../hardening/FrameClassWriterTest.java | 69 +++++++++++++++ .../java/com/codename1/builders/Executor.java | 22 ++++- 6 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java create mode 100644 maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java index a569cbb3697..f48cfe9ac54 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java @@ -57,8 +57,21 @@ public final class ControlFlowTransform { static final String GUARD_FIELD = "zq$cf"; static final String GUARD_DESC = "I"; + private final ClassLoader hierarchy; private int guardedMethods; + public ControlFlowTransform() { + this(null); + } + + /** + * @param hierarchy a classloader over the (renamed) input classes plus the library jars, used + * for stack-map frame computation; may be {@code null} in tests + */ + public ControlFlowTransform(ClassLoader hierarchy) { + this.hierarchy = hierarchy; + } + public int getGuardedMethods() { return guardedMethods; } @@ -92,7 +105,7 @@ public byte[] transform(byte[] classBytes) { addGuardField(cn); initGuardField(cn); - ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES); + ClassWriter cw = new FrameClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, hierarchy); cn.accept(cw); return cw.toByteArray(); } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java new file mode 100644 index 00000000000..bf9aa6d106b --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.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.hardening; + +import org.objectweb.asm.ClassWriter; + +/** + * A {@link ClassWriter} that resolves the class hierarchy from the application and + * library jars instead of the engine's own classloader. + * + *

{@code COMPUTE_FRAMES} has to find the common superclass of two reference types + * at a control-flow join, and ASM's default implementation does that by loading the + * types through {@code getClassLoader()}. The engine runs as {@code java -jar + * cn1-hardening.jar}, so the application classes and the supplied library jars are + * not on that classloader; the default resolver would then fail with a missing-type + * exception and abort hardening on any class with a merge between application types. + * This writer is given a classloader built over the (renamed) input classes plus the + * library jars, and falls back to {@code java/lang/Object} -- always a valid, if + * imprecise, common superclass for the verifier -- when a type still can't be + * resolved, so frame computation never crashes the build. + */ +public final class FrameClassWriter extends ClassWriter { + + private final ClassLoader hierarchy; + + public FrameClassWriter(int flags, ClassLoader hierarchy) { + super(flags); + this.hierarchy = hierarchy; + } + + @Override + protected String getCommonSuperClass(String type1, String type2) { + if (hierarchy == null) { + return safeDefault(type1, type2); + } + try { + Class c1 = Class.forName(type1.replace('/', '.'), false, hierarchy); + Class c2 = Class.forName(type2.replace('/', '.'), false, hierarchy); + if (c1.isAssignableFrom(c2)) { + return type1; + } + if (c2.isAssignableFrom(c1)) { + return type2; + } + if (c1.isInterface() || c2.isInterface()) { + return "java/lang/Object"; + } + Class c = c1; + do { + c = c.getSuperclass(); + if (c == null) { + return "java/lang/Object"; + } + } while (!c.isAssignableFrom(c2)); + return c.getName().replace('.', '/'); + } catch (Throwable t) { + // A type that can't be resolved (renamed, or absent from the supplied jars): + // Object is always a safe common superclass for the verifier. + return "java/lang/Object"; + } + } + + private static String safeDefault(String type1, String type2) { + return type1.equals(type2) ? type1 : "java/lang/Object"; + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index e0b2f1d76f0..e4aac4388c1 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -127,6 +127,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi Map renamed; int renamedCount = 0; File mappingFile = req.getMappingFile(); + File hierarchyJar; if (cfg.isRenameEnabled()) { if (!proguardCanRunHere()) { @@ -142,19 +143,26 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi req.getLibraryJars(), keepRules, dict, workDir); renamed = JarDemuxer.readClasses(renamedJar); renamedCount = countRenamed(inClasses.keySet(), renamed.keySet()); + hierarchyJar = renamedJar; } else { renamed = new LinkedHashMap(inClasses); + hierarchyJar = classesJar; if (mappingFile != null) { writeText(mappingFile, ""); } } + // Classloader over the (renamed) app classes plus the library jars, so stack-map frame + // computation resolves the class hierarchy without loading types through the engine's own + // classloader (see FrameClassWriter). + ClassLoader hierarchy = buildHierarchyLoader(hierarchyJar, req.getLibraryJars()); + int seed = deriveSeed(cfg, req.getBuildKey()); int encryptedStrings = 0; boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); if (stringsApplied) { for (Map.Entry e : renamed.entrySet()) { - StringEncryptTransform t = new StringEncryptTransform(cfg.isEncryptAllStrings(), seed); + StringEncryptTransform t = new StringEncryptTransform(cfg.isEncryptAllStrings(), seed, hierarchy); byte[] out = t.transform(e.getValue()); if (out != e.getValue()) { e.setValue(out); @@ -167,7 +175,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi boolean controlFlowApplied = cfg.isControlFlow() && controlFlowSafeFor(cfg.getPlatform()); if (controlFlowApplied) { for (Map.Entry e : renamed.entrySet()) { - ControlFlowTransform t = new ControlFlowTransform(); + ControlFlowTransform t = new ControlFlowTransform(hierarchy); byte[] out = t.transform(e.getValue()); if (out != e.getValue()) { e.setValue(out); @@ -244,6 +252,32 @@ static boolean controlFlowSafeFor(String platform) { || "javase".equals(platform) || "desktop".equals(platform); } + /** + * A classloader over the (renamed) application classes plus the library jars, for stack-map + * frame computation. JDK library classes resolve through the parent (bootstrap) loader, so the + * jmods are intentionally not added -- URLClassLoader can't read them and java.* resolves via + * the parent anyway. Never initializes classes (FrameClassWriter uses initialize=false). + */ + private static ClassLoader buildHierarchyLoader(File hierarchyJar, List libraryJars) { + List urls = new ArrayList(); + try { + if (hierarchyJar != null && hierarchyJar.isFile()) { + urls.add(hierarchyJar.toURI().toURL()); + } + if (libraryJars != null) { + for (File lib : libraryJars) { + if (lib != null && lib.isFile()) { + urls.add(lib.toURI().toURL()); + } + } + } + } catch (java.net.MalformedURLException e) { + return HardeningEngine.class.getClassLoader(); + } + return new java.net.URLClassLoader(urls.toArray(new java.net.URL[urls.size()]), + HardeningEngine.class.getClassLoader()); + } + private static int deriveSeed(HardeningConfig cfg, String buildKey) { String basis = cfg.getSeed() != null ? cfg.getSeed() : (buildKey == null || buildKey.isEmpty() ? "cn1-hardening" : buildKey); diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 4c01b9e15ae..905dfa58d0e 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -64,11 +64,22 @@ public final class StringEncryptTransform { private final boolean encryptAllStrings; private final int seed; + private final ClassLoader hierarchy; private int encryptedCount; public StringEncryptTransform(boolean encryptAllStrings, int seed) { + this(encryptAllStrings, seed, null); + } + + /** + * @param hierarchy a classloader over the (renamed) input classes plus the library jars, used + * for stack-map frame computation so it never loads types through the engine's + * own classloader; may be {@code null} in tests with no app-type merges + */ + public StringEncryptTransform(boolean encryptAllStrings, int seed, ClassLoader hierarchy) { this.encryptAllStrings = encryptAllStrings; this.seed = seed; + this.hierarchy = hierarchy; } public int getEncryptedCount() { @@ -116,7 +127,7 @@ public byte[] transform(byte[] classBytes) { addDecoder(cn, base); - ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES); + ClassWriter cw = new FrameClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, hierarchy); cn.accept(cw); return cw.toByteArray(); } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java new file mode 100644 index 00000000000..804bd92c129 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java @@ -0,0 +1,69 @@ +/* + * 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.hardening; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +/** + * The frame-hierarchy resolver must find common superclasses from the supplied + * classloader (not the engine's own), and must fall back to Object rather than + * throw when a type is unresolvable -- otherwise COMPUTE_FRAMES would abort + * hardening on any class with a merge between application types (Codex P1). + */ +public class FrameClassWriterTest { + + // Same package, so the protected getCommonSuperClass is directly callable. + private String common(ClassLoader cl, String a, String b) { + return new FrameClassWriter(0, cl).getCommonSuperClass(a, b); + } + + @Test + public void resolvesCommonSuperFromLoader() { + ClassLoader cl = getClass().getClassLoader(); + assertEquals("java/lang/Number", common(cl, "java/lang/Integer", "java/lang/Long")); + assertEquals("java/util/AbstractList", common(cl, "java/util/ArrayList", "java/util/Vector")); + assertEquals("java/lang/Object", common(cl, "java/lang/String", "java/lang/Integer")); + } + + @Test + public void identicalTypeReturnsItself() { + assertEquals("java/lang/String", common(getClass().getClassLoader(), + "java/lang/String", "java/lang/String")); + } + + @Test + public void unresolvableTypeFallsBackToObjectNotThrow() { + // A type absent from the loader (e.g. a renamed app class not on the engine classpath) + // must NOT crash frame computation. + assertEquals("java/lang/Object", + common(getClass().getClassLoader(), "totally/Missing", "java/lang/String")); + } + + @Test + public void nullLoaderIsSafe() { + assertEquals("java/lang/Object", common(null, "a/B", "c/D")); + assertEquals("a/B", common(null, "a/B", "a/B")); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index ff2e29aac8c..b17fb3e4e38 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2465,7 +2465,10 @@ private void writeHardeningConfig(File config, BuildRequest request) throws IOEx } } p.setProperty("cn1.platform", hardeningPlatform()); - p.setProperty("cn1.mainClass", request.getMainClass() == null ? "" : request.getMainClass()); + // The keep rule must name the FULLY QUALIFIED main class: getMainClass() is the simple name + // (the stubs combine it with getPackageName()), so passing it bare would keep a default-package + // class and let ProGuard rename the real application class out from under the generated stub. + p.setProperty("cn1.mainClass", fullyQualifiedMainClass(request)); p.setProperty("cn1.renameSupported", Boolean.toString(hardeningRenameSupported())); // Local plugin builds are ungated: the engine is open source and a developer must be able // to reproduce a cloud failure locally. The cloud daemon sets this from the account tier. @@ -2489,6 +2492,23 @@ private void writeHardeningConfig(File config, BuildRequest request) throws IOEx } } + /** The fully qualified main class: {@code getPackageName().getMainClass()} unless already qualified. */ + private String fullyQualifiedMainClass(BuildRequest request) { + String main = request.getMainClass(); + if (main == null || main.trim().length() == 0) { + return ""; + } + main = main.trim(); + if (main.indexOf('.') >= 0) { + return main; + } + String pkg = request.getPackageName(); + if (pkg == null || pkg.trim().length() == 0) { + return main; + } + return pkg.trim() + "." + main; + } + private int runForked(java.util.List cmd, File workDir) throws IOException, InterruptedException { ProcessBuilder pb = new ProcessBuilder(cmd); pb.directory(workDir); From 605f722c411742cf519ada4554771394c71703d6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:52:39 +0700 Subject: [PATCH 005/110] Address Codex round-2 review + fix core/CI build breaks Codex P1/P2: - harden.keep: split on newlines only (a ';' is legal inside a rule body). - Keep SourceFile,LineNumberTable so ParparVM/native traces keep real line numbers for retrace. - Honor constants-vs-all string mode: 'constants' encrypts only values declared as static-final String constants (and javac's inlined copies), 'all' encrypts every literal. - Propagate cn1.mappingId/cn1.hardened/cn1.hardenLevel into the request before stub generation; Android stub now stamps them (Hardening.isHardened(), crash report mappingId/level). - Supply the compile/platform classpath to ProGuard as library jars so an app method overriding a framework method is not renamed apart from its superclass. - Append harden.keep + the name-bound PropertyBusinessObject keep to Android's R8 config (Android keeps R8 as sole renamer). Build fixes: - CrashProtection.safeRawStack: build the raw stack with StringBuilder instead of java.io.PrintWriter, which the core's CLDC11 bootclasspath (ANT build) lacks. - Embed the engine jar via maven-dependency-plugin:copy (resolves the standalone artifact from the reactor/repo) so partial plugin-only CI builds no longer fail copying from an unbuilt sibling target/. - Keep the test resource bytes ASCII (explicit byte[] rather than a non-ASCII literal). Co-Authored-By: Claude Opus 4.8 --- .../com/codename1/crash/CrashProtection.java | 31 +++++++-- .../codename1/hardening/BuiltinKeepRules.java | 7 +- .../codename1/hardening/HardeningConfig.java | 4 +- .../codename1/hardening/HardeningEngine.java | 13 +++- .../hardening/StringEncryptTransform.java | 60 +++++++++++++++--- .../hardening/HardeningEngineTest.java | Bin 9104 -> 9867 bytes maven/codenameone-maven-plugin/pom.xml | 38 +++++++++-- .../builders/AndroidGradleBuilder.java | 35 ++++++++++ .../java/com/codename1/builders/Executor.java | 32 +++++++++- .../com/codename1/maven/CN1BuildMojo.java | 23 +++++++ 10 files changed, 220 insertions(+), 23 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/CrashProtection.java b/CodenameOne/src/com/codename1/crash/CrashProtection.java index 5fb6c1291c4..a465aa6663a 100644 --- a/CodenameOne/src/com/codename1/crash/CrashProtection.java +++ b/CodenameOne/src/com/codename1/crash/CrashProtection.java @@ -254,11 +254,32 @@ static CrashReportPayload build(Throwable t) { /// Swallows any failure: capturing a crash report must never itself crash. private static String safeRawStack(Throwable t) { try { - java.io.StringWriter sw = new java.io.StringWriter(); - java.io.PrintWriter pw = new java.io.PrintWriter(sw); - t.printStackTrace(pw); - pw.flush(); - String s = sw.toString(); + // Built by hand rather than via printStackTrace(PrintWriter): the core is compiled + // against a restricted (CLDC-like) API that has no java.io.PrintWriter. The frame + // lines use the " at .:" shape -- the same the ParparVM native + // trace uses -- so the trace-format sniffer classifies it correctly when structured + // frames are unavailable. getStackTrace() now returns frames on every port. + StringBuilder sb = new StringBuilder(); + Throwable cur = t; + int depth = 0; + while (cur != null && depth < 8) { + if (depth > 0) { + sb.append("Caused by: "); + } + sb.append(cur.toString()).append('\n'); + StackTraceElement[] els = cur.getStackTrace(); + int limit = els.length < CrashReportPayload.MAX_FRAMES + ? els.length : CrashReportPayload.MAX_FRAMES; + for (int i = 0; i < limit; i++) { + StackTraceElement e = els[i]; + sb.append(" at ").append(e.getClassName()).append('.') + .append(e.getMethodName()).append(':').append(e.getLineNumber()) + .append('\n'); + } + cur = cur.getCause(); + depth++; + } + String s = sb.toString(); return s.length() == 0 ? null : s; } catch (Throwable ignored) { return null; diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java index 6cdbc192f79..7907825b3e3 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java @@ -94,7 +94,12 @@ public static List flags() { r.add("-dontusemixedcaseclassnames"); r.add("-dontnote"); r.add("-dontwarn"); - r.add("-keepattributes Exceptions,InnerClasses,Signature,EnclosingMethod,*Annotation*"); + // Keep SourceFile + LineNumberTable: ParparVM translates the line table into its + // on-device debug-line info, and the crash retrace passes device line numbers through + // rather than reconstructing them, so stripping the tables would make every hardened + // trace report unknown/-1 lines. The renamed names still hide the code; line tables don't. + r.add("-keepattributes Exceptions,InnerClasses,Signature,EnclosingMethod,*Annotation*," + + "SourceFile,LineNumberTable"); return r; } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java index ad6be0a9e91..150d1193722 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java @@ -106,7 +106,9 @@ public static HardeningConfig from(Map hints, String platform, b List keep = new ArrayList(); String keepRaw = get(hints, "harden.keep", null); if (keepRaw != null) { - for (String rule : keepRaw.split("[\\n;]")) { + // Split only on newlines: a semicolon is legal ProGuard syntax inside a rule body + // (e.g. "-keep class com.example.Foo { *; }"), so splitting on ';' would shred rules. + for (String rule : keepRaw.split("\\r?\\n")) { String t = rule.trim(); if (!t.isEmpty()) { keep.add(t); diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index e4aac4388c1..b03cfb493ca 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -161,8 +161,19 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int encryptedStrings = 0; boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); if (stringsApplied) { + // In "constants" mode, first collect the values declared as static-final String + // constants across the whole jar, so we encrypt exactly those (and javac's inlined + // copies) and nothing incidental. + java.util.Set constantValues = null; + if (!cfg.isEncryptAllStrings()) { + constantValues = new java.util.HashSet(); + for (byte[] cls : renamed.values()) { + StringEncryptTransform.collectConstantValues(cls, constantValues); + } + } for (Map.Entry e : renamed.entrySet()) { - StringEncryptTransform t = new StringEncryptTransform(cfg.isEncryptAllStrings(), seed, hierarchy); + StringEncryptTransform t = new StringEncryptTransform( + cfg.isEncryptAllStrings(), seed, hierarchy, constantValues); byte[] out = t.transform(e.getValue()); if (out != e.getValue()) { e.setValue(out); diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 905dfa58d0e..ba85467c330 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -65,21 +65,48 @@ public final class StringEncryptTransform { private final boolean encryptAllStrings; private final int seed; private final ClassLoader hierarchy; + private final java.util.Set constantValues; private int encryptedCount; public StringEncryptTransform(boolean encryptAllStrings, int seed) { - this(encryptAllStrings, seed, null); + this(encryptAllStrings, seed, null, null); + } + + public StringEncryptTransform(boolean encryptAllStrings, int seed, ClassLoader hierarchy) { + this(encryptAllStrings, seed, hierarchy, null); } /** - * @param hierarchy a classloader over the (renamed) input classes plus the library jars, used - * for stack-map frame computation so it never loads types through the engine's - * own classloader; may be {@code null} in tests with no app-type merges + * @param hierarchy a classloader over the (renamed) input classes plus the library jars, + * used for stack-map frame computation so it never loads types through the + * engine's own classloader; may be {@code null} in tests + * @param constantValues in "constants" mode ({@code encryptAllStrings == false}), the set of + * string values that were declared as {@code static final String} + * constants across the jar; only those literals (including javac's inlined + * copies at every read site) are encrypted. Ignored in "all" mode. May be + * {@code null}, in which case constants mode encrypts nothing extra. */ - public StringEncryptTransform(boolean encryptAllStrings, int seed, ClassLoader hierarchy) { + public StringEncryptTransform(boolean encryptAllStrings, int seed, ClassLoader hierarchy, + java.util.Set constantValues) { this.encryptAllStrings = encryptAllStrings; this.seed = seed; this.hierarchy = hierarchy; + this.constantValues = constantValues; + } + + /** Collects the values of {@code static final String} fields in {@code classBytes} into {@code out}. */ + public static void collectConstantValues(byte[] classBytes, final java.util.Set out) { + new ClassReader(classBytes).accept(new org.objectweb.asm.ClassVisitor(Opcodes.ASM9) { + @Override + public org.objectweb.asm.FieldVisitor visitField(int access, String name, String desc, + String sig, Object value) { + if ((access & Opcodes.ACC_STATIC) != 0 && (access & Opcodes.ACC_FINAL) != 0 + && value instanceof String) { + out.add((String) value); + } + return null; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); } public int getEncryptedCount() { @@ -105,7 +132,10 @@ public byte[] transform(byte[] classBytes) { int base = keyBase(cn.name); boolean changed = false; - // Channel 1: LDC string literals in method bodies. + // Channel 1: LDC string literals in method bodies. In "all" mode every literal is + // encrypted; in "constants" mode only literals whose value was declared as a + // static-final String constant somewhere in the jar -- which is exactly the set javac + // inlined at these read sites -- so ordinary incidental literals are left alone. if (cn.methods != null) { for (MethodNode mn : cn.methods) { if (mn.instructions == null) { @@ -118,7 +148,7 @@ public byte[] transform(byte[] classBytes) { } } - // Channel 2: static final String ConstantValue attributes. + // Channel 2: static final String ConstantValue attributes (both modes). changed |= encryptStaticFinalStrings(cn, base); if (!changed) { @@ -139,7 +169,7 @@ private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base) { AbstractInsnNode next = insn.getNext(); if (insn instanceof LdcInsnNode) { LdcInsnNode ldc = (LdcInsnNode) insn; - if (ldc.cst instanceof String && shouldEncrypt((String) ldc.cst)) { + if (ldc.cst instanceof String && shouldEncryptLiteral((String) ldc.cst)) { String plain = (String) ldc.cst; ldc.cst = encode(plain, base); mn.instructions.insert(ldc, new MethodInsnNode( @@ -273,6 +303,20 @@ private boolean shouldEncrypt(String s) { return true; } + /** + * A method-body literal is encrypted in "all" mode, or in "constants" mode only when its value + * was declared as a static-final String constant somewhere in the jar (javac inlined those here). + */ + private boolean shouldEncryptLiteral(String s) { + if (!shouldEncrypt(s)) { + return false; + } + if (encryptAllStrings) { + return true; + } + return constantValues != null && constantValues.contains(s); + } + /** Encodes a string by XORing each char with a position-dependent key derived from {@code base}. */ static String encode(String plain, int base) { char[] c = plain.toCharArray(); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index 425274394b8491042b62e9aed6108d6e3322ae09..a24b2453978d3d2ffd050c33c023acfb62915dd7 100644 GIT binary patch delta 890 zcmcgqL2FY%5Jn_g1rJtD6f6$8ygbNjQ;M`$6xvE9P-{u>Bpx>JP4c#Gb|1U@k~~Fo z6+9^HfAB2i2YB%h_yhba;%r`%_Ta^fd-#@_ot>HQoB8tW)AMgXR;C*p@Dv5_p=K6E z0bIr+Ptd^e%OhYifs_wY=oOY+RzarK2IT{`Zj$tmK6rFTqEpt58CZ@_kATgl{lW)dG-3;Je5!9PjC=4 zAvpH1=^^s4@8QV9i-p;mv|3GQ_zpv8O%4w)7(4XRnrxFRcpJQ*HLq1}OuLrYGM&I< zh?S(Cq%|;92|g;DwL~k9`dl&rdnfH>5O4!1V_jqx6KItBaXA2P&9ZUQflI+9MQCIR z8Nf2$2*X`qkO{_RABJdCm4M)uW+CW)gY(J*jcDfy4G8R8U_wb;znFU;Q#Rrxr*DQ! z%VG=TltdSbB~%kR>8)YR3iqM<(WSK~uDg26ZPgySE46#GueG4^-}uKW^ILYtzX`pZ z7(uq>MfgVz+|B!U+~)@scXh3Lc{AK~(`skCo?tAPCf18F!Z0@$dU!sjdbyqDLY3%# vR(IU*yTPT0?4$;YloWc%D&VGivL9W66p+U~Bx=ud+PPz*t+XP@<5WlUQ7=P@Y(< zP+U@!nU}7cnwMNuSx}OiVx>@!lbD%Tl3G!ske;8Hs-uvUnvqzRnO|C@02EG4E>S4Y zNX=8o$xlp4P0^doEgv*lTf%hmGYQGb0U{zmcJb!_66&l9PNgMK!+{p17Nr6?nK?NM kaEm72kTYO5*0i4NFCq%mr7OUtfC?t7$SZH2pzxa&0G~uwAOHXW diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml index bd1fbdb3b60..206e4015d27 100644 --- a/maven/codenameone-maven-plugin/pom.xml +++ b/maven/codenameone-maven-plugin/pom.xml @@ -376,6 +376,39 @@ 3.2.5 + + org.apache.maven.plugins + maven-dependency-plugin + + + + embed-hardening-engine + generate-resources + + copy + + + + + com.codenameone + cn1-hardening + ${project.version} + standalone + jar + ${project.build.outputDirectory} + cn1-hardening.jar + + + true + true + + + + org.apache.maven.plugins maven-antrun-plugin @@ -393,11 +426,6 @@ - - diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 596717fc7dc..8650e36284e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -758,6 +758,32 @@ protected String hardeningPlatform() { return "and"; } + /** + * R8 keep rules contributed by app hardening, appended to the generated {@code proguard.cfg}. + * On Android the engine does not rename, so the user's {@code harden.keep} rules and the + * name-bound property-object rule (renaming a {@code PropertyBusinessObject}'s members silently + * changes JSON/DB schema) must be handed to R8 here. Empty when hardening is off. + */ + private String hardeningR8Keep(BuildRequest request) { + String level = request.getArg("harden.level", "off"); + if (level == null || level.trim().length() == 0 || "off".equalsIgnoreCase(level.trim())) { + return ""; + } + StringBuilder sb = new StringBuilder(); + sb.append("-keepclassmembernames class * implements " + + "com.codename1.properties.PropertyBusinessObject { *; }\n"); + String keep = request.getArg("harden.keep", ""); + if (keep != null && keep.trim().length() > 0) { + // Newlines only: a ';' is legal inside a ProGuard rule body. + for (String rule : keep.split("\\r?\\n")) { + if (rule.trim().length() > 0) { + sb.append(rule.trim()).append('\n'); + } + } + } + return sb.toString(); + } + @Override protected boolean hardeningRenameSupported() { // R8 remains the sole renamer on Android; the engine only encrypts strings here and @@ -4744,6 +4770,8 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { stubSourceCode += decodeFunction(); stubSourceCode += " public static final String BUILD_KEY = \"" + buildKeyEncoded(request) + "\";\n" + " public static final String CN1_MAPPING_ID = \"" + resolveMappingId(request) + "\";\n" + + " public static final String CN1_HARDENED = \"" + request.getArg("cn1.hardened", "false") + "\";\n" + + " public static final String CN1_HARDEN_LEVEL = \"" + request.getArg("cn1.hardenLevel", "off") + "\";\n" + " public static final String PACKAGE_NAME = \"" + request.getPackageName() + "\";\n" + " public static final String BUILT_BY_USER = \"" + xorEncode(request.getUserName()) + "\";\n" + " public static final String LICENSE_KEY = \"" + xorEncode(licenseKey) + "\";\n" @@ -4805,6 +4833,8 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + nativeThemeStubProps + " Display.getInstance().setProperty(\"build_key\", d(BUILD_KEY));\n" + " Display.getInstance().setProperty(\"cn1.mappingId\", CN1_MAPPING_ID);\n" + + " Display.getInstance().setProperty(\"cn1.hardened\", CN1_HARDENED);\n" + + " Display.getInstance().setProperty(\"cn1.hardenLevel\", CN1_HARDEN_LEVEL);\n" + " Display.getInstance().setProperty(\"package_name\", PACKAGE_NAME);\n" + " Display.getInstance().setProperty(\"built_by_user\", d(BUILT_BY_USER));\n" + useBackgroundPermissionSnippet @@ -5521,6 +5551,11 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { : "") + facebookProguard + " " + request.getArg("android.proguardKeep", "") + "\n" + // App-hardening keep rules for R8. On Android the engine does not rename (R8 is the + // sole renamer), so the user's harden.keep and the name-bound property-object rule + // must reach R8 here or a dynamically-resolved class can still be renamed and fail + // only in the hardened release. + + hardeningR8Keep(request) + (usesHealthStore ? HealthManifestFragments.proguardKeepRules( new java.util.ArrayList( diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index b17fb3e4e38..8aab81d2e54 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2356,9 +2356,31 @@ protected boolean hardeningRenameSupported() { return true; } - /** Extra library jars the hardening engine should see so it does not misrename overrides. */ + /** + * Library jars the hardening engine passes to ProGuard so it can see inherited framework APIs + * and not rename an application method that overrides a framework method (which would break + * dispatch at runtime). The caller supplies the compile/platform classpath in the + * {@code cn1.hardening.libraryJars} request argument (path-separated); subclasses may add more. + */ protected java.util.List hardeningLibraryJars(BuildRequest request) { - return new java.util.ArrayList(); + java.util.List jars = new java.util.ArrayList(); + String raw = request.getArg("cn1.hardening.libraryJars", ""); + if (raw == null || raw.length() == 0) { + // Fallback: the maven plugin publishes the compile classpath here (a single injection + // point rather than threading it through every local-build request). + raw = System.getProperty("cn1.hardening.libraryJars", ""); + } + if (raw != null && raw.length() > 0) { + for (String p : raw.split(java.util.regex.Pattern.quote(File.pathSeparator))) { + if (p != null && p.trim().length() > 0) { + File f = new File(p.trim()); + if (f.exists()) { + jars.add(f); + } + } + } + } + return jars; } private File lastHardeningMapping; @@ -2436,6 +2458,12 @@ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildEx if (exit == 0) { lastHardeningMapping = mapping.isFile() ? mapping : null; lastHardeningMappingId = readMappingId(mapping); + // Propagate the mapping id / hardened flag / level into the request BEFORE the + // builder generates its stubs, so the stubs stamp them as runtime properties + // (Hardening.isHardened(), the crash report's mappingId/hardenLevel). + request.putArgument("cn1.mappingId", lastHardeningMappingId); + request.putArgument("cn1.hardened", "true"); + request.putArgument("cn1.hardenLevel", level.trim().toLowerCase()); log("cn1-hardening: applied, mappingId=" + lastHardeningMappingId); return hardened; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 5bad93f35f2..7b5df56435d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -209,6 +209,29 @@ private void applyHardeningPreflight() throws MojoFailureException { } else { System.clearProperty("cn1.harden.forceOff"); } + // Publish the compile classpath so the hardening engine can hand it to ProGuard as library + // jars (so an application method that overrides a framework method is not renamed apart from + // its superclass). Only needed when hardening will actually run. + if (!"off".equalsIgnoreCase(level.trim()) && !r.isForceOff()) { + try { + List cp = project.getCompileClasspathElements(); + StringBuilder sb = new StringBuilder(); + for (String element : cp) { + File f = new File(element); + if (f.isFile() && element.endsWith(".jar")) { + if (sb.length() > 0) { + sb.append(File.pathSeparator); + } + sb.append(f.getAbsolutePath()); + } + } + System.setProperty("cn1.hardening.libraryJars", sb.toString()); + } catch (org.apache.maven.artifact.DependencyResolutionRequiredException ex) { + getLog().debug("Could not resolve compile classpath for hardening library jars", ex); + } + } else { + System.clearProperty("cn1.hardening.libraryJars"); + } } /** From 0fffb4c59fbf030521df0f040114f1a9975bc6ab Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:59:36 +0700 Subject: [PATCH 006/110] Address Codex round-3 review (verifier hierarchy, service descriptors, Select delimiters) - OutputVerifier: pass the input/library hierarchy classloader to CheckClassAdapter.verify so the final verification pass resolves application types instead of loading them from the engine's classpath (a class with a merge between app types would otherwise fail verification). (P1) - Keep every class named by a META-INF/services/* descriptor (the service interface and each provider), since the descriptors are copied verbatim and ServiceLoader would break if they were renamed; regression-tested. (P1) - Terminate the hardening Select .values lists with their delimiter, which BuildHintEditor reads as the last character, so the simulator shows the real options instead of splitting on a letter. (P2) Co-Authored-By: Claude Opus 4.8 --- .../impl/javase/BuildHintSchemaDefaults.java | 8 +-- .../codename1/hardening/HardeningEngine.java | 50 ++++++++++++++++++- .../codename1/hardening/OutputVerifier.java | 13 +++-- .../hardening/HardeningEngineTest.java | 35 +++++++++++++ 4 files changed, 98 insertions(+), 8 deletions(-) diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java index 02afb2983a2..acd45bd516a 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java @@ -83,7 +83,7 @@ private static void registerHardening() { set("{{#hardening#harden.level}}.label", "Hardening level"); set("{{#hardening#harden.level}}.type", "Select"); - set("{{#hardening#harden.level}}.values", "off,standard,aggressive,paranoid"); + set("{{#hardening#harden.level}}.values", "off,standard,aggressive,paranoid,"); set("{{#hardening#harden.level}}.description", "off = no hardening. standard = renaming + constant-string encryption. " + "aggressive = + all-string encryption + control flow. paranoid = + opaque " @@ -92,13 +92,13 @@ private static void registerHardening() { set("{{#hardening#harden.strings}}.label", "String encryption"); set("{{#hardening#harden.strings}}.type", "Select"); - set("{{#hardening#harden.strings}}.values", "off,constants,all"); + set("{{#hardening#harden.strings}}.values", "off,constants,all,"); set("{{#hardening#harden.strings}}.description", "Override string encryption independently of the level."); set("{{#hardening#harden.controlFlow}}.label", "Control-flow obfuscation"); set("{{#hardening#harden.controlFlow}}.type", "Select"); - set("{{#hardening#harden.controlFlow}}.values", "off,on"); + set("{{#hardening#harden.controlFlow}}.values", "off,on,"); set("{{#hardening#harden.controlFlow}}.description", "Override control-flow obfuscation. Applied on Android and desktop only; left off " + "the ParparVM native ports where it fights the translator's optimizer."); @@ -111,7 +111,7 @@ private static void registerHardening() { set("{{#hardening#harden.allowUnhardenedLocalBuild}}.label", "Allow unhardened local build"); set("{{#hardening#harden.allowUnhardenedLocalBuild}}.type", "Select"); - set("{{#hardening#harden.allowUnhardenedLocalBuild}}.values", "false,true"); + set("{{#hardening#harden.allowUnhardenedLocalBuild}}.values", "false,true,"); set("{{#hardening#harden.allowUnhardenedLocalBuild}}.description", "Let a local or source-project target build unhardened instead of failing the " + "pre-flight. The output is NOT hardened."); diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index b03cfb493ca..f13f33b9064 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -122,6 +122,9 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi InputJarKeepScanner scanner = new InputJarKeepScanner(); scanner.scan(inClasses); keepRules.addAll(scanner.keepRules()); + // Keep classes named by META-INF/services descriptors: those files are copied verbatim, so + // ServiceLoader would fail if the service interface or a provider class were renamed. + keepRules.addAll(serviceDescriptorKeeps(nonClass)); keepRules.addAll(cfg.getExtraKeepRules()); Map renamed; @@ -196,7 +199,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi } MangleCollisionCheck.check(renamed.keySet()); - OutputVerifier.verify(renamed); + OutputVerifier.verify(renamed, hierarchy); // Idempotence marker: a nested builder delegation must not harden twice. nonClass.asMap().put("META-INF/CN1-HARDENED", @@ -289,6 +292,51 @@ private static ClassLoader buildHierarchyLoader(File hierarchyJar, List li HardeningEngine.class.getClassLoader()); } + /** + * Keep rules for every class named by a {@code META-INF/services/*} descriptor -- the service + * interface (the file name) and each provider class listed inside. The descriptors are carried + * across verbatim, so renaming any of these would break {@code ServiceLoader}. + */ + private static List serviceDescriptorKeeps(JarDemuxer.NonClassEntries nonClass) { + List rules = new ArrayList(); + java.util.Set seen = new java.util.HashSet(); + String prefix = "META-INF/services/"; + for (Map.Entry e : nonClass.asMap().entrySet()) { + String name = e.getKey(); + if (!name.startsWith(prefix) || name.length() <= prefix.length()) { + continue; + } + addServiceKeep(rules, seen, name.substring(prefix.length())); + String body = new String(e.getValue(), java.nio.charset.Charset.forName("UTF-8")); + for (String line : body.split("\\r?\\n")) { + int hash = line.indexOf('#'); + if (hash >= 0) { + line = line.substring(0, hash); + } + addServiceKeep(rules, seen, line.trim()); + } + } + return rules; + } + + private static void addServiceKeep(List rules, java.util.Set seen, String className) { + String c = className.trim(); + if (c.length() == 0 || !isPlausibleClassName(c) || !seen.add(c)) { + return; + } + rules.add("-keep class " + c + " { *; }"); + } + + private static boolean isPlausibleClassName(String s) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (!Character.isJavaIdentifierPart(c) && c != '.' && c != '$') { + return false; + } + } + return true; + } + private static int deriveSeed(HardeningConfig cfg, String buildKey) { String basis = cfg.getSeed() != null ? cfg.getSeed() : (buildKey == null || buildKey.isEmpty() ? "cn1-hardening" : buildKey); diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java index e5bd113ba95..1d7c87de74c 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java @@ -39,12 +39,19 @@ public final class OutputVerifier { private OutputVerifier() { } - /** @throws HardeningException on the first class that fails verification, naming it. */ - public static void verify(Map classesByInternalName) throws HardeningException { + /** + * @param hierarchy a classloader over the (renamed) input classes plus the library jars, so the + * verifier's {@code SimpleVerifier} resolves application types instead of + * loading them from the engine's own classpath (which would fail verification + * on any class with a merge between application types). May be {@code null}. + * @throws HardeningException on the first class that fails verification, naming it. + */ + public static void verify(Map classesByInternalName, ClassLoader hierarchy) + throws HardeningException { for (Map.Entry e : classesByInternalName.entrySet()) { StringWriter sw = new StringWriter(); try { - CheckClassAdapter.verify(new ClassReader(e.getValue()), false, new PrintWriter(sw)); + CheckClassAdapter.verify(new ClassReader(e.getValue()), hierarchy, false, new PrintWriter(sw)); } catch (Throwable t) { throw new HardeningException("Hardened class '" + e.getKey() + "' failed bytecode verification: " + t.getMessage(), t); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index a24b2453978..048b3bb0ad9 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -142,6 +142,41 @@ public void standardHardenRenamesEncryptsAndPreservesResources() throws Exceptio cl.close(); } + @Test + public void serviceProviderClassesAreKept() throws Exception { + org.junit.Assume.assumeTrue("ProGuard renamer needs JDK <=20", HardeningEngine.proguardCanRunHere()); + // Build a jar where Helper is declared as a service provider; it must survive un-renamed + // so the verbatim-copied descriptor still resolves via ServiceLoader. + File jar = tmp.newFile("svc.jar"); + FileOutputStream fo = new FileOutputStream(jar); + ZipOutputStream zos = new ZipOutputStream(fo); + putClass(zos, SECRETS); + putClass(zos, HELPER); + zos.putNextEntry(new ZipEntry("META-INF/services/com.example.MyService")); + zos.write("# a provider\ncom.codename1.hardening.fixture.Helper\n" + .getBytes(Charset.forName("UTF-8"))); + zos.closeEntry(); + zos.finish(); + fo.close(); + + File out = tmp.newFile("svc-hardened.jar"); + Map hints = new HashMap(); + hints.put("harden.level", "standard"); + HardeningRequest req = new HardeningRequest() + .inputJar(jar).outputJar(out).mappingFile(tmp.newFile("svc-map.txt")) + .workDir(tmp.newFolder("svc-work")) + .config(HardeningConfig.from(hints, "ios", true)) + .mainClass("com.codename1.hardening.fixture.Secrets"); + HardeningResult r = HardeningEngine.harden(req); + assertTrue(r.isHardened()); + Map outEntries = readAll(out); + assertTrue("service provider class must be kept, not renamed", + outEntries.containsKey(HELPER + ".class")); + assertArrayEquals("# a provider\ncom.codename1.hardening.fixture.Helper\n" + .getBytes(Charset.forName("UTF-8")), + outEntries.get("META-INF/services/com.example.MyService")); + } + @Test public void offProfileIsSkippedAndReturnsInput() throws Exception { HardeningResult r = harden(HardeningProfile.OFF, "ios", true); From 889912753c8f1617b758cbd577ac00ec2517bf3c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:31:35 +0700 Subject: [PATCH 007/110] docs: satisfy the developer-guide prose gate (Vale + xref + LanguageTool) - Use contractions, drop flagged adverbs (quietly/silently/honestly) and remove needless hyphens in App-Hardening + Crash-Protection (Microsoft Vale style, warnings are build-breaking). - Add the [[crash-protection]] anchor so App-Hardening's <> xref resolves. - Accept the App Hardening technical terms (unhardened, unretraceable, deobfuscation, minify*, retrace*) in the developer-guide LanguageTool list. Co-Authored-By: Claude Opus 4.8 --- docs/developer-guide/App-Hardening.asciidoc | 20 +++++++++---------- .../developer-guide/Crash-Protection.asciidoc | 5 +++-- docs/developer-guide/languagetool-accept.txt | 9 +++++++++ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/docs/developer-guide/App-Hardening.asciidoc b/docs/developer-guide/App-Hardening.asciidoc index 81451cb857f..61aa7229e4d 100644 --- a/docs/developer-guide/App-Hardening.asciidoc +++ b/docs/developer-guide/App-Hardening.asciidoc @@ -3,15 +3,15 @@ Every shipped app is a program someone else can read. The class and method names survive into the binary, the string constants sit in plain sight, and the control flow is exactly what you wrote. On Android a release build is run through R8, which renames the Java names -- but on the other ports even that much isn't true: the iOS and native builds translate your code to C through ParparVM and the class names, method names and every string literal end up in the binary as readable text. -App Hardening closes that gap across *all* the ports from one place. It renames classes, methods and fields; encrypts string constants so they are not present as plaintext in the binary; and obfuscates control flow -- and it does this to the merged application before each platform build, so Android, iOS, JavaScript and the native desktop targets are all covered by one transform and one mapping. +App Hardening closes that gap across *all* the ports from one place. It renames classes, methods and fields; encrypts string constants so they're not present as plaintext in the binary; and obfuscates control flow -- and it does this to the merged application before each platform build, so Android, iOS, JavaScript and the native desktop targets are all covered by one transform and one mapping. -WARNING: App Hardening doesn't make an app impossible to reverse engineer, and no product does. What it changes is the cost: turning a class named `LoginController` with a string `"invalid password"` into a class named `zqab` with an encrypted constant moves the first afternoon of a reverse-engineering effort from "read it" to "reconstruct it." Be careful not to promise more than that, internally or in marketing. It is one layer; pair it with <> so the statement your backend trusts is made by hardware the attacker doesn't control. +WARNING: App Hardening doesn't make an app impossible to reverse engineer, and no product does. What it changes is the cost: turning a class named `LoginController` with a string `"invalid password"` into a class named `zqab` with an encrypted constant moves the first afternoon of a reverse-engineering effort from "read it" to "reconstruct it." Be careful not to promise more than that, internally or in marketing. It's one layer; pair it with <> so the statement your backend trusts is made by hardware the attacker doesn't control. -This is an *Enterprise* feature. A build that asks for it without an Enterprise subscription *fails with an explanation* rather than quietly producing an unhardened binary -- a binary that looks protected but isn't is worse than one that never claimed to be. +This is an *Enterprise* feature. A build that asks for it without an Enterprise subscription *fails with an explanation* rather than producing an unhardened binary -- a binary that looks protected but isn't is worse than one that never claimed to be. === What it changes, per port -The transform runs on the merged application jar, at the bytecode level, before any platform-specific build step. That is why one implementation reaches every port: iOS/ParparVM translates the already-hardened bytecode to C (so the C constant pool never sees the plaintext), R8 consumes already-hardened classes on Android, and the JavaScript backend minifies already-hardened classes. +The transform runs on the merged application jar, at the bytecode level, before any platform-specific build step. That's why one implementation reaches every port: iOS/ParparVM translates the already-hardened bytecode to C (so the C constant pool never sees the plaintext), R8 consumes already-hardened classes on Android, and the JavaScript backend minifies already-hardened classes. [cols="2,1,4"] |=== @@ -103,23 +103,23 @@ Renaming is safe for code the compiler and runtime resolve by symbol, and unsafe Two categories deserve special attention: -* *Name-bound persistence.* A `PropertyBusinessObject`'s property names *are* the JSON keys and the database column names. Renaming them would silently change the on-disk schema and the wire format, which corrupts data on the next app upgrade rather than throwing. The engine keeps these member names automatically. +* *Name-bound persistence.* A `PropertyBusinessObject`'s property names *are* the JSON keys and the database column names. Renaming them would change the on-disk schema and the wire format, which corrupts data on the next app upgrade rather than throwing. The engine keeps these member names automatically. * *Runtime reflection you construct dynamically.* If you build a class name at runtime from pieces the analysis can't follow, add a `harden.keep` rule for it. When you enable a hardening level, review your app for these name-bound patterns before the first hardened cloud build: reflective `Class.forName` targets built from dynamic strings, GUI-builder resources that reference components by class name, and any framework registration that resolves an implementation by name. The automatic keep analysis catches the common cases; a `harden.keep` rule covers anything it can't see. === Crash reports from a hardened build -Hardening and Crash Protection are designed together. The build server retains the obfuscation mapping and symbolicates incoming reports against it, so a crash from a hardened build still lands as a readable, correctly-lined GitHub issue -- see <>. Two consequences follow: a report whose mapping has aged out of retention can no longer be retraced, and a locally hardened build (whose mapping never reached the server) can't be symbolicated at all, which is why the pre-flight refuses to harden a local target by default. +Hardening and Crash Protection are designed together. The build server retains the obfuscation mapping and symbolicates incoming reports against it, so a crash from a hardened build still lands as a readable, correctly lined GitHub issue -- see <>. Two consequences follow: a report whose mapping has aged out of retention can no longer be retraced, and a locally hardened build (whose mapping never reached the server) can't be symbolicated at all, which is why the pre-flight refuses to harden a local target by default. -=== Local and source builds are not hardened +=== Local and source builds aren't hardened -Hardening runs on the Codename One build server. A local or source-project target (`*-source`, `local-*`) never reaches the server, so its output is not hardened; the build fails the pre-flight rather than mislead you, unless you set `harden.allowUnhardenedLocalBuild=true`. The simulator is never obfuscated either -- it runs your `target/classes` directly. App code can read `com.codename1.security.hardening.Hardening.isHardened()` to tell an honestly-hardened build from one of these. +Hardening runs on the Codename One build server. A local or source-project target (`*-source`, `local-*`) never reaches the server, so its output isn't hardened; the build fails the pre-flight rather than mislead you, unless you set `harden.allowUnhardenedLocalBuild=true`. The simulator is never obfuscated either -- it runs your `target/classes` directly. App code can read `com.codename1.security.hardening.Hardening.isHardened()` to tell a hardened build apart from one of these. === Hardening and App Shield These are two different Enterprise features and you can use either or both. App Hardening protects the *binary* -- it raises the cost of reading and modifying the app on the device. App Shield protects the *app-to-server relationship* -- it gives your backend a cryptographically verifiable statement that a request came from a genuine, unmodified app on an uncompromised device. Hardening makes an attacker work harder to patch out App Shield's checks; App Shield makes patching them out insufficient, because the statement your backend trusts is made by a party the attacker doesn't control. -=== What this does not protect against +=== What this doesn't protect against -Hardening raises the cost of static analysis and casual tampering. It does not stop a determined attacker with time, it does not protect a secret you embed in the client (put it on your server -- see the security chapter), and it is not a substitute for server-side authorization. Treat it as one layer of defense in depth, not a guarantee. +Hardening raises the cost of static analysis and casual tampering. It doesn't stop a determined attacker with time, it doesn't protect a secret you embed in the client (put it on your server -- see the security chapter), and it's not a substitute for server-side authorization. Treat it as one layer of defense in depth, not a guarantee. diff --git a/docs/developer-guide/Crash-Protection.asciidoc b/docs/developer-guide/Crash-Protection.asciidoc index 767d3de0e4d..a25ace7b485 100644 --- a/docs/developer-guide/Crash-Protection.asciidoc +++ b/docs/developer-guide/Crash-Protection.asciidoc @@ -1,3 +1,4 @@ +[[crash-protection]] == Crash Protection Crash Protection is an opt-in service that captures uncaught exceptions in your shipping app and files them as deduplicated issues on your GitHub repository. Symbolicated stack traces, scrubbed messages, and a per-bug counter are all recorded server-side; you triage from GitHub Issues like any other bug. @@ -85,12 +86,12 @@ The Codename One crash-protection client runs incoming messages through a scrubb - `rawStack` -- the pre-rendered Java stack (via `printStackTrace`, including the cause chain). On the ParparVM ports this is the readable Java trace, since `getStackTrace()` there yields a formatted string rather than structured frames - `traceFormat` -- how the server should read `rawStack`: `structured`, `parparvm-text`, `js-error`, or `none`. Derived, never guessed - `mappingId` -- the id of the obfuscation mapping a hardened build shipped with, so a report ties to the exact mapping even if a rebuild reused the build key; empty for unhardened builds -- `hardenLevel` -- the hardening level of the build, so the server can explain an unretraceable report honestly +- `hardenLevel` -- the hardening level of the build, so the server can give an honest reason for an unretraceable report - `clientTs` === Crash reports from a hardened build -When a build is hardened (see <>), the build server retains the cross-platform obfuscation mapping and symbolicates incoming reports against it, so a hardened build's crashes still land as readable, correctly-lined issues. Two things follow from how the mapping is retained: a report whose mapping has aged out of retention can no longer be retraced, and a locally hardened build -- whose mapping never reached the server -- can't be symbolicated at all. On the ParparVM ports (iOS, tvOS, watchOS, mac-native, Windows, Linux) the Java trace arrives as `rawStack` in the `parparvm-text` format and is parsed server-side; on the JavaScript port it arrives as a JavaScript engine stack (`js-error`) and is symbolicated best-effort through the source map. +When a build is hardened (see <>), the build server retains the cross-platform obfuscation mapping and symbolicates incoming reports against it, so a hardened build's crashes still land as readable, correctly lined issues. Two things follow from how the mapping is retained: a report whose mapping has aged out of retention can no longer be retraced, and a locally hardened build -- whose mapping never reached the server -- can't be symbolicated at all. On the ParparVM ports (iOS, tvOS, watchOS, mac-native, Windows, Linux) the Java trace arrives as `rawStack` in the `parparvm-text` format and is parsed server-side; on the JavaScript port it arrives as a JavaScript engine stack (`js-error`) and is symbolicated best-effort through the source map. ==== Default scrubber rules diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 0be2b48f143..93b772efdfa 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -634,3 +634,12 @@ BlueZ # App Shield chapter (App-Shield.asciidoc). "quickstart" is the product's own # name for the backend integration snippets the build console renders. [Qq]uickstarts? + +# ----------------------------------------------------------------------------- +# App Hardening (Enterprise) terminology. +# ----------------------------------------------------------------------------- +unhardened +unretraceable +[Dd]eobfuscation +[Mm]inif(y|ies|ied|ier|ication) +[Rr]etrace(d|s|able)? From d2ebbef8169fc9e1c0ba107c4abc0517efea7ae5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:48:31 +0700 Subject: [PATCH 008/110] Address Codex round-4 + Copilot review Codex: - Preserve the JavaScript engine stack in rawStack: capture the platform's own printStackTrace(PrintStream) rendering instead of rebuilding from structured frames (which are empty on the JS port). Added printStackTrace(PrintStream) to the CLDC11 API and the ParparVM runtime Throwable; PrintStream (unlike PrintWriter) is in the restricted core API. - Stamp hardening metadata in the iOS stub too (shared Executor helper); Android already did. (ParparVM JS/native ports have no runtime-property stub yet -- same gap as build_key there.) - Derive the engine platform for Mac-native builds (harden.mac.enabled now applies). - Don't stamp the empty engine mapping's constant id on Android (R8 owns the real mapping); leave cn1.mappingId empty when the engine doesn't rename. - Fail an Android build that requests hardening renaming while android.enableProguard=false disables R8. Copilot: - Control-flow guard uses the 2-arg System.getProperty so it can't NPE when java.home is absent (Android). - The engine (Main) fails loudly on an invalid harden.level instead of treating it as off, and gates entitlement only when hardening is actually active so a per-platform opt-out works on a non-entitled account. - Clarify the docs/comment: getStackTrace() now returns structured frames on every port; rawStack complements them (and is the JS port's readable trace). Co-Authored-By: Claude Opus 4.8 --- .../com/codename1/crash/CrashProtection.java | 36 ++++++------------- .../codename1/crash/CrashReportPayload.java | 10 +++--- Ports/CLDC11/src/java/lang/Throwable.java | 7 ++++ .../developer-guide/Crash-Protection.asciidoc | 4 +-- .../hardening/ControlFlowTransform.java | 7 ++-- .../codename1/hardening/HardeningEngine.java | 5 ++- .../java/com/codename1/hardening/Main.java | 16 ++++++++- .../builders/AndroidGradleBuilder.java | 14 +++++++- .../java/com/codename1/builders/Executor.java | 17 +++++++-- .../com/codename1/builders/IPhoneBuilder.java | 10 +++++- .../codename1/builders/JavaScriptBuilder.java | 2 +- .../builders/LinuxNativeBuilder.java | 2 +- .../builders/WindowsNativeBuilder.java | 2 +- vm/JavaAPI/src/java/lang/Throwable.java | 8 +++++ 14 files changed, 97 insertions(+), 43 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/CrashProtection.java b/CodenameOne/src/com/codename1/crash/CrashProtection.java index a465aa6663a..f898721032e 100644 --- a/CodenameOne/src/com/codename1/crash/CrashProtection.java +++ b/CodenameOne/src/com/codename1/crash/CrashProtection.java @@ -254,32 +254,16 @@ static CrashReportPayload build(Throwable t) { /// Swallows any failure: capturing a crash report must never itself crash. private static String safeRawStack(Throwable t) { try { - // Built by hand rather than via printStackTrace(PrintWriter): the core is compiled - // against a restricted (CLDC-like) API that has no java.io.PrintWriter. The frame - // lines use the " at .:" shape -- the same the ParparVM native - // trace uses -- so the trace-format sniffer classifies it correctly when structured - // frames are unavailable. getStackTrace() now returns frames on every port. - StringBuilder sb = new StringBuilder(); - Throwable cur = t; - int depth = 0; - while (cur != null && depth < 8) { - if (depth > 0) { - sb.append("Caused by: "); - } - sb.append(cur.toString()).append('\n'); - StackTraceElement[] els = cur.getStackTrace(); - int limit = els.length < CrashReportPayload.MAX_FRAMES - ? els.length : CrashReportPayload.MAX_FRAMES; - for (int i = 0; i < limit; i++) { - StackTraceElement e = els[i]; - sb.append(" at ").append(e.getClassName()).append('.') - .append(e.getMethodName()).append(':').append(e.getLineNumber()) - .append('\n'); - } - cur = cur.getCause(); - depth++; - } - String s = sb.toString(); + // Capture the platform's own rendering via printStackTrace(PrintStream) -- PrintStream + // (unlike PrintWriter) is in the restricted CLDC core API. This is what preserves the + // real trace on the ParparVM ports: the pre-rendered C shadow-call-stack text, or the + // JavaScript engine's Error().stack on the JS port (where getStackTrace() has no + // structured frames to offer). On the JVM ports it is the standard full trace. + java.io.ByteArrayOutputStream bout = new java.io.ByteArrayOutputStream(); + java.io.PrintStream ps = new java.io.PrintStream(bout); + t.printStackTrace(ps); + ps.flush(); + String s = bout.toString(); return s.length() == 0 ? null : s; } catch (Throwable ignored) { return null; diff --git a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java index a8defc4afbe..b575375209b 100644 --- a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java +++ b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java @@ -45,10 +45,12 @@ final class CrashReportPayload { /// signal handlers are usually compact (~64 frames * ~120 chars), /// but a corrupt stack can produce arbitrarily long output. static final int MAX_NATIVE_STACK_LEN = 16 * 1024; - /// Hard cap on the raw (pre-rendered) Java stack string. On the - /// ParparVM ports the trace arrives as a formatted string rather - /// than structured frames; on the JS port it is a JavaScript - /// engine stack. Mirrors {@link #MAX_NATIVE_STACK_LEN}. + /// Hard cap on the raw (pre-rendered) Java stack string captured via + /// `printStackTrace` -- the verbatim platform rendering plus the cause + /// chain. It complements the structured {@link #frames} (populated on + /// every port), and on the JS port, where there are no structured + /// frames, it carries the JavaScript engine stack. Mirrors + /// {@link #MAX_NATIVE_STACK_LEN}. static final int MAX_RAW_STACK_LEN = 16 * 1024; /// Trace-format discriminator values. Tells the server how to parse diff --git a/Ports/CLDC11/src/java/lang/Throwable.java b/Ports/CLDC11/src/java/lang/Throwable.java index a45188ab72d..f4b408fc18b 100644 --- a/Ports/CLDC11/src/java/lang/Throwable.java +++ b/Ports/CLDC11/src/java/lang/Throwable.java @@ -85,6 +85,13 @@ public void printStackTrace(){ return; //TODO codavaj!! } + /// Prints this throwable and its backtrace to the given stream. On the ParparVM ports this + /// writes the pre-rendered native stack (the C shadow-call-stack text, or the JavaScript + /// engine's Error().stack on the JS port), which the crash reporter captures as the raw stack. + public void printStackTrace(java.io.PrintStream s){ + return; //TODO codavaj!! + } + /// Returns a short description of this Throwable object. If this Throwable object was /// with an error message string, then the result is the concatenation of three strings: The name of the actual class of this object ": " (a colon and a space) The result of the /// method for this object If this Throwable object was diff --git a/docs/developer-guide/Crash-Protection.asciidoc b/docs/developer-guide/Crash-Protection.asciidoc index a25ace7b485..7bcdd15bfef 100644 --- a/docs/developer-guide/Crash-Protection.asciidoc +++ b/docs/developer-guide/Crash-Protection.asciidoc @@ -83,7 +83,7 @@ The Codename One crash-protection client runs incoming messages through a scrubb - `exceptionClass` - `messageScrubbed` -- *scrubbed* - `frames[]` -- class / method / file / line / `native` flag per frame -- `rawStack` -- the pre-rendered Java stack (via `printStackTrace`, including the cause chain). On the ParparVM ports this is the readable Java trace, since `getStackTrace()` there yields a formatted string rather than structured frames +- `rawStack` -- the pre-rendered Java stack captured via `printStackTrace`, including the cause chain and any verbatim platform formatting. It complements the structured `frames` (which `getStackTrace()` now populates on every port) and is the readable trace on the JavaScript port, where the JavaScript engine's stack has no structured frames - `traceFormat` -- how the server should read `rawStack`: `structured`, `parparvm-text`, `js-error`, or `none`. Derived, never guessed - `mappingId` -- the id of the obfuscation mapping a hardened build shipped with, so a report ties to the exact mapping even if a rebuild reused the build key; empty for unhardened builds - `hardenLevel` -- the hardening level of the build, so the server can give an honest reason for an unretraceable report @@ -91,7 +91,7 @@ The Codename One crash-protection client runs incoming messages through a scrubb === Crash reports from a hardened build -When a build is hardened (see <>), the build server retains the cross-platform obfuscation mapping and symbolicates incoming reports against it, so a hardened build's crashes still land as readable, correctly lined issues. Two things follow from how the mapping is retained: a report whose mapping has aged out of retention can no longer be retraced, and a locally hardened build -- whose mapping never reached the server -- can't be symbolicated at all. On the ParparVM ports (iOS, tvOS, watchOS, mac-native, Windows, Linux) the Java trace arrives as `rawStack` in the `parparvm-text` format and is parsed server-side; on the JavaScript port it arrives as a JavaScript engine stack (`js-error`) and is symbolicated best-effort through the source map. +When a build is hardened (see <>), the build server retains the cross-platform obfuscation mapping and symbolicates incoming reports against it, so a hardened build's crashes still land as readable, correctly lined issues. Two things follow from how the mapping is retained: a report whose mapping has aged out of retention can no longer be retraced, and a locally hardened build -- whose mapping never reached the server -- can't be symbolicated at all. The ParparVM ports (iOS, tvOS, watchOS, mac-native, Windows, Linux) report structured `frames`, and also carry the pre-rendered trace in `rawStack`; the JavaScript port has no structured frames, so its report relies on the `rawStack` JavaScript engine stack (`js-error`), symbolicated best-effort through the source map. ==== Default scrubber rules diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java index f48cfe9ac54..08d4b379329 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java @@ -162,10 +162,13 @@ private void addGuardField(ClassNode cn) { private void initGuardField(ClassNode cn) { InsnList init = new InsnList(); - // zq$cf = System.getProperty("java.home").length(); -- always >= 1, never foldable. + // zq$cf = System.getProperty("java.home", "cn1").length(); -- always >= 1, never foldable. + // The two-arg overload guarantees a non-null result (java.home can be absent on Android), + // so the guard can never NPE in . init.add(new LdcInsnNode("java.home")); + init.add(new LdcInsnNode("cn1")); init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", - "(Ljava/lang/String;)Ljava/lang/String;", false)); + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", false)); init.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/String", "length", "()I", false)); init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, GUARD_FIELD, GUARD_DESC)); diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index f13f33b9064..d0199752236 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -208,8 +208,11 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi JarDemuxer.rebuild(req.getOutputJar(), renamed, nonClass); + // Only the engine's own rename produces a mapping worth an id. On Android the engine does + // not rename (R8 is the sole renamer and produces the real per-build mapping later), so the + // engine mapping is empty -- hashing it would stamp a meaningless constant id. Leave it empty. String mappingId = ""; - if (mappingFile != null) { + if (mappingFile != null && cfg.isRenameEnabled()) { mappingId = MappingWriter.finalizeMapping(mappingFile, ENGINE_VERSION, PROGUARD_VERSION, cfg.getPlatform(), req.getBuildKey()); } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java index 1b1d1c2e946..66fa4f34e7d 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java @@ -93,9 +93,23 @@ static int run(String[] args) { } } + // An unrecognized harden.level must fail loudly rather than be treated as off (which + // would ship an unhardened binary the developer believes is hardened). + String rawLevel = hints.get("harden.level"); + if (rawLevel != null && rawLevel.trim().length() > 0 + && !"off".equalsIgnoreCase(rawLevel.trim()) + && HardeningProfile.parse(rawLevel) == null) { + System.err.println("Invalid harden.level '" + rawLevel + "'. Valid values are: " + + "off, standard, aggressive, paranoid."); + return EXIT_FAILED; + } + HardeningConfig cfg = HardeningConfig.from(hints, platform, renameSupported); - if (cfg.getProfile() != HardeningProfile.OFF && !entitled) { + // Gate entitlement only when hardening will actually run: a per-platform opt-out + // (harden..enabled=false) must be able to produce an unhardened build even + // on a non-entitled account, rather than failing here. + if (cfg.isActive() && !entitled) { System.err.println("App hardening is an Enterprise feature and this build is not " + "entitled. Refusing to produce a half-hardened binary."); return EXIT_NOT_ENTITLED; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 8650e36284e..090b63db804 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -754,7 +754,7 @@ private static String escape(String str, String chars) { } @Override - protected String hardeningPlatform() { + protected String hardeningPlatform(BuildRequest request) { return "and"; } @@ -825,6 +825,18 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc request.putArgument("android.release", "false"); request.putArgument("android.debug", "true"); } + // On Android renaming is delivered by R8 (the engine does not rename here), so a hardening + // level that promises renaming cannot be honored with R8 turned off. Fail rather than ship a + // build stamped "hardened" that was never renamed. (harden.rename=false opts out explicitly.) + String hardenLevel = request.getArg("harden.level", "off"); + boolean hardenRenames = hardenLevel != null && !"off".equalsIgnoreCase(hardenLevel.trim()) + && hardenLevel.trim().length() > 0 + && !"false".equalsIgnoreCase(request.getArg("harden.rename", "true")); + if (hardenRenames && request.getArg("android.enableProguard", "true").equals("false")) { + throw new BuildException("harden.level=" + hardenLevel + " requires Android's R8/ProGuard " + + "renaming, but android.enableProguard=false disables it. Enable R8, set " + + "harden.rename=false, or set harden.level=off."); + } if (useGradle8) { getGradleJavaHome(); // will throw build exception if JAVA17_HOME is not set minimumGradleVersion = GRADLE_8_VERSION; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 8aab81d2e54..8542efd3105 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2344,10 +2344,23 @@ public String xorEncode(String s) { * The platform id this builder targets, for the hardening engine ({@code ios}, {@code and}, * {@code javascript}, {@code win}, {@code linux}, {@code mac}, ...). Subclasses override. */ - protected String hardeningPlatform() { + protected String hardeningPlatform(BuildRequest request) { return "unknown"; } + /** + * Java source that stamps the hardening runtime properties ({@code cn1.mappingId}, + * {@code cn1.hardened}, {@code cn1.hardenLevel}) into {@code Display}, so every port's stub can + * emit them the same way. These back {@code Hardening.isHardened()} and the crash report's + * mapping id / level. The values are controlled build outputs (a hex id and a fixed level), + * so string concatenation into the stub is safe. + */ + protected String hardeningRuntimeProperties(BuildRequest request) { + return " Display.getInstance().setProperty(\"cn1.mappingId\", \"" + resolveMappingId(request) + "\");\n" + + " Display.getInstance().setProperty(\"cn1.hardened\", \"" + request.getArg("cn1.hardened", "false") + "\");\n" + + " Display.getInstance().setProperty(\"cn1.hardenLevel\", \"" + request.getArg("cn1.hardenLevel", "off") + "\");\n"; + } + /** * Whether the hardening engine should rename for this platform. Android returns false: R8 * remains the sole renamer there, and the engine only encrypts strings and exports keep rules. @@ -2492,7 +2505,7 @@ private void writeHardeningConfig(File config, BuildRequest request) throws IOEx p.setProperty(key, request.getArg(key, "")); } } - p.setProperty("cn1.platform", hardeningPlatform()); + p.setProperty("cn1.platform", hardeningPlatform(request)); // The keep rule must name the FULLY QUALIFIED main class: getMainClass() is the simple name // (the stubs combine it with getPackageName()), so passing it bare would keep a default-package // class and let ProGuard rename the real application class out from under the generated stub. 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 bc1923e6145..87673e39998 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 @@ -478,7 +478,14 @@ private String podVersionRequirement(String hint, String fallback) { @Override - protected String hardeningPlatform() { + protected String hardeningPlatform(BuildRequest request) { + // A native-Mac build reports "mac" so harden.mac.enabled / harden.ios.enabled apply to the + // right output. (A combined iOS build that also emits a Mac slice hardens the shared jar + // once, under "ios".) + if ("true".equals(request.getArg("macNative.enabled", "false")) + && !"true".equals(request.getArg("ios.enabled", "true"))) { + return "mac"; + } return "ios"; } @@ -2030,6 +2037,7 @@ public void usesClassMethod(String cls, String method) { + delayPushCompletion + " Display.getInstance().setProperty(\"AppVersion\", APPLICATION_VERSION);\n" + " Display.getInstance().setProperty(\"AppName\", APPLICATION_NAME);\n" + + hardeningRuntimeProperties(request) + newStorage + disableScreenshots + adPadding diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java index d718a6d11c1..84765aa55c8 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java @@ -90,7 +90,7 @@ public File getJavaScriptDeployableArtifact() { } @Override - protected String hardeningPlatform() { + protected String hardeningPlatform(BuildRequest request) { return "javascript"; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java index c9e0f6e60be..8cdf0f8a357 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java @@ -182,7 +182,7 @@ static String detectHostArch() { } @Override - protected String hardeningPlatform() { + protected String hardeningPlatform(BuildRequest request) { return "linux"; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java index dda6718cb52..c02c09f74e1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java @@ -164,7 +164,7 @@ static String detectHostArch() { } @Override - protected String hardeningPlatform() { + protected String hardeningPlatform(BuildRequest request) { return "win"; } diff --git a/vm/JavaAPI/src/java/lang/Throwable.java b/vm/JavaAPI/src/java/lang/Throwable.java index d26cef0ff06..1591ecaa7ed 100644 --- a/vm/JavaAPI/src/java/lang/Throwable.java +++ b/vm/JavaAPI/src/java/lang/Throwable.java @@ -106,6 +106,14 @@ public void printStackTrace(){ } } + public void printStackTrace(java.io.PrintStream s) { + s.println(stack); + if (cause != null) { + s.println("Caused by "); + cause.printStackTrace(s); + } + } + public void printStackTrace(PrintWriter s) { s.println(stack); if (cause != null) { From 50093589821831cc0758d9e49d9c35560f6ca5d2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:56:07 +0700 Subject: [PATCH 009/110] Address Codex round-5 review - JarDemuxer drops jar signature blocks (META-INF/*.SF|*.RSA|*.DSA|*.EC) when rebuilding, since renaming invalidates them and a verifying JarFile would throw SecurityException: Invalid signature file digest. - MappingFile maps distinct obfuscated->original line ranges (R8 / optimized ProGuard, e.g. 1:2:...:40:41) back to the source line instead of passing the device line through; tested. - HardeningPreflight honors per-platform opt-outs: a target with harden..enabled=false is treated as off rather than rejected. - paranoid is now a genuinely stronger tier: control-flow inserts two nested opaque-predicate guards per method (intensity 2) vs one for aggressive; tested. Co-Authored-By: Claude Opus 4.8 --- .../hardening/ControlFlowTransform.java | 15 ++++-- .../codename1/hardening/HardeningConfig.java | 12 +++++ .../codename1/hardening/HardeningEngine.java | 5 +- .../com/codename1/hardening/JarDemuxer.java | 20 ++++++++ .../hardening/ControlFlowTransformTest.java | 22 ++++++++ .../com/codename1/retrace/MappingFile.java | 50 +++++++++++++++---- .../codename1/retrace/MappingFileTest.java | 11 ++++ .../com/codename1/maven/CN1BuildMojo.java | 37 ++++++++++++++ 8 files changed, 157 insertions(+), 15 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java index 08d4b379329..d9fbf3fe792 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java @@ -58,18 +58,25 @@ public final class ControlFlowTransform { static final String GUARD_DESC = "I"; private final ClassLoader hierarchy; + private final int intensity; private int guardedMethods; public ControlFlowTransform() { - this(null); + this(null, 1); + } + + public ControlFlowTransform(ClassLoader hierarchy) { + this(hierarchy, 1); } /** * @param hierarchy a classloader over the (renamed) input classes plus the library jars, used * for stack-map frame computation; may be {@code null} in tests + * @param intensity how many opaque-predicate guards to insert per method (paranoid uses 2) */ - public ControlFlowTransform(ClassLoader hierarchy) { + public ControlFlowTransform(ClassLoader hierarchy, int intensity) { this.hierarchy = hierarchy; + this.intensity = Math.max(1, intensity); } public int getGuardedMethods() { @@ -93,7 +100,9 @@ public byte[] transform(byte[] classBytes) { if (!isGuardable(mn)) { continue; } - prependGuard(cn, mn); + for (int i = 0; i < intensity; i++) { + prependGuard(cn, mn); + } guardedMethods++; changed = true; } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java index 150d1193722..0982ee21cd3 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java @@ -173,6 +173,18 @@ public boolean isControlFlow() { return controlFlow; } + /** + * How many opaque-predicate guards control-flow obfuscation inserts per method: {@code paranoid} + * inserts two (nested) where {@code aggressive} inserts one, which is what makes {@code paranoid} + * a genuinely stronger tier rather than an alias. + */ + public int getControlFlowIntensity() { + if (!controlFlow) { + return 0; + } + return profile == HardeningProfile.PARANOID ? 2 : 1; + } + public boolean isPlatformEnabled() { return platformEnabled; } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index d0199752236..62a5a7dc148 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -189,7 +189,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi boolean controlFlowApplied = cfg.isControlFlow() && controlFlowSafeFor(cfg.getPlatform()); if (controlFlowApplied) { for (Map.Entry e : renamed.entrySet()) { - ControlFlowTransform t = new ControlFlowTransform(hierarchy); + ControlFlowTransform t = new ControlFlowTransform(hierarchy, cfg.getControlFlowIntensity()); byte[] out = t.transform(e.getValue()); if (out != e.getValue()) { e.setValue(out); @@ -232,7 +232,8 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi result.getTransformsApplied().add(cfg.isEncryptAllStrings() ? "strings:all" : "strings:constants"); } if (controlFlowApplied && guardedMethods > 0) { - result.getTransformsApplied().add("controlFlow"); + result.getTransformsApplied().add(cfg.getControlFlowIntensity() >= 2 + ? "controlFlow:intense" : "controlFlow"); } if (cfg.isControlFlow() && !controlFlowApplied) { result.getWarnings().add("control-flow obfuscation is not applied on platform '" diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java index 4cb38c97392..559bed7b22e 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java @@ -92,6 +92,12 @@ public static NonClassEntries split(File input, File classesJarOut) throws IOExc zos.putNextEntry(out); zos.write(data); zos.closeEntry(); + } else if (isJarSignature(name)) { + // Renaming/transforming classes invalidates any bundled jar signature, so + // carrying the .SF/.RSA/.DSA/.EC blocks across would make a verifying JarFile + // throw SecurityException: Invalid signature file digest. Drop them; without + // the .SF the JVM no longer verifies, which is correct for a rewritten jar. + continue; } else { nonClass.put(name, data); } @@ -156,6 +162,20 @@ public static Map readClasses(File jar) throws IOException { return classes; } + /** True for a jar signature block under META-INF that a class rewrite invalidates. */ + static boolean isJarSignature(String name) { + String upper = name.toUpperCase(); + if (!upper.startsWith("META-INF/")) { + return false; + } + // Only the signature blocks in META-INF's top level, not nested paths. + if (upper.indexOf('/', "META-INF/".length()) >= 0) { + return false; + } + return upper.endsWith(".SF") || upper.endsWith(".RSA") || upper.endsWith(".DSA") + || upper.endsWith(".EC"); + } + private static byte[] readAll(InputStream in) throws IOException { ByteArrayOutputStream bout = new ByteArrayOutputStream(Math.max(1024, in.available())); byte[] buf = new byte[8192]; diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java index f278dafd1c1..17e5b17b418 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java @@ -65,6 +65,28 @@ public void guardsVerifyAndPreserveBehaviour() throws Exception { c.getMethod("concat", String.class).invoke(null, "Bo")); } + @Test + public void intenseGuardsVerifyAndPreserveBehaviour() throws Exception { + // Paranoid intensity: two nested guards per method. Must still verify and be a no-op. + byte[] out = new ControlFlowTransform(null, 2).transform(original()); + CheckClassAdapter.verify(new ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define(CLASS + "$P", rename(out, CLASS, CLASS + "$P")); + // A distinct class name via a fresh loader; behaviour must be unchanged. + assertEquals("hello secret world", c.getMethod("greet").invoke(null)); + assertEquals(5, c.getMethod("compute", int.class, int.class).invoke(null, 2, 3)); + } + + // Renames the class internal name so the intense variant can load beside the plain one. + private static byte[] rename(byte[] bytes, String from, String to) { + org.objectweb.asm.ClassReader cr = new org.objectweb.asm.ClassReader(bytes); + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cr.accept(new org.objectweb.asm.commons.ClassRemapper(cw, + new org.objectweb.asm.commons.SimpleRemapper(from.replace('.', '/'), + to.replace('.', '/'))), 0); + return cw.toByteArray(); + } + private static final class ByteLoader extends ClassLoader { Class define(String name, byte[] b) { return defineClass(name, b, 0, b.length); diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java index f668255765b..003468c8521 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -43,13 +43,23 @@ public final class MappingFile { private static final class MethodMapping { final String originalName; - final int startLine; - final int endLine; + final int startLine; // obfuscated range start (0 if none) + final int endLine; // obfuscated range end + final int originalStartLine; // original range start (0 if none / same) - MethodMapping(String originalName, int startLine, int endLine) { + MethodMapping(String originalName, int startLine, int endLine, int originalStartLine) { this.originalName = originalName; this.startLine = startLine; this.endLine = endLine; + this.originalStartLine = originalStartLine; + } + + /** Maps an observed obfuscated line into the original source line, when both ranges are known. */ + int mapLine(int observed) { + if (startLine != 0 && originalStartLine != 0 && observed >= startLine && observed <= endLine) { + return originalStartLine + (observed - startLine); + } + return observed; } } @@ -124,7 +134,21 @@ private void parseMemberLine(ClassMapping cm, String line) { left = left.substring(secondColon + 1); } } - // left is now "returnType methodName(args)"; extract the method name. + // left is now "returnType methodName(args)" optionally followed by ":origStart[:origEnd]" + // (R8 / optimized ProGuard maps the obfuscated range to a distinct original range). + int originalStartLine = 0; + int closeParen = left.indexOf(')'); + if (closeParen >= 0) { + String afterParen = left.substring(closeParen + 1); + if (afterParen.startsWith(":")) { + String[] parts = afterParen.substring(1).split(":"); + if (parts.length >= 1) { + originalStartLine = parseIntSafe(parts[0]); + } + } + left = left.substring(0, closeParen + 1); + } + // Extract the method name from "returnType methodName(args)". int paren = left.indexOf('('); String beforeParen = left.substring(0, paren).trim(); int sp = beforeParen.lastIndexOf(' '); @@ -134,7 +158,7 @@ private void parseMemberLine(ClassMapping cm, String line) { list = new ArrayList(); cm.methods.put(obfName, list); } - list.add(new MethodMapping(originalMethod, startLine, endLine)); + list.add(new MethodMapping(originalMethod, startLine, endLine, originalStartLine)); } /** @@ -147,24 +171,30 @@ public Frame retrace(Frame obfuscated) { if (cm == null) { return obfuscated; } + int observed = obfuscated.getLineNumber(); String originalMethod = obfuscated.getMethodName(); + int mappedLine = observed; List candidates = cm.methods.get(obfuscated.getMethodName()); if (candidates != null && !candidates.isEmpty()) { - originalMethod = pickByLine(candidates, obfuscated.getLineNumber()); + MethodMapping m = pickByLine(candidates, observed); + originalMethod = m.originalName; + // Translate the observed obfuscated line back to the original source line when the + // mapping carries a distinct original range (R8 / optimized ProGuard). + mappedLine = m.mapLine(observed); } String originalClass = cm.originalName; String file = simpleSourceFile(originalClass); - return new Frame(originalClass, originalMethod, file, obfuscated.getLineNumber()); + return new Frame(originalClass, originalMethod, file, mappedLine); } - private String pickByLine(List candidates, int line) { + private MethodMapping pickByLine(List candidates, int line) { // Prefer a candidate whose obfuscated line range contains the frame's line. for (MethodMapping m : candidates) { if (m.startLine != 0 && line >= m.startLine && line <= m.endLine) { - return m.originalName; + return m; } } - return candidates.get(0).originalName; + return candidates.get(0); } private static String simpleSourceFile(String fqcn) { diff --git a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java index 2480248e10c..4672dcd9556 100644 --- a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -59,6 +59,17 @@ public void retracesMethodByLineRange() throws Exception { assertEquals("com.example.MyForm", out.getClassName()); } + @Test + public void mapsDistinctOriginalLineRange() throws Exception { + // R8 / optimized ProGuard: obfuscated lines 1:2 map to original lines 40:41. + MappingFile mf = MappingFile.parse( + "com.example.MyForm -> zqaaaa:\n" + + " 1:2:void f():40:41 -> a\n"); + Frame out = mf.retrace(new Frame("zqaaaa", "a", "zqaaaa.java", 2)); + assertEquals("f", out.getMethodName()); + assertEquals(41, out.getLineNumber()); + } + @Test public void unknownClassPassesThroughUnchanged() throws Exception { MappingFile mf = MappingFile.parse(MAPPING); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 7b5df56435d..db7b11a72f5 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -193,6 +193,13 @@ private void applyHardeningPreflight() throws MojoFailureException { } } String level = settings.getProperty("codename1.arg.harden.level", "off"); + // A per-platform opt-out (harden..enabled=false) means hardening won't run for + // this target, so the pre-flight must not reject it -- treat the level as off. + String hardenPlatform = normalizeHardenPlatform(platform); + if (hardenPlatform != null && "false".equalsIgnoreCase( + settings.getProperty("codename1.arg.harden." + hardenPlatform + ".enabled", "true").trim())) { + level = "off"; + } boolean allowLocal = "true".equalsIgnoreCase( settings.getProperty("codename1.arg.harden.allowUnhardenedLocalBuild", "false").trim()); boolean onDeviceDebug = "true".equalsIgnoreCase( @@ -234,6 +241,36 @@ private void applyHardeningPreflight() throws MojoFailureException { } } + /** Maps {@code codename1.platform} to the {@code harden..enabled} opt-out key. */ + private static String normalizeHardenPlatform(String platform) { + if (platform == null) { + return null; + } + String p = platform.trim().toLowerCase(); + if (p.startsWith("android")) { + return "and"; + } + if (p.startsWith("ios")) { + return "ios"; + } + if (p.contains("javascript")) { + return "javascript"; + } + if (p.contains("win")) { + return "win"; + } + if (p.contains("mac")) { + return "mac"; + } + if (p.contains("linux")) { + return "linux"; + } + if (p.contains("javase") || p.contains("desktop")) { + return "javase"; + } + return null; + } + /** * Merge a set of jars into a single jar file. * @param dest The destination jar file. Also the first source if it already exists. From fdc77f21a8aacfb3b0a1f79c1c0a59a68ec3344d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:01:15 +0700 Subject: [PATCH 010/110] Address Codex round-6 review (interface literals, empty-config) - String encryption now processes Java 8 interface default/static method bodies (previously the whole interface was skipped, leaving their literals plaintext in all/paranoid mode). The synthesized decoder is public in an interface (private statics are 9+); interface constant fields are still left alone. Tested. - The engine no longer marks a build hardened when a non-off level has all its transforms individually disabled (harden.rename=false + harden.strings=off + no control flow): it returns SKIPPED_NOT_REQUESTED instead of rebuilding an unchanged jar and stamping cn1.hardened=true. Tested. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/HardeningEngine.java | 17 ++++++++++ .../hardening/StringEncryptTransform.java | 26 +++++++------- .../hardening/HardeningEngineTest.java | 19 +++++++++++ .../hardening/StringEncryptTransformTest.java | 23 +++++++++++++ .../codename1/hardening/fixture/Iface.java | 34 +++++++++++++++++++ 5 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Iface.java diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 62a5a7dc148..a692308c38b 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -96,6 +96,12 @@ public static HardeningResult harden(HardeningRequest req) throws HardeningExcep if (!cfg.isPlatformEnabled()) { return HardeningResult.skipped(HardeningResult.Outcome.SKIPPED_PLATFORM_DISABLED, req.getInputJar()); } + // A non-off level whose transforms are all individually disabled (e.g. harden.rename=false + + // harden.strings=off + no control flow) does nothing -- don't rebuild the jar and stamp it + // "hardened" with an empty transform set. Treat it as not requested. + if (!willApplyAnyTransform(cfg)) { + return HardeningResult.skipped(HardeningResult.Outcome.SKIPPED_NOT_REQUESTED, req.getInputJar()); + } File workDir = req.getWorkDir(); if (workDir == null) { @@ -255,6 +261,17 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi * encrypting one would break the bridge. Every other port is safe (the decoder is ordinary * translated/compiled code). */ + /** True when at least one transform will actually run for this config and platform. */ + static boolean willApplyAnyTransform(HardeningConfig cfg) { + if (cfg.isRenameEnabled()) { + return true; + } + if (cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform())) { + return true; + } + return cfg.isControlFlow() && controlFlowSafeFor(cfg.getPlatform()); + } + static boolean stringEncryptionSafeFor(String platform) { return !"javascript".equals(platform); } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index ba85467c330..deff1181eed 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -118,16 +118,11 @@ public byte[] transform(byte[] classBytes) { ClassNode cn = new ClassNode(); new ClassReader(classBytes).accept(cn, ClassReader.SKIP_FRAMES); - // Interfaces (including annotations) are skipped: their fields are implicitly - // constant, they have no place for a decode call in a Java-5-compatible way, - // and their methods carry no encryptable literals. - if ((cn.access & Opcodes.ACC_INTERFACE) != 0) { - return classBytes; - } // If the class already defines a member colliding with the decoder, leave it alone. if (hasDecoderCollision(cn)) { return classBytes; } + boolean isInterface = (cn.access & Opcodes.ACC_INTERFACE) != 0; int base = keyBase(cn.name); boolean changed = false; @@ -148,14 +143,17 @@ public byte[] transform(byte[] classBytes) { } } - // Channel 2: static final String ConstantValue attributes (both modes). - changed |= encryptStaticFinalStrings(cn, base); + // Channel 2: static final String ConstantValue attributes (both modes). Skipped on + // interfaces, whose fields are implicitly constant and have no rewritable init slot. + if (!isInterface) { + changed |= encryptStaticFinalStrings(cn, base); + } if (!changed) { return classBytes; } - addDecoder(cn, base); + addDecoder(cn, base, isInterface); ClassWriter cw = new FrameClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, hierarchy); cn.accept(cw); @@ -231,10 +229,12 @@ private void prependToClinit(ClassNode cn, InsnList init) { } } - private void addDecoder(ClassNode cn, int base) { - MethodNode m = new MethodNode(Opcodes.ASM9, - Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC, - DECODER_NAME, DECODER_DESC, null, null); + private void addDecoder(ClassNode cn, int base, boolean isInterface) { + // A Java 8 interface may only have public static methods (private statics are 9+), so the + // decoder is public there; in a class it stays private. + int access = (isInterface ? Opcodes.ACC_PUBLIC : Opcodes.ACC_PRIVATE) + | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC; + MethodNode m = new MethodNode(Opcodes.ASM9, access, DECODER_NAME, DECODER_DESC, null, null); InsnList in = m.instructions; // char[] c = s.toCharArray(); (local 1) in.add(new VarInsnNode(Opcodes.ALOAD, 0)); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index 048b3bb0ad9..f7f3b982c43 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -184,6 +184,25 @@ public void offProfileIsSkippedAndReturnsInput() throws Exception { assertEquals(HardeningResult.Outcome.SKIPPED_NOT_REQUESTED, r.getOutcome()); } + @Test + public void nonOffLevelWithAllTransformsDisabledIsSkipped() throws Exception { + // standard, but rename off and strings off -> nothing to do -> not stamped hardened. + File in = buildInputJar(); + File out = tmp.newFile("noop-hardened.jar"); + Map hints = new HashMap(); + hints.put("harden.level", "standard"); + hints.put("harden.rename", "false"); + hints.put("harden.strings", "off"); + HardeningRequest req = new HardeningRequest() + .inputJar(in).outputJar(out).mappingFile(tmp.newFile("noop-map.txt")) + .workDir(tmp.newFolder("noop-work")) + .config(HardeningConfig.from(hints, "ios", true)) + .mainClass("com.codename1.hardening.fixture.Secrets"); + HardeningResult r = HardeningEngine.harden(req); + assertFalse("a config that applies no transform must not be marked hardened", r.isHardened()); + assertEquals(HardeningResult.Outcome.SKIPPED_NOT_REQUESTED, r.getOutcome()); + } + @Test public void androidDoesNotRenameButStillEncrypts() throws Exception { // renameSupported=false models Android, where R8 is the sole renamer. diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 70d61b13bf7..96478f5630f 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -102,6 +102,29 @@ public void shortStringsAreNotEncrypted() throws Exception { assertEquals(GREETING, c.getMethod("greet").invoke(null)); } + @Test + public void encryptsInterfaceDefaultAndStaticMethodLiterals() throws Exception { + InputStream in = getClass().getResourceAsStream( + "/com/codename1/hardening/fixture/Iface.class"); + ByteArrayOutputStream b = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int r; + while ((r = in.read(buf)) >= 0) { + b.write(buf, 0, r); + } + in.close(); + StringEncryptTransform t = new StringEncryptTransform(true, 99); + byte[] out = t.transform(b.toByteArray()); + assertTrue("interface method literals should be encrypted", t.getEncryptedCount() >= 2); + assertFalse(StringEncryptTransform.containsStringLiteral(out, "interface default secret")); + assertFalse(StringEncryptTransform.containsStringLiteral(out, "interface static secret")); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + // The static method round-trips when loaded. + Class c = new ByteLoader().define("com.codename1.hardening.fixture.Iface", out); + assertEquals("interface static secret", c.getMethod("staticSecret").invoke(null)); + } + /** Defines transformed bytes as a fresh class distinct from the already-loaded fixture. */ private static final class ByteLoader extends ClassLoader { Class define(String name, byte[] b) { diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Iface.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Iface.java new file mode 100644 index 00000000000..4e3c7fd1cc4 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Iface.java @@ -0,0 +1,34 @@ +/* + * 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.hardening.fixture; + +/** A Java 8 interface with an executable default/static method carrying string literals. */ +public interface Iface { + default String secret() { + return "interface default secret"; + } + + static String staticSecret() { + return "interface static secret"; + } +} From 6efa3efaf0ea135607c0b2cd04a778b305525ae4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:13:45 +0700 Subject: [PATCH 011/110] Address Codex round-7 review - Interface decoder invocations use itf=true so ASM writes an InterfaceMethodref, not a Methodref -- otherwise an encrypted default/static interface method throws IncompatibleClassChangeError at run time. - Gate entitlement in the engine CLI on willApplyAnyTransform(cfg) rather than isActive(), so a level whose transforms are all disabled is skipped (not rejected as not-entitled), matching the SKIPPED path. - Embed the engine jar at the plugin's prepare-package phase, not generate-resources, so pr.yml's '-pl codenameone-maven-plugin -am ... test' (which only advances the upstream module through test, before its package/shade) no longer fails resolving the standalone classifier; package/install still embed it. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/com/codename1/hardening/Main.java | 8 ++++---- .../com/codename1/hardening/StringEncryptTransform.java | 9 ++++++--- maven/codenameone-maven-plugin/pom.xml | 7 ++++++- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java index 66fa4f34e7d..1a29fb3419a 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java @@ -106,10 +106,10 @@ static int run(String[] args) { HardeningConfig cfg = HardeningConfig.from(hints, platform, renameSupported); - // Gate entitlement only when hardening will actually run: a per-platform opt-out - // (harden..enabled=false) must be able to produce an unhardened build even - // on a non-entitled account, rather than failing here. - if (cfg.isActive() && !entitled) { + // Gate entitlement only when a transform will actually run: a per-platform opt-out, or a + // level whose transforms are all individually disabled, must produce an unhardened build + // even on a non-entitled account (the engine would return SKIPPED), rather than failing. + if (HardeningEngine.willApplyAnyTransform(cfg) && !entitled) { System.err.println("App hardening is an Enterprise feature and this build is not " + "entitled. Refusing to produce a half-hardened binary."); return EXIT_NOT_ENTITLED; diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index deff1181eed..e7b5c12637d 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -139,7 +139,7 @@ public byte[] transform(byte[] classBytes) { if (DECODER_NAME.equals(mn.name)) { continue; } - changed |= encryptMethodLiterals(cn, mn, base); + changed |= encryptMethodLiterals(cn, mn, base, isInterface); } } @@ -160,7 +160,7 @@ public byte[] transform(byte[] classBytes) { return cw.toByteArray(); } - private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base) { + private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boolean isInterface) { boolean changed = false; AbstractInsnNode insn = mn.instructions.getFirst(); while (insn != null) { @@ -170,8 +170,11 @@ private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base) { if (ldc.cst instanceof String && shouldEncryptLiteral((String) ldc.cst)) { String plain = (String) ldc.cst; ldc.cst = encode(plain, base); + // The itf flag must be true when the decoder lives in an interface, or the JVM + // writes a Methodref instead of an InterfaceMethodref and throws + // IncompatibleClassChangeError at run time. mn.instructions.insert(ldc, new MethodInsnNode( - Opcodes.INVOKESTATIC, cn.name, DECODER_NAME, DECODER_DESC, false)); + Opcodes.INVOKESTATIC, cn.name, DECODER_NAME, DECODER_DESC, isInterface)); encryptedCount++; changed = true; } diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml index 206e4015d27..5e5160ac605 100644 --- a/maven/codenameone-maven-plugin/pom.xml +++ b/maven/codenameone-maven-plugin/pom.xml @@ -387,7 +387,12 @@ unlike copying from a sibling target/ directory. --> embed-hardening-engine - generate-resources + + prepare-package copy From d354db3eed5fad582bc98db5488ff0802915a5d5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:27:27 +0700 Subject: [PATCH 012/110] Address Codex round-8 review - Track rename *requested* (intent) vs rename *enabled* (engine does it). On Android renameEnabled is false but R8 performs the requested rename, so willApplyAnyTransform now counts a rename-only Android build as hardened (transform 'rename:r8') instead of skipping it and leaving cn1.hardened=false. Tested. - The Android R8/enableProguard conflict check respects harden.and.enabled=false: an explicitly opted-out Android target no longer fails that check. - Reject an unknown harden.strings value (e.g. a typo 'constant') in the engine CLI instead of silently enabling the most invasive 'all' mode; the config also falls back to the level default rather than 'all' for an unrecognized value. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/HardeningConfig.java | 32 +++++++++++++++---- .../codename1/hardening/HardeningEngine.java | 7 +++- .../java/com/codename1/hardening/Main.java | 11 +++++++ .../hardening/HardeningEngineTest.java | 20 ++++++++++++ .../builders/AndroidGradleBuilder.java | 4 ++- 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java index 0982ee21cd3..c44c8f0d50d 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java @@ -37,6 +37,7 @@ */ public final class HardeningConfig { private final HardeningProfile profile; + private final boolean renameRequested; private final boolean renameEnabled; private final boolean encryptConstantStrings; private final boolean encryptAllStrings; @@ -46,11 +47,12 @@ public final class HardeningConfig { private final String seed; private final List extraKeepRules; - private HardeningConfig(HardeningProfile profile, boolean renameEnabled, + private HardeningConfig(HardeningProfile profile, boolean renameRequested, boolean renameEnabled, boolean encryptConstantStrings, boolean encryptAllStrings, boolean controlFlow, boolean platformEnabled, String platform, String seed, List extraKeepRules) { this.profile = profile; + this.renameRequested = renameRequested; this.renameEnabled = renameEnabled; this.encryptConstantStrings = encryptConstantStrings; this.encryptAllStrings = encryptAllStrings; @@ -76,7 +78,10 @@ public static HardeningConfig from(Map hints, String platform, b } boolean platformEnabled = boolTri(get(hints, "harden." + platform + ".enabled", "true"), true); - boolean rename = renameSupported && boolTri(get(hints, "harden.rename", null), level.renamesByDefault()); + // renameRequested is the developer's intent; renameEnabled is whether the *engine* renames. + // On Android renameEnabled is false but the rename is still requested and delivered by R8. + boolean renameRequested = boolTri(get(hints, "harden.rename", null), level.renamesByDefault()); + boolean renameEnabled = renameSupported && renameRequested; String strings = get(hints, "harden.strings", null); boolean encConst; @@ -86,16 +91,20 @@ public static HardeningConfig from(Map hints, String platform, b encAll = level.encryptsAllStringsByDefault(); } else { String v = strings.trim().toLowerCase(); - if ("off".equals(v) || "false".equals(v) || "0".equals(v)) { + if ("off".equals(v)) { encConst = false; encAll = false; - } else if ("constants".equals(v) || "1".equals(v)) { + } else if ("constants".equals(v)) { encConst = true; encAll = false; - } else { - // "all", "true", "2", "3" + } else if ("all".equals(v)) { encConst = true; encAll = true; + } else { + // Unknown value: fall back to the level default rather than silently enabling the + // most invasive mode. The CLI (Main) rejects an unknown harden.strings up front. + encConst = level.encryptsConstantStringsByDefault(); + encAll = level.encryptsAllStringsByDefault(); } } @@ -116,7 +125,8 @@ public static HardeningConfig from(Map hints, String platform, b } } - return new HardeningConfig(level, rename, encConst, encAll, cf, platformEnabled, platform, seed, keep); + return new HardeningConfig(level, renameRequested, renameEnabled, encConst, encAll, cf, + platformEnabled, platform, seed, keep); } private static String get(Map hints, String key, String def) { @@ -157,6 +167,14 @@ public boolean isRenameEnabled() { return renameEnabled; } + /** + * Whether renaming was requested, independent of whether this engine performs it. On Android + * this is true while {@link #isRenameEnabled()} is false, because R8 does the rename externally. + */ + public boolean isRenameRequested() { + return renameRequested; + } + public boolean isEncryptConstantStrings() { return encryptConstantStrings; } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index a692308c38b..56e9b72e0b9 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -233,6 +233,9 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi // transform it skipped. This is what the downstream verifier checks against. if (cfg.isRenameEnabled()) { result.getTransformsApplied().add("rename"); + } else if (cfg.isRenameRequested()) { + // Android: the engine doesn't rename, R8 does. Still a rename, still hardened. + result.getTransformsApplied().add("rename:r8"); } if (stringsApplied && encryptedStrings > 0) { result.getTransformsApplied().add(cfg.isEncryptAllStrings() ? "strings:all" : "strings:constants"); @@ -263,7 +266,9 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi */ /** True when at least one transform will actually run for this config and platform. */ static boolean willApplyAnyTransform(HardeningConfig cfg) { - if (cfg.isRenameEnabled()) { + // renameRequested (not renameEnabled): on Android the engine does not rename, but R8 does, + // so a rename-only Android build is still a hardened build and must not be skipped. + if (cfg.isRenameRequested()) { return true; } if (cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform())) { diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java index 1a29fb3419a..347a39693f2 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java @@ -103,6 +103,17 @@ static int run(String[] args) { + "off, standard, aggressive, paranoid."); return EXIT_FAILED; } + // An unrecognized harden.strings must fail rather than silently enabling the most + // invasive ("all") mode on a typo. + String rawStrings = hints.get("harden.strings"); + if (rawStrings != null && rawStrings.trim().length() > 0) { + String s = rawStrings.trim().toLowerCase(); + if (!"off".equals(s) && !"constants".equals(s) && !"all".equals(s)) { + System.err.println("Invalid harden.strings '" + rawStrings + "'. Valid values " + + "are: off, constants, all."); + return EXIT_FAILED; + } + } HardeningConfig cfg = HardeningConfig.from(hints, platform, renameSupported); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index f7f3b982c43..faa72afabdc 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -184,6 +184,26 @@ public void offProfileIsSkippedAndReturnsInput() throws Exception { assertEquals(HardeningResult.Outcome.SKIPPED_NOT_REQUESTED, r.getOutcome()); } + @Test + public void androidRenameOnlyIsHardenedViaR8() throws Exception { + // Android (renameSupported=false), standard with strings off: the engine renames nothing, + // but R8 will, so the build must be marked hardened rather than skipped. + File in = buildInputJar(); + File out = tmp.newFile("and-hardened.jar"); + Map hints = new HashMap(); + hints.put("harden.level", "standard"); + hints.put("harden.strings", "off"); + HardeningRequest req = new HardeningRequest() + .inputJar(in).outputJar(out).mappingFile(tmp.newFile("and-map.txt")) + .workDir(tmp.newFolder("and-work")) + .config(HardeningConfig.from(hints, "and", false)) + .mainClass("com.codename1.hardening.fixture.Secrets"); + HardeningResult r = HardeningEngine.harden(req); + assertTrue("Android rename-only must be marked hardened (R8 renames)", r.isHardened()); + assertEquals(0, r.getRenamedClasses()); + assertTrue(r.getTransformsApplied().contains("rename:r8")); + } + @Test public void nonOffLevelWithAllTransformsDisabledIsSkipped() throws Exception { // standard, but rename off and strings off -> nothing to do -> not stamped hardened. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 090b63db804..2e9e59eaf3d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -829,7 +829,9 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc // level that promises renaming cannot be honored with R8 turned off. Fail rather than ship a // build stamped "hardened" that was never renamed. (harden.rename=false opts out explicitly.) String hardenLevel = request.getArg("harden.level", "off"); - boolean hardenRenames = hardenLevel != null && !"off".equalsIgnoreCase(hardenLevel.trim()) + boolean androidHardeningEnabled = !"false".equalsIgnoreCase(request.getArg("harden.and.enabled", "true")); + boolean hardenRenames = androidHardeningEnabled + && hardenLevel != null && !"off".equalsIgnoreCase(hardenLevel.trim()) && hardenLevel.trim().length() > 0 && !"false".equalsIgnoreCase(request.getArg("harden.rename", "true")); if (hardenRenames && request.getArg("android.enableProguard", "true").equals("false")) { From 71b9c6830c4acb1494447836d6ea0dd024529e26 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:41:50 +0700 Subject: [PATCH 013/110] Address Codex round-9 review - The synthesized decoder returns an interned String, so reference (==) equality that Java guarantees for string literals/constants still holds after encryption (two decodes of the same literal, and a constant vs its inlined readers, are now the same object). Tested. - MappingFile retains the original range's end bound: a single-line original range (e.g. 1:3:...:40:40) collapses every covered line to that line, and a shorter original range is clamped instead of overshooting. Tested. - The plugin depends on the UNCLASSIFIED cn1-hardening artifact for reactor ordering (resolvable from target/classes during '-am ... test'), provided+optional with a wildcard exclusion so ProGuard/ASM stay off the plugin classpath; the shaded 'standalone' jar is still pulled by the dependency-plugin copy at package. Co-Authored-By: Claude Opus 4.8 --- .../hardening/StringEncryptTransform.java | 5 +++- .../hardening/StringEncryptTransformTest.java | 11 ++++++++ .../com/codename1/retrace/MappingFile.java | 26 ++++++++++++++----- .../codename1/retrace/MappingFileTest.java | 10 +++++++ maven/codenameone-maven-plugin/pom.xml | 17 ++++++++++-- 5 files changed, 60 insertions(+), 9 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index e7b5c12637d..c3c5ad1600f 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -274,11 +274,14 @@ private void addDecoder(ClassNode cn, int base, boolean isInterface) { in.add(new org.objectweb.asm.tree.IincInsnNode(2, 1)); in.add(new org.objectweb.asm.tree.JumpInsnNode(Opcodes.GOTO, loop)); in.add(end); - // return new String(c); + // return new String(c).intern(); -- intern so a decoded literal is the canonical String, + // preserving reference (==) equality that Java guarantees for string literals and constants. in.add(new org.objectweb.asm.tree.TypeInsnNode(Opcodes.NEW, "java/lang/String")); in.add(new InsnNode(Opcodes.DUP)); in.add(new VarInsnNode(Opcodes.ALOAD, 1)); in.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, "java/lang/String", "", "([C)V", false)); + in.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/String", "intern", + "()Ljava/lang/String;", false)); in.add(new InsnNode(Opcodes.ARETURN)); if (cn.methods == null) { cn.methods = new java.util.ArrayList(); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 96478f5630f..c605e379f1c 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -90,6 +90,17 @@ public void behaviourIsPreserved() throws Exception { assertEquals(5, c.getMethod("compute", int.class, int.class).invoke(null, 2, 3)); } + @Test + public void decodedLiteralsAreCanonical() throws Exception { + // The decoded literal must be interned, so reference (==) equality that Java guarantees + // for string literals still holds after encryption. + Class c = new ByteLoader().define(CLASS, transformed()); + Object a = c.getMethod("greet").invoke(null); + Object b = c.getMethod("greet").invoke(null); + org.junit.Assert.assertSame("decoded literals must be the canonical interned String", a, b); + org.junit.Assert.assertSame(GREETING.intern(), a); + } + @Test public void shortStringsAreNotEncrypted() throws Exception { // The control integer method has no strings; encryption count comes only from diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java index 003468c8521..8503b9d58a7 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -46,20 +46,32 @@ private static final class MethodMapping { final int startLine; // obfuscated range start (0 if none) final int endLine; // obfuscated range end final int originalStartLine; // original range start (0 if none / same) + final int originalEndLine; // original range end (== start for a single line) - MethodMapping(String originalName, int startLine, int endLine, int originalStartLine) { + MethodMapping(String originalName, int startLine, int endLine, + int originalStartLine, int originalEndLine) { this.originalName = originalName; this.startLine = startLine; this.endLine = endLine; this.originalStartLine = originalStartLine; + this.originalEndLine = originalEndLine; } - /** Maps an observed obfuscated line into the original source line, when both ranges are known. */ + /** + * Maps an observed obfuscated line into the original source line. A single-line original + * range ({@code originalStart == originalEnd}) collapses every covered line to that line; + * otherwise the offset is applied but clamped to the original range end so a shorter + * original range never overshoots. + */ int mapLine(int observed) { - if (startLine != 0 && originalStartLine != 0 && observed >= startLine && observed <= endLine) { - return originalStartLine + (observed - startLine); + if (startLine == 0 || originalStartLine == 0 || observed < startLine || observed > endLine) { + return observed; } - return observed; + if (originalEndLine <= originalStartLine) { + return originalStartLine; + } + int mapped = originalStartLine + (observed - startLine); + return mapped > originalEndLine ? originalEndLine : mapped; } } @@ -137,6 +149,7 @@ private void parseMemberLine(ClassMapping cm, String line) { // left is now "returnType methodName(args)" optionally followed by ":origStart[:origEnd]" // (R8 / optimized ProGuard maps the obfuscated range to a distinct original range). int originalStartLine = 0; + int originalEndLine = 0; int closeParen = left.indexOf(')'); if (closeParen >= 0) { String afterParen = left.substring(closeParen + 1); @@ -145,6 +158,7 @@ private void parseMemberLine(ClassMapping cm, String line) { if (parts.length >= 1) { originalStartLine = parseIntSafe(parts[0]); } + originalEndLine = parts.length >= 2 ? parseIntSafe(parts[1]) : originalStartLine; } left = left.substring(0, closeParen + 1); } @@ -158,7 +172,7 @@ private void parseMemberLine(ClassMapping cm, String line) { list = new ArrayList(); cm.methods.put(obfName, list); } - list.add(new MethodMapping(originalMethod, startLine, endLine, originalStartLine)); + list.add(new MethodMapping(originalMethod, startLine, endLine, originalStartLine, originalEndLine)); } /** diff --git a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java index 4672dcd9556..87a6232f1e3 100644 --- a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -70,6 +70,16 @@ public void mapsDistinctOriginalLineRange() throws Exception { assertEquals(41, out.getLineNumber()); } + @Test + public void singleLineOriginalRangeCollapses() throws Exception { + // Obfuscated lines 1:3 all map to original line 40 (a single-line original range). + MappingFile mf = MappingFile.parse( + "com.example.MyForm -> zqaaaa:\n" + + " 1:3:void f():40:40 -> a\n"); + assertEquals(40, mf.retrace(new Frame("zqaaaa", "a", "zqaaaa.java", 3)).getLineNumber()); + assertEquals(40, mf.retrace(new Frame("zqaaaa", "a", "zqaaaa.java", 1)).getLineNumber()); + } + @Test public void unknownClassPassesThroughUnchanged() throws Exception { MappingFile mf = MappingFile.parse(MAPPING); diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml index 5e5160ac605..dfd96236479 100644 --- a/maven/codenameone-maven-plugin/pom.xml +++ b/maven/codenameone-maven-plugin/pom.xml @@ -266,8 +266,21 @@ com.codenameone cn1-hardening ${project.version} - standalone - runtime + + provided + true + + + * + * + + + ProGuard/ASM off this plugin's classpath (the engine is only ever forked). --> provided true From 495a599908818ea8645acfccdf6e686c0128ccfc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:56:03 +0700 Subject: [PATCH 015/110] Address Codex round-10 review - willApplyAnyTransform returns false when the platform is opted out (harden..enabled=false), so a non-entitled build of an opted-out target is skipped rather than rejected as not-entitled. Tested. - Seed the rename dictionary: Cn1NameFactory.writeDictionary shifts the starting word by the seed / build key, so harden.seed actually changes the mapping (and the same seed reproduces it) instead of every build getting identical names. Tested. - Make ParparVM String.intern() atomic (synchronized on the shared pool), so concurrent interning of equal decoded literals returns the same object and can't corrupt the pool -- the port-specific root cause behind the decoder's canonical-string guarantee. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/Cn1NameFactory.java | 11 ++- .../codename1/hardening/HardeningEngine.java | 9 ++- .../hardening/Cn1NameFactoryTest.java | 67 +++++++++++++++++++ .../hardening/HardeningEngineTest.java | 19 ++++++ vm/JavaAPI/src/java/lang/String.java | 16 +++-- 5 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 maven/cn1-hardening/src/test/java/com/codename1/hardening/Cn1NameFactoryTest.java diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java index 1a63938099c..43c5193a78a 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java @@ -84,15 +84,20 @@ public static String word(int index) { /** * Writes a dictionary of {@code count} distinct names to {@code out}. A build feeds the same * file as the class, member and package obfuscation dictionary; sizing it above the number of - * names any one scope needs guarantees ProGuard never falls back to short names. + * names any one scope needs guarantees ProGuard never falls back to short names. The + * {@code seed} shifts the starting word so that different seeds (or build keys) yield different + * name assignments -- hence different mappings -- while the same seed reproduces them exactly. */ - public static void writeDictionary(File out, int count) throws IOException { + public static void writeDictionary(File out, int count, int seed) throws IOException { int safeCount = Math.max(count, 1); + // A stable, non-negative offset from the seed; the word() indexing stays injective, so the + // offset never introduces collisions. + int offset = (seed & 0x7fffffff) % 1000000; FileOutputStream fo = new FileOutputStream(out); try { Writer w = new BufferedWriter(new OutputStreamWriter(fo, Charset.forName("UTF-8"))); for (int i = 0; i < safeCount; i++) { - w.write(word(i)); + w.write(word(offset + i)); w.write('\n'); } w.flush(); diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 56e9b72e0b9..eb744b9d53f 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -146,7 +146,9 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "JDK 17; for a local hardened build, run it on JDK 8-" + PROGUARD_MAX_JDK + "."); } File dict = new File(workDir, "cn1-dict.txt"); - Cn1NameFactory.writeDictionary(dict, Cn1NameFactory.dictionarySizeFor(classesIn)); + // Seed the dictionary so harden.seed / the build key actually changes the mapping. + Cn1NameFactory.writeDictionary(dict, Cn1NameFactory.dictionarySizeFor(classesIn), + deriveSeed(cfg, req.getBuildKey())); File renamedJar = new File(workDir, "renamed.jar"); ProGuardRunner.rename(classesJar, renamedJar, mappingFile, req.getLibraryJars(), keepRules, dict, workDir); @@ -266,6 +268,11 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi */ /** True when at least one transform will actually run for this config and platform. */ static boolean willApplyAnyTransform(HardeningConfig cfg) { + // A per-platform opt-out means nothing runs for this target -- so a non-entitled build with + // harden..enabled=false is skipped, not rejected as not-entitled. + if (!cfg.isPlatformEnabled()) { + return false; + } // renameRequested (not renameEnabled): on Android the engine does not rename, but R8 does, // so a rename-only Android build is still a hardened build and must not be skipped. if (cfg.isRenameRequested()) { diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/Cn1NameFactoryTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/Cn1NameFactoryTest.java new file mode 100644 index 00000000000..f5120deb015 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/Cn1NameFactoryTest.java @@ -0,0 +1,67 @@ +/* + * 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.hardening; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.nio.charset.Charset; +import java.nio.file.Files; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** The dictionary is prefixed (no short names) and seed-dependent (reproducible renaming). */ +public class Cn1NameFactoryTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void everyGeneratedNameIsPrefixedAndLongEnough() { + for (int i = 0; i < 5000; i += 137) { + String w = Cn1NameFactory.word(i); + assertTrue(w, w.startsWith(Cn1NameFactory.PREFIX)); + assertTrue(w, w.length() >= 6); + assertFalse("must not contain '_'", w.indexOf('_') >= 0); + assertEquals("lower-case only", w.toLowerCase(), w); + } + } + + @Test + public void differentSeedsProduceDifferentDictionariesButSameSeedReproduces() throws Exception { + File a = tmp.newFile("a.txt"); + File b = tmp.newFile("b.txt"); + File a2 = tmp.newFile("a2.txt"); + Cn1NameFactory.writeDictionary(a, 100, 111); + Cn1NameFactory.writeDictionary(b, 100, 222); + Cn1NameFactory.writeDictionary(a2, 100, 111); + String sa = new String(Files.readAllBytes(a.toPath()), Charset.forName("UTF-8")); + String sb = new String(Files.readAllBytes(b.toPath()), Charset.forName("UTF-8")); + String sa2 = new String(Files.readAllBytes(a2.toPath()), Charset.forName("UTF-8")); + assertFalse("different seeds must produce different name assignments", sa.equals(sb)); + assertEquals("the same seed must reproduce the dictionary exactly", sa, sa2); + } +} diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index faa72afabdc..2000ec98422 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -184,6 +184,25 @@ public void offProfileIsSkippedAndReturnsInput() throws Exception { assertEquals(HardeningResult.Outcome.SKIPPED_NOT_REQUESTED, r.getOutcome()); } + @Test + public void platformOptOutIsSkippedNotHardened() throws Exception { + File in = buildInputJar(); + File out = tmp.newFile("optout-hardened.jar"); + Map hints = new HashMap(); + hints.put("harden.level", "standard"); + hints.put("harden.ios.enabled", "false"); + HardeningRequest req = new HardeningRequest() + .inputJar(in).outputJar(out).mappingFile(tmp.newFile("optout-map.txt")) + .workDir(tmp.newFolder("optout-work")) + .config(HardeningConfig.from(hints, "ios", true)) + .mainClass("com.codename1.hardening.fixture.Secrets"); + HardeningResult r = HardeningEngine.harden(req); + assertFalse(r.isHardened()); + assertEquals(HardeningResult.Outcome.SKIPPED_PLATFORM_DISABLED, r.getOutcome()); + assertFalse("an opted-out platform must not count as an applied transform", + HardeningEngine.willApplyAnyTransform(HardeningConfig.from(hints, "ios", true))); + } + @Test public void androidRenameOnlyIsHardenedViaR8() throws Exception { // Android (renameSupported=false), standard with strings off: the engine renames nothing, diff --git a/vm/JavaAPI/src/java/lang/String.java b/vm/JavaAPI/src/java/lang/String.java index 2acccabe7af..83bfacfd477 100644 --- a/vm/JavaAPI/src/java/lang/String.java +++ b/vm/JavaAPI/src/java/lang/String.java @@ -606,12 +606,18 @@ public int indexOf(java.lang.String subString, int start){ * All literal strings and string-valued constant expressions are interned. String literals are defined in Section 3.10.5 of the Java Language Specification */ public java.lang.String intern() { - int off = str.indexOf(this); - if(off > -1) { - return str.get(off); + // Synchronized on the shared pool: intern() must be atomic so two threads canonicalizing + // equal strings concurrently return the same object (and never corrupt the pool by adding + // during another thread's traversal). The JDK contract requires s.intern()==t.intern() + // whenever s.equals(t). + synchronized(str) { + int off = str.indexOf(this); + if(off > -1) { + return str.get(off); + } + str.add(this); + return this; } - str.add(this); - return this; } /** From c94e6dd9b8d2e3e83874a21c30600d6e515de6e9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:07:59 +0700 Subject: [PATCH 016/110] Address Codex round-11 review - Control-flow guard derives its predicate from Runtime.getRuntime(). availableProcessors() (contractually >= 1, unfoldable) instead of a system property whose value could be present-but-empty and collapse the guard into its dead arm. - IPhoneBuilder reports the 'mac' hardening platform whenever macNative.enabled=true (the signal the native-Mac target actually sets), so harden.mac.enabled applies to the Mac output; the previous ios.enabled check was never set by any producer. - Docs: harden.keep is one-rule-per-line (newline-separated only); stop documenting ';'-separation, which the parser can't use because ';' is legal inside a rule body. Co-Authored-By: Claude Opus 4.8 --- docs/developer-guide/App-Hardening.asciidoc | 2 +- .../codename1/hardening/ControlFlowTransform.java | 15 +++++++-------- .../com/codename1/builders/IPhoneBuilder.java | 9 ++++----- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/docs/developer-guide/App-Hardening.asciidoc b/docs/developer-guide/App-Hardening.asciidoc index 61aa7229e4d..b50f56279c3 100644 --- a/docs/developer-guide/App-Hardening.asciidoc +++ b/docs/developer-guide/App-Hardening.asciidoc @@ -61,7 +61,7 @@ codename1.arg.harden.level=standard |`harden.keep` |_(none)_ -|Keep rules in ProGuard syntax (newline- or `;`-separated), for classes resolved by name at runtime that the automatic analysis can't see. Same syntax as `android.proguardKeep`, so existing rules port directly. +|Keep rules in ProGuard syntax, one rule per line, for classes resolved by name at runtime that the automatic analysis can't see. Same syntax as `android.proguardKeep`, so existing rules port directly. (Rules are separated by newlines only, since a `;` is legal inside a rule body such as `{ *; }`.) |`harden..enabled` |`true` diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java index d9fbf3fe792..d7e2f128838 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java @@ -171,14 +171,13 @@ private void addGuardField(ClassNode cn) { private void initGuardField(ClassNode cn) { InsnList init = new InsnList(); - // zq$cf = System.getProperty("java.home", "cn1").length(); -- always >= 1, never foldable. - // The two-arg overload guarantees a non-null result (java.home can be absent on Android), - // so the guard can never NPE in . - init.add(new LdcInsnNode("java.home")); - init.add(new LdcInsnNode("cn1")); - init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", - "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", false)); - init.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/String", "length", "()I", false)); + // zq$cf = Runtime.getRuntime().availableProcessors(); -- contractually >= 1 on every JVM, + // and a runtime call the optimizer/decompiler cannot fold, so the guard is always taken and + // can neither NPE nor (unlike a possibly-empty system property) collapse to a zero value. + init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Runtime", "getRuntime", + "()Ljava/lang/Runtime;", false)); + init.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/Runtime", "availableProcessors", + "()I", false)); init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, GUARD_FIELD, GUARD_DESC)); MethodNode clinit = null; 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 87673e39998..f615381b388 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 @@ -479,11 +479,10 @@ private String podVersionRequirement(String hint, String fallback) { @Override protected String hardeningPlatform(BuildRequest request) { - // A native-Mac build reports "mac" so harden.mac.enabled / harden.ios.enabled apply to the - // right output. (A combined iOS build that also emits a Mac slice hardens the shared jar - // once, under "ios".) - if ("true".equals(request.getArg("macNative.enabled", "false")) - && !"true".equals(request.getArg("ios.enabled", "true"))) { + // The native-Mac target sets macNative.enabled=true (BuildMacNativeMojo / CN1BuildMojo), so + // a build producing a Mac slice reports "mac" and honors harden.mac.enabled. The shared + // application jar is hardened once, so a combined build hardens the Mac output under "mac". + if ("true".equals(request.getArg("macNative.enabled", "false"))) { return "mac"; } return "ios"; From b625567eec8841afa224acc64450a067f79f088a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:28:45 +0700 Subject: [PATCH 017/110] Address Codex round-12 review - InputJarKeepScanner now also collects static-final String field ConstantValue attributes, so a class named for reflection only in a constant field (never an LDC) is kept instead of renamed. - The engine exports its derived keep rules to a --r8keep file; on Android (where R8 is the sole renamer and the engine does not rename) Executor passes the file and AndroidGradleBuilder feeds it to proguard.cfg, so reflectively referenced classes reach R8 rather than being renamed out from under the lookup. - MangleCollisionCheck runs only for the ParparVM-C targets (ios/mac/watch/tv/win/linux) whose symbol mangle can actually alias two names; on Android/JavaSE a.b_c and a.b.c stay distinct, so the check no longer aborts legal builds. - The native-Mac targets resolve their harden..enabled opt-out from the build target (mac), matching IPhoneBuilder, instead of the platform=ios they run under. - Two regression tests: scanner keeps a class named only by a field constant; Android run exports reflection + main + harden.keep rules to the R8 keep file. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/HardeningEngine.java | 30 ++++++- .../codename1/hardening/HardeningRequest.java | 10 +++ .../hardening/InputJarKeepScanner.java | 11 +++ .../java/com/codename1/hardening/Main.java | 4 +- .../hardening/HardeningEngineTest.java | 82 ++++++++++++++++++- .../builders/AndroidGradleBuilder.java | 11 +++ .../java/com/codename1/builders/Executor.java | 15 ++++ .../com/codename1/maven/CN1BuildMojo.java | 24 +++++- 8 files changed, 180 insertions(+), 7 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index eb744b9d53f..5e68bd83330 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -133,6 +133,18 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi keepRules.addAll(serviceDescriptorKeeps(nonClass)); keepRules.addAll(cfg.getExtraKeepRules()); + // Export the derived keep rules for a downstream renamer the engine doesn't drive itself. + // On Android R8 is the sole renamer (isRenameEnabled()==false), so without this the classes + // the scanner found reflectively (Class.forName targets, service providers, name-bound + // property objects, the app's own harden.keep) would be invisible to R8 and get renamed. + if (req.getR8KeepFile() != null) { + StringBuilder r8 = new StringBuilder(); + for (String rule : keepRules) { + r8.append(rule).append('\n'); + } + writeText(req.getR8KeepFile(), r8.toString()); + } + Map renamed; int renamedCount = 0; File mappingFile = req.getMappingFile(); @@ -206,7 +218,12 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi } } - MangleCollisionCheck.check(renamed.keySet()); + // The a.b_c / a.b.c -> a_b_c collision only exists in the ParparVM C symbol mangle. On + // Android (R8/DEX -- and the engine does not even rename there) and JavaSE (plain JVM), + // '.' vs '_' stay distinct, so two legal classes must not abort the build. + if (translatesThroughParparVMC(cfg.getPlatform())) { + MangleCollisionCheck.check(renamed.keySet()); + } OutputVerifier.verify(renamed, hierarchy); // Idempotence marker: a nested builder delegation must not harden twice. @@ -299,6 +316,17 @@ static boolean controlFlowSafeFor(String platform) { || "javase".equals(platform) || "desktop".equals(platform); } + /** + * The ports whose classes are translated to C by ParparVM, where the class/package mangle + * ({@code . / $} all collapse to {@code _}) can make two legal Java names share one C symbol. + * The collision guard is meaningful only for these; Android (DEX) and JavaSE (JVM) keep the + * names distinct, and JavaScript uses a different mangling entirely. + */ + static boolean translatesThroughParparVMC(String platform) { + return "ios".equals(platform) || "mac".equals(platform) || "watch".equals(platform) + || "tv".equals(platform) || "win".equals(platform) || "linux".equals(platform); + } + /** * A classloader over the (renamed) application classes plus the library jars, for stack-map * frame computation. JDK library classes resolve through the parent (bootstrap) loader, so the diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningRequest.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningRequest.java index bcf77090d98..fd2370661da 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningRequest.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningRequest.java @@ -37,6 +37,7 @@ public final class HardeningRequest { private File outputJar; private File mappingFile; private File reportFile; + private File r8KeepFile; private File workDir; private HardeningConfig config; private String mainClass; @@ -79,6 +80,15 @@ public HardeningRequest reportFile(File f) { return this; } + public File getR8KeepFile() { + return r8KeepFile; + } + + public HardeningRequest r8KeepFile(File f) { + this.r8KeepFile = f; + return this; + } + public File getWorkDir() { return workDir; } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java index faa4db11b4e..0375de38087 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java @@ -102,5 +102,16 @@ public void visitLdcInsn(Object value) { } }; } + + @Override + public org.objectweb.asm.FieldVisitor visitField(int access, String name, String descriptor, + String signature, Object value) { + // A reflective class name may live only in a static-final String field's ConstantValue + // attribute, never as an LDC (e.g. read by an external framework). Collect those too. + if (value instanceof String) { + stringConstants.add((String) value); + } + return null; + } } } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java index 347a39693f2..e78d609d304 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java @@ -62,7 +62,7 @@ static int run(String[] args) { try { if (args.length == 0 || !"harden".equals(args[0])) { System.err.println("usage: harden --in --out --mapping " - + "--report --config "); + + "--report [--r8keep ] --config "); return EXIT_FAILED; } Map opts = parseOptions(args); @@ -70,6 +70,7 @@ static int run(String[] args) { File out = fileOpt(opts, "out"); File mapping = fileOpt(opts, "mapping"); File report = opts.containsKey("report") ? new File(opts.get("report")) : null; + File r8Keep = opts.containsKey("r8keep") ? new File(opts.get("r8keep")) : null; File configFile = fileOpt(opts, "config"); Properties props = new Properties(); @@ -131,6 +132,7 @@ static int run(String[] args) { .outputJar(out) .mappingFile(mapping) .reportFile(report) + .r8KeepFile(r8Keep) .workDir(out.getAbsoluteFile().getParentFile()) .config(cfg) .mainClass(mainClass) diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index 2000ec98422..2e998f83bdf 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -72,6 +72,12 @@ private File buildInputJar() throws Exception { } private void putClass(ZipOutputStream zos, String internal) throws Exception { + zos.putNextEntry(new ZipEntry(internal + ".class")); + zos.write(resourceBytes(internal)); + zos.closeEntry(); + } + + private byte[] resourceBytes(String internal) throws Exception { InputStream in = getClass().getResourceAsStream("/" + internal + ".class"); ByteArrayOutputStream b = new ByteArrayOutputStream(); byte[] buf = new byte[4096]; @@ -80,9 +86,23 @@ private void putClass(ZipOutputStream zos, String internal) throws Exception { b.write(buf, 0, r); } in.close(); - zos.putNextEntry(new ZipEntry(internal + ".class")); - zos.write(b.toByteArray()); - zos.closeEntry(); + return b.toByteArray(); + } + + /** + * A synthetic class whose only reference to {@code targetBinaryName} is a static-final String + * field carrying it as a {@code ConstantValue} attribute -- never an LDC. Models a class name a + * framework reads reflectively from a constant field. + */ + private static byte[] classWithConstantNamingField(String internalName, String targetBinaryName) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internalName, null, "java/lang/Object", null); + cw.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "TARGET", "Ljava/lang/String;", + null, targetBinaryName).visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); } private HardeningResult harden(HardeningProfile profile, String platform, boolean renameSupported) @@ -272,6 +292,62 @@ public void javascriptSkipsStringEncryption() throws Exception { assertTrue(r.getRenamedClasses() >= 1); } + @Test + public void scannerKeepsClassNamedOnlyByAFieldConstant() throws Exception { + // The class name lives solely in a static-final String field's ConstantValue attribute, + // never as an LDC, so a method-instruction-only scan would miss it. + byte[] ref = classWithConstantNamingField( + "com/codename1/hardening/fixture/Ref", "com.codename1.hardening.fixture.Helper"); + Map classes = new HashMap(); + classes.put("com/codename1/hardening/fixture/Ref", ref); + classes.put(HELPER, resourceBytes(HELPER)); + InputJarKeepScanner scanner = new InputJarKeepScanner(); + scanner.scan(classes); + assertTrue("class named by a field ConstantValue must be kept", + scanner.keepRules().contains( + "-keep class com.codename1.hardening.fixture.Helper { *; }")); + } + + @Test + public void androidExportsReflectionKeepsToR8() throws Exception { + // On Android the engine does not rename (R8 does), so the classes the scanner found + // reflectively must be written to the R8 keep file or R8 renames them out from under the + // reflective lookup. Ref names Helper only via a field constant. + File jar = tmp.newFile("r8.jar"); + FileOutputStream fo = new FileOutputStream(jar); + ZipOutputStream zos = new ZipOutputStream(fo); + putClass(zos, SECRETS); + putClass(zos, HELPER); + zos.putNextEntry(new ZipEntry("com/codename1/hardening/fixture/Ref.class")); + zos.write(classWithConstantNamingField( + "com/codename1/hardening/fixture/Ref", "com.codename1.hardening.fixture.Helper")); + zos.closeEntry(); + zos.finish(); + fo.close(); + + File out = tmp.newFile("r8-hardened.jar"); + File r8Keep = tmp.newFile("cn1-r8-keep.pro"); + Map hints = new HashMap(); + hints.put("harden.level", "standard"); + hints.put("harden.keep", "-keep class com.example.Manual { *; }"); + HardeningRequest req = new HardeningRequest() + .inputJar(jar).outputJar(out).mappingFile(tmp.newFile("r8-map.txt")) + .r8KeepFile(r8Keep) + .workDir(tmp.newFolder("r8-work")) + .config(HardeningConfig.from(hints, "and", false)) + .mainClass("com.codename1.hardening.fixture.Secrets"); + HardeningResult r = HardeningEngine.harden(req); + assertTrue(r.isHardened()); + assertTrue("engine must emit the R8 keep file", r8Keep.isFile()); + String keep = new String(Files.readAllBytes(r8Keep.toPath()), Charset.forName("UTF-8")); + assertTrue("reflectively referenced class must reach R8", + keep.contains("-keep class com.codename1.hardening.fixture.Helper { *; }")); + assertTrue("the main class must reach R8", + keep.contains("com.codename1.hardening.fixture.Secrets")); + assertTrue("the user's harden.keep must reach R8", + keep.contains("-keep class com.example.Manual { *; }")); + } + private boolean hasZqClass(java.util.Set names) { for (String n : names) { if (n.endsWith(".class") && n.substring(n.lastIndexOf('/') + 1).startsWith(Cn1NameFactory.PREFIX)) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 2e9e59eaf3d..82da673f95d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -769,6 +769,17 @@ private String hardeningR8Keep(BuildRequest request) { if (level == null || level.trim().length() == 0 || "off".equalsIgnoreCase(level.trim())) { return ""; } + // Prefer the full keep set the engine derived from the input jar: besides the name-bound + // property-object rule and the user's harden.keep, it covers the classes the ASM scanner + // found reflectively (Class.forName targets, META-INF/services providers, GUI-builder + // references). Those are invisible to R8, so without them R8 would rename a reflectively + // referenced class and the hardened release would fail to resolve its original name. + String engineKeep = getLastHardeningR8Keep(); + if (engineKeep != null && engineKeep.trim().length() > 0) { + return engineKeep.endsWith("\n") ? engineKeep : engineKeep + "\n"; + } + // Fallback when the engine emitted no keep file (e.g. build() invoked without runBuild): + // keep at least the load-bearing rules so a hardened build still resolves. StringBuilder sb = new StringBuilder(); sb.append("-keepclassmembernames class * implements " + "com.codename1.properties.PropertyBusinessObject { *; }\n"); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 8542efd3105..6f493138eaa 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2398,12 +2398,23 @@ protected java.util.List hardeningLibraryJars(BuildRequest request) { private File lastHardeningMapping; private String lastHardeningMappingId = ""; + private String lastHardeningR8Keep = ""; /** The cross-platform obfuscation mapping produced by the last {@link #hardenSourceJar} call, or null. */ public File getLastHardeningMapping() { return lastHardeningMapping; } + /** + * The keep rules the engine derived from the input jar (reflective {@code Class.forName} + * targets, service providers, name-bound property objects, the app's {@code harden.keep}), + * for a downstream renamer the engine does not drive itself -- specifically R8 on Android. + * Empty when hardening did not run or emitted no rules. + */ + public String getLastHardeningR8Keep() { + return lastHardeningR8Keep; + } + /** The mapping id produced by the last {@link #hardenSourceJar} call, or empty. */ public String getLastHardeningMappingId() { return lastHardeningMappingId; @@ -2447,6 +2458,7 @@ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildEx File hardened = new File(workDir, "hardened.jar"); File mapping = new File(workDir, "cn1-mapping.txt"); File report = new File(workDir, "cn1-harden-report.json"); + File r8Keep = new File(workDir, "cn1-r8-keep.pro"); File config = new File(workDir, "config.properties"); writeHardeningConfig(config, request); @@ -2464,6 +2476,8 @@ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildEx cmd.add(mapping.getAbsolutePath()); cmd.add("--report"); cmd.add(report.getAbsolutePath()); + cmd.add("--r8keep"); + cmd.add(r8Keep.getAbsolutePath()); cmd.add("--config"); cmd.add(config.getAbsolutePath()); @@ -2471,6 +2485,7 @@ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildEx if (exit == 0) { lastHardeningMapping = mapping.isFile() ? mapping : null; lastHardeningMappingId = readMappingId(mapping); + lastHardeningR8Keep = r8Keep.isFile() ? readFileToString(r8Keep) : ""; // Propagate the mapping id / hardened flag / level into the request BEFORE the // builder generates its stubs, so the stubs stamp them as runtime properties // (Hardening.isHardened(), the crash report's mappingId/hardenLevel). diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index db7b11a72f5..a8678896984 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -194,8 +194,13 @@ private void applyHardeningPreflight() throws MojoFailureException { } String level = settings.getProperty("codename1.arg.harden.level", "off"); // A per-platform opt-out (harden..enabled=false) means hardening won't run for - // this target, so the pre-flight must not reject it -- treat the level as off. - String hardenPlatform = normalizeHardenPlatform(platform); + // this target, so the pre-flight must not reject it -- treat the level as off. The native-Mac + // targets ride the iOS pipeline with platform=ios, so derive their opt-out key from the + // build target instead (matching IPhoneBuilder, which reports "mac" for them). + String hardenPlatform = hardenPlatformForBuildTarget(buildTarget); + if (hardenPlatform == null) { + hardenPlatform = normalizeHardenPlatform(platform); + } if (hardenPlatform != null && "false".equalsIgnoreCase( settings.getProperty("codename1.arg.harden." + hardenPlatform + ".enabled", "true").trim())) { level = "off"; @@ -241,6 +246,21 @@ private void applyHardeningPreflight() throws MojoFailureException { } } + /** + * The {@code harden..enabled} opt-out key implied by the build target, for targets + * whose {@code codename1.platform} does not name their real hardening platform. The native-Mac + * targets (mac-source / mac-os-x-native) run with platform=ios but harden as "mac", so their + * opt-out is {@code harden.mac.enabled}. Returns {@code null} when the target carries no such + * override and the platform value should be used. + */ + private static String hardenPlatformForBuildTarget(String buildTarget) { + if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget) + || BUILD_TARGET_MAC_NATIVE.equals(buildTarget)) { + return "mac"; + } + return null; + } + /** Maps {@code codename1.platform} to the {@code harden..enabled} opt-out key. */ private static String normalizeHardenPlatform(String platform) { if (platform == null) { From df007c44006e89aed8bc5785d7a9ed21823112bb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:41:52 +0700 Subject: [PATCH 018/110] Address Codex round-13 review + fix developer-guide LanguageTool gate - Stamp cn1.mappingId / cn1.hardened / cn1.hardenLevel in the JavaScript, Linux and Windows launchers via the shared hardeningRuntimeProperties helper, so Hardening.isHardened() and crash payloads carry the mapping id / level on those ports too (parity with iOS and Android). On JavaScript the stamp runs right after ParparVMBootstrap.bootstrap returns, when Display is live. - Parse the Android harden.rename / harden.and.enabled opt-outs with a shared tri-state helper (Executor.hardenBoolArg) matching HardeningConfig.boolTri, so harden.rename=off and =0 behave like =false instead of being misread as 'renaming still requested' and rejecting the build with R8 disabled. Regression test HardeningBooleanArgTest. - Add 'symbolicates' to languagetool-accept.txt (fixes the red developer-guide quality gate: 2 MORFOLOGIK matches on the Crash-reports paragraph). Co-Authored-By: Claude Opus 4.8 --- docs/developer-guide/languagetool-accept.txt | 1 + .../builders/AndroidGradleBuilder.java | 7 +- .../java/com/codename1/builders/Executor.java | 25 +++++ .../codename1/builders/JavaScriptBuilder.java | 10 +- .../builders/LinuxNativeBuilder.java | 3 + .../builders/WindowsNativeBuilder.java | 3 + .../builders/HardeningBooleanArgTest.java | 93 +++++++++++++++++++ 7 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HardeningBooleanArgTest.java diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 93b772efdfa..7b1d49ef8d7 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -560,6 +560,7 @@ transcoder # throughout the Crash Protection chapter and standard in the field. [Ss]ymbolicated [Ss]ymbolicate +[Ss]ymbolicates [Ss]ymbolication # Short for "deduplication" -- "dedup the same crash" is how everyone # in the crash-reporting space talks. Shows up in CrashReportPayload diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 82da673f95d..cbc4b2e899d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -840,11 +840,14 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc // level that promises renaming cannot be honored with R8 turned off. Fail rather than ship a // build stamped "hardened" that was never renamed. (harden.rename=false opts out explicitly.) String hardenLevel = request.getArg("harden.level", "off"); - boolean androidHardeningEnabled = !"false".equalsIgnoreCase(request.getArg("harden.and.enabled", "true")); + // Parse the opt-outs with the same tri-state rules the engine's HardeningConfig.boolTri uses + // (false/0/off/no all mean off), so harden.rename=off and harden.rename=0 behave identically + // to harden.rename=false here rather than being misread as "renaming still requested". + boolean androidHardeningEnabled = hardenBoolArg(request, "harden.and.enabled", true); boolean hardenRenames = androidHardeningEnabled && hardenLevel != null && !"off".equalsIgnoreCase(hardenLevel.trim()) && hardenLevel.trim().length() > 0 - && !"false".equalsIgnoreCase(request.getArg("harden.rename", "true")); + && hardenBoolArg(request, "harden.rename", true); if (hardenRenames && request.getArg("android.enableProguard", "true").equals("false")) { throw new BuildException("harden.level=" + hardenLevel + " requires Android's R8/ProGuard " + "renaming, but android.enableProguard=false disables it. Enable R8, set " diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 6f493138eaa..9c2eb71902b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2429,6 +2429,31 @@ public boolean runBuild(File sourceZip, BuildRequest request) throws BuildExcept return build(hardenSourceJar(sourceZip, request), request); } + /** + * Reads a {@code harden.*} boolean argument with the same tri-state rules the engine's + * {@code HardeningConfig.boolTri} applies: {@code true/1/2/3/on} are true, {@code false/0/off} + * are false, and anything else (including unset/blank) falls back to {@code def}. Builders must + * use this rather than a bare {@code "false".equals(...)} so a documented alias like + * {@code harden.rename=off} is not silently misread. + */ + protected boolean hardenBoolArg(BuildRequest request, String key, boolean def) { + String v = request.getArg(key, null); + if (v == null) { + return def; + } + String t = v.trim().toLowerCase(); + if (t.length() == 0) { + return def; + } + if ("true".equals(t) || "1".equals(t) || "2".equals(t) || "3".equals(t) || "on".equals(t)) { + return true; + } + if ("false".equals(t) || "0".equals(t) || "off".equals(t)) { + return false; + } + return def; + } + /** * Applies the app-hardening transform to the merged application jar and returns the jar the * build should proceed with. When hardening is not requested (or already applied, or declined diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java index 84765aa55c8..5a89a8b0b52 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java @@ -137,7 +137,7 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException List generatedImpls = generateNativeInterfaceImpls(buildDir, nativeInterfaces); String translatorAppName = sanitizeIdentifier(request.getMainClass()) + "JavaScriptMain"; - File launcherJava = writeLauncher(buildDir, translatorAppName, request.getPackageName(), request.getMainClass(), stageClasses, nativeInterfaces); + File launcherJava = writeLauncher(buildDir, translatorAppName, request.getPackageName(), request.getMainClass(), stageClasses, nativeInterfaces, request); compileLauncher(launcherJava, generatedImpls, stageClasses, portClassesStaged); File parparvmCompilerJar = extractParparVMCompiler(); @@ -392,7 +392,7 @@ private String resolveJavac() { } private File writeLauncher(File workDir, String launcherName, String packageName, String mainClass, File stageClasses, - List> nativeInterfaces) throws IOException { + List> nativeInterfaces, BuildRequest request) throws IOException { // If the build-time SVG transcoder generated com.codename1.generated.svg.SVGRegistry // for this app, register the transcoded SVGs at startup -- the JS-port analogue of // JavaSEPort.init's reflective installGlobal(). A DIRECT call (not reflection) is @@ -422,6 +422,12 @@ private File writeLauncher(File workDir, String launcherName, String packageName } } pw.println(" ParparVMBootstrap.bootstrap(new " + mainClass + "());"); + // bootstrap() runs Display.init followed by the app's init/start synchronously, so + // Display is live once it returns; stamp the hardening metadata now so + // Hardening.isHardened() and crash reports carry the mapping id / level on this port + // too (parity with iOS and Android). hardeningRuntimeProperties emits 8-space-indented + // Display.getInstance().setProperty(...) lines. + pw.print(hardeningRuntimeProperties(request)); pw.println(" }"); pw.println("}"); } finally { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java index 8cdf0f8a357..cb582804443 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java @@ -642,6 +642,9 @@ private void writeBootstrapStub(BuildRequest request, File classesDir, File stub src.append(registerNatives); src.append(" final ").append(main).append(" app = new ").append(main).append("();\n"); src.append(" Display.init(null);\n"); + // Stamp the hardening metadata so Hardening.isHardened() and crash reports carry the + // mapping id / level on this port too (parity with iOS and Android). + src.append(hardeningRuntimeProperties(request)); src.append(svgInstall); src.append(" Display.getInstance().callSerially(new Runnable() {\n"); src.append(" public void run() {\n"); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java index c02c09f74e1..df996d05aff 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java @@ -1198,6 +1198,9 @@ private void writeBootstrapStub(BuildRequest request, File classesDir, File stub src.append(registerNatives); src.append(" final ").append(main).append(" app = new ").append(main).append("();\n"); src.append(" Display.init(null);\n"); + // Stamp the hardening metadata so Hardening.isHardened() and crash reports carry the + // mapping id / level on this port too (parity with iOS and Android). + src.append(hardeningRuntimeProperties(request)); src.append(svgInstall); src.append(" Display.getInstance().callSerially(new Runnable() {\n"); src.append(" public void run() {\n"); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HardeningBooleanArgTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HardeningBooleanArgTest.java new file mode 100644 index 00000000000..ed398845938 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HardeningBooleanArgTest.java @@ -0,0 +1,93 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The tri-state parsing of {@code harden.*} boolean arguments must match the engine's + * {@code HardeningConfig.boolTri}. The regression: a bare {@code "false".equals(...)} in the Android + * builder recognized only the literal {@code false}, so the documented aliases {@code harden.rename=off} + * and {@code harden.rename=0} were misread as "renaming still requested" and the build was rejected + * with R8 disabled even though the engine had disabled renaming. + */ +class HardeningBooleanArgTest { + + /** Executor is abstract; only hardenBoolArg is under test. */ + private static final class Probe extends Executor { + @Override + public boolean build(File sourceZip, BuildRequest request) { + return false; + } + + @Override + protected String getDeviceIdCode() { + return ""; + } + + @Override + protected String generatePeerComponentCreationCode(String methodCallString) { + return ""; + } + + @Override + protected String convertPeerComponentToNative(String param) { + return ""; + } + + boolean parse(String value, boolean def) { + BuildRequest r = new BuildRequest(); + if (value != null) { + r.putArgument("harden.rename", value); + } + return hardenBoolArg(r, "harden.rename", def); + } + } + + @Test + void offAndZeroReadAsFalseJustLikeFalse() { + Probe p = new Probe(); + assertFalse(p.parse("false", true), "false"); + assertFalse(p.parse("off", true), "off is a documented alias for false"); + assertFalse(p.parse("0", true), "0 is a documented alias for false"); + assertFalse(p.parse("OFF", true), "case-insensitive"); + } + + @Test + void truthyAndDefaultsBehaveAsExpected() { + Probe p = new Probe(); + assertTrue(p.parse("true", false), "true"); + assertTrue(p.parse("on", false), "on"); + assertTrue(p.parse("1", false), "1"); + // Unset and unrecognized both fall back to the default rather than flipping to false. + assertTrue(p.parse(null, true), "unset -> default"); + assertTrue(p.parse("", true), "blank -> default"); + assertTrue(p.parse("maybe", true), "unrecognized -> default"); + assertFalse(p.parse("maybe", false), "unrecognized -> default (false)"); + } +} From 9fa7384837fa8b4f894e21efb0b6034670621332 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:00:23 +0700 Subject: [PATCH 019/110] Fix JS launcher compile: import Display for the hardening stamp The round-13 hardening-metadata stamp emits Display.getInstance().setProperty(...) into the generated JavaScript launcher, but the launcher had no import for com.codename1.ui.Display (unlike the Linux/Windows bootstrap stubs, which already import it), breaking the initializr JavaScript build in the Build website CI step. Add the import. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/com/codename1/builders/JavaScriptBuilder.java | 1 + 1 file changed, 1 insertion(+) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java index 5a89a8b0b52..d7a1a54d3b8 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java @@ -404,6 +404,7 @@ private File writeLauncher(File workDir, String launcherName, String packageName PrintWriter pw = new PrintWriter(new OutputStreamWriter(new FileOutputStream(f), StandardCharsets.UTF_8)); try { pw.println("import com.codename1.impl.html5.ParparVMBootstrap;"); + pw.println("import com.codename1.ui.Display;"); pw.println("import " + packageName + "." + mainClass + ";"); pw.println(); pw.println("public final class " + launcherName + " {"); From 23e52cd961192f5b6d99d3a0340fdca753e136b3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:00:53 +0700 Subject: [PATCH 020/110] Address Codex review round 14 (unresolved #5527 threads) Engine (cn1-hardening): - Report SKIPPED, not HARDENED, when a requested transform has no eligible target (e.g. rename off and no encryptable string): the app is byte-unchanged, so cn1.hardened=true would be dishonest. Regression test requestedTransformWithNoEligibleTargetsIsSkipped. - StringEncryptTransform now encrypts interface String ConstantValue fields too, moving the plaintext into a decoder call (Java 8 interfaces allow ) with the correct itf=true invoke flag, instead of leaking 'String TOKEN = "secret"'. Test encryptsInterfaceConstantValueField. - Guard both encryption channels against an oversized ciphertext: the XOR key can widen ASCII into 3-byte modified UTF-8, so a large-but-valid literal could overflow the 65535-byte constant pool and make ASM throw; such literals are left in plaintext. Test oversizedLiteralIsLeftPlaintextNotCrashing + fitsConstantPool. Plugin / ports: - JavaScript launcher stamps the hardening metadata via a new ParparVMBootstrap.bootstrap( lifecycle, afterInit) overload, so it runs after Display.init but BEFORE the app's init/start -- a crash during startup now carries the mapping id/level, which a post-bootstrap stamp missed. - Android crash reports get a stable, build-key-derived mapping id (SHA-256, engine-id format) when the engine leaves it empty because R8 is the sole renamer, so a hardened Android crash can be tied to the R8 mapping.txt uploaded for the build. Docs: - Levels table no longer claims line-number stripping: the transform deliberately keeps SourceFile/LineNumberTable for retracing and strips only local-variable names. Co-Authored-By: Claude Opus 4.8 --- .../impl/html5/ParparVMBootstrap.java | 17 +++++ docs/developer-guide/App-Hardening.asciidoc | 4 +- .../codename1/hardening/HardeningEngine.java | 13 ++++ .../hardening/StringEncryptTransform.java | 73 +++++++++++++++---- .../hardening/HardeningEngineTest.java | 42 +++++++++++ .../hardening/StringEncryptTransformTest.java | 64 ++++++++++++++++ .../codename1/hardening/fixture/Iface.java | 3 + .../java/com/codename1/builders/Executor.java | 33 +++++++++ .../codename1/builders/JavaScriptBuilder.java | 14 ++-- 9 files changed, 241 insertions(+), 22 deletions(-) diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java index 3d25977daaa..6fcbcb1ce24 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java @@ -41,9 +41,26 @@ public ParparVMBootstrap(Lifecycle lifecycle) { } public static void bootstrap(Lifecycle lifecycle) { + bootstrap(lifecycle, null); + } + + /** + * As {@link #bootstrap(Lifecycle)}, but runs {@code afterInit} once {@code Display} is + * initialized and before the lifecycle's {@code init}/{@code start} callbacks. The generated + * launcher uses this to stamp the app-hardening metadata (so {@code Hardening.isHardened()} and + * any crash raised during {@code init}/{@code start} already see the mapping id and level), + * which a post-bootstrap stamp would miss because {@code run()} invokes the lifecycle inline. + * + * @param lifecycle the application lifecycle + * @param afterInit code to run after {@code Display.init} and before the lifecycle starts; may be null + */ + public static void bootstrap(Lifecycle lifecycle, Runnable afterInit) { com.codename1.impl.ImplementationFactory.setInstance(new com.codename1.impl.ImplementationFactory()); ParparVMBootstrap bootstrap = new ParparVMBootstrap(lifecycle); Display.init(bootstrap); + if (afterInit != null) { + afterInit.run(); + } bootstrap.run(); } diff --git a/docs/developer-guide/App-Hardening.asciidoc b/docs/developer-guide/App-Hardening.asciidoc index b50f56279c3..765a519c0cb 100644 --- a/docs/developer-guide/App-Hardening.asciidoc +++ b/docs/developer-guide/App-Hardening.asciidoc @@ -91,10 +91,12 @@ The level is the one decision most projects need to make. The individual switche |Class/method/field renaming |-- |yes |yes |yes |String encryption |-- |constants |all |all + reflective names |Control-flow obfuscation |-- |-- |yes |yes + opaque predicates -|Debug / line-number stripping |-- |yes |yes |yes +|Local-variable debug stripping |-- |yes |yes |yes |Symbol/mapping upload |-- |required |required |required |=== +Line numbers are deliberately *kept*, not stripped: the transform preserves the `SourceFile` and `LineNumberTable` attributes (and Android's generated R8 configuration keeps the same) so a crash from a hardened build still retraces to a file and line against the retained mapping. What renaming removes is the local-variable and parameter *names*; the method and class names are replaced by the mapping, not deleted. If you need a build with no line information at all, that is a separate choice you make in your own ProGuard/R8 configuration, and it makes crash reports unretraceable. + Higher levels cost build time, a little binary size and a little startup time. Measure the trade-off for your own app before committing to `paranoid`; the honest number for your codebase is the one that matters, not a headline figure. === Keeping what must not be renamed diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 5e68bd83330..f1ef5e62e63 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -218,6 +218,19 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi } } + // Even when the config asked for a transform, the input may contain nothing eligible (e.g. + // rename off, and no static-final string longer than two characters to encrypt): every + // counter stays zero and no transform actually ran. Stamping cn1.hardened=true for a + // byte-unchanged app would be dishonest, so report SKIPPED and let the caller keep the input. + // Android still counts as hardened here because R8 renames downstream (isRenameRequested). + boolean anyApplied = cfg.isRenameEnabled() + || cfg.isRenameRequested() + || (stringsApplied && encryptedStrings > 0) + || (controlFlowApplied && guardedMethods > 0); + if (!anyApplied) { + return HardeningResult.skipped(HardeningResult.Outcome.SKIPPED_NOT_REQUESTED, req.getInputJar()); + } + // The a.b_c / a.b.c -> a_b_c collision only exists in the ParparVM C symbol mangle. On // Android (R8/DEX -- and the engine does not even rename there) and JavaSE (plain JVM), // '.' vs '_' stay distinct, so two legal classes must not abort the build. diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index c3c5ad1600f..8549e77c694 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -143,11 +143,11 @@ public byte[] transform(byte[] classBytes) { } } - // Channel 2: static final String ConstantValue attributes (both modes). Skipped on - // interfaces, whose fields are implicitly constant and have no rewritable init slot. - if (!isInterface) { - changed |= encryptStaticFinalStrings(cn, base); - } + // Channel 2: static final String ConstantValue attributes (both modes), including + // interfaces. A Java 8 interface may carry a for non-constant field initialization, + // so an interface constant's plaintext can be moved to a decoder call there just as a class + // field's is -- otherwise "String TOKEN = \"secret\"" would still leak the plaintext. + changed |= encryptStaticFinalStrings(cn, base, isInterface); if (!changed) { return classBytes; @@ -169,14 +169,21 @@ private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boo LdcInsnNode ldc = (LdcInsnNode) insn; if (ldc.cst instanceof String && shouldEncryptLiteral((String) ldc.cst)) { String plain = (String) ldc.cst; - ldc.cst = encode(plain, base); - // The itf flag must be true when the decoder lives in an interface, or the JVM - // writes a Methodref instead of an InterfaceMethodref and throws - // IncompatibleClassChangeError at run time. - mn.instructions.insert(ldc, new MethodInsnNode( - Opcodes.INVOKESTATIC, cn.name, DECODER_NAME, DECODER_DESC, isInterface)); - encryptedCount++; - changed = true; + String cipher = encode(plain, base); + // The XOR key spans 0..0xFFFF, so an ASCII literal can encrypt into mostly + // 3-byte (modified) UTF-8 characters; a large-but-valid literal could then exceed + // the 65535-byte constant-pool limit and make ASM throw while writing the class. + // Leave such a literal in plaintext rather than fail the whole build. + if (fitsConstantPool(cipher)) { + ldc.cst = cipher; + // The itf flag must be true when the decoder lives in an interface, or the JVM + // writes a Methodref instead of an InterfaceMethodref and throws + // IncompatibleClassChangeError at run time. + mn.instructions.insert(ldc, new MethodInsnNode( + Opcodes.INVOKESTATIC, cn.name, DECODER_NAME, DECODER_DESC, isInterface)); + encryptedCount++; + changed = true; + } } } insn = next; @@ -184,7 +191,7 @@ private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boo return changed; } - private boolean encryptStaticFinalStrings(ClassNode cn, int base) { + private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInterface) { if (cn.fields == null) { return false; } @@ -194,11 +201,19 @@ private boolean encryptStaticFinalStrings(ClassNode cn, int base) { boolean isStatic = (fn.access & Opcodes.ACC_STATIC) != 0; if (isStatic && fn.value instanceof String && shouldEncrypt((String) fn.value)) { String plain = (String) fn.value; + String cipher = encode(plain, base); + // Skip a literal whose ciphertext would overflow the 65535-byte constant-pool limit + // (the XOR key can widen ASCII into 3-byte UTF-8); leaving it as-is beats failing. + if (!fitsConstantPool(cipher)) { + continue; + } // Strip the ConstantValue so the plaintext leaves the class file entirely // (this is the slot ParparVM would otherwise dump into the C constant pool). fn.value = null; - init.add(new LdcInsnNode(encode(plain, base))); - init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, DECODER_NAME, DECODER_DESC, false)); + init.add(new LdcInsnNode(cipher)); + // itf=true when the decoder lives in an interface, else the JVM emits a Methodref + // instead of an InterfaceMethodref and throws IncompatibleClassChangeError. + init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, DECODER_NAME, DECODER_DESC, isInterface)); init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, fn.name, fn.desc)); encryptedCount++; changed = true; @@ -210,6 +225,32 @@ private boolean encryptStaticFinalStrings(ClassNode cn, int base) { return changed; } + /** + * Whether {@code s} fits a single constant-pool entry: the class-file format stores a String + * constant as modified UTF-8 with a 16-bit (65535-byte) length prefix. The XOR cipher can turn + * an ASCII character into a value up to {@code 0xFFFF} (three modified-UTF-8 bytes), so an + * originally-valid literal can encrypt into an over-long one; such literals are left in plaintext. + */ + static boolean fitsConstantPool(String s) { + long bytes = 0; + for (int i = 0; i < s.length(); i++) { + int c = s.charAt(i) & 0xFFFF; + // Modified UTF-8: 0x0001..0x007F -> 1 byte; 0x0000 and 0x0080..0x07FF -> 2 bytes; + // 0x0800..0xFFFF -> 3 bytes. + if (c >= 0x0001 && c <= 0x007F) { + bytes += 1; + } else if (c == 0x0000 || c <= 0x07FF) { + bytes += 2; + } else { + bytes += 3; + } + if (bytes > 65535) { + return false; + } + } + return bytes <= 65535; + } + private void prependToClinit(ClassNode cn, InsnList init) { MethodNode clinit = null; if (cn.methods != null) { diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index 2e998f83bdf..28d8df708e2 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -243,6 +243,48 @@ public void androidRenameOnlyIsHardenedViaR8() throws Exception { assertTrue(r.getTransformsApplied().contains("rename:r8")); } + @Test + public void requestedTransformWithNoEligibleTargetsIsSkipped() throws Exception { + // rename off + strings requested (constants), but the only class has no encryptable string: + // nothing actually runs, so the build must report SKIPPED rather than stamp cn1.hardened=true + // on a byte-unchanged app. + File jar = tmp.newFile("noop2.jar"); + FileOutputStream fo = new FileOutputStream(jar); + ZipOutputStream zos = new ZipOutputStream(fo); + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/NoStrings", null, "java/lang/Object", null); + org.objectweb.asm.MethodVisitor m = cw.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "add", "(II)I", null, null); + m.visitCode(); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 0); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 1); + m.visitInsn(org.objectweb.asm.Opcodes.IADD); + m.visitInsn(org.objectweb.asm.Opcodes.IRETURN); + m.visitMaxs(2, 2); + m.visitEnd(); + cw.visitEnd(); + zos.putNextEntry(new ZipEntry("app/NoStrings.class")); + zos.write(cw.toByteArray()); + zos.closeEntry(); + zos.finish(); + fo.close(); + + File out = tmp.newFile("noop2-hardened.jar"); + Map hints = new HashMap(); + hints.put("harden.level", "standard"); + hints.put("harden.rename", "false"); + hints.put("harden.strings", "constants"); + HardeningRequest req = new HardeningRequest() + .inputJar(jar).outputJar(out).mappingFile(tmp.newFile("noop2-map.txt")) + .workDir(tmp.newFolder("noop2-work")) + .config(HardeningConfig.from(hints, "ios", true)) + .mainClass("app.NoStrings"); + HardeningResult r = HardeningEngine.harden(req); + assertFalse("no eligible target ran, so the build must not be marked hardened", r.isHardened()); + assertEquals(HardeningResult.Outcome.SKIPPED_NOT_REQUESTED, r.getOutcome()); + } + @Test public void nonOffLevelWithAllTransformsDisabledIsSkipped() throws Exception { // standard, but rename off and strings off -> nothing to do -> not stamped hardened. diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index c605e379f1c..c01935a95d1 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -136,6 +136,70 @@ public void encryptsInterfaceDefaultAndStaticMethodLiterals() throws Exception { assertEquals("interface static secret", c.getMethod("staticSecret").invoke(null)); } + @Test + public void encryptsInterfaceConstantValueField() throws Exception { + InputStream in = getClass().getResourceAsStream( + "/com/codename1/hardening/fixture/Iface.class"); + ByteArrayOutputStream b = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int r; + while ((r = in.read(buf)) >= 0) { + b.write(buf, 0, r); + } + in.close(); + StringEncryptTransform t = new StringEncryptTransform(true, 5); + byte[] out = t.transform(b.toByteArray()); + // The interface's String TOKEN constant must no longer be present as plaintext. + assertFalse("interface field ConstantValue plaintext survived", + StringEncryptTransform.containsStringLiteral(out, "interface constant secret")); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + // Loading the interface runs its decoder; the field reads back its true value. + Class c = new ByteLoader().define("com.codename1.hardening.fixture.Iface", out); + assertEquals("interface constant secret", c.getField("TOKEN").get(null)); + } + + @Test + public void oversizedLiteralIsLeftPlaintextNotCrashing() throws Exception { + // A large-but-valid ASCII literal (40000 chars = 40000 UTF-8 bytes, under the 65535 limit) + // would encrypt into mostly 3-byte characters and overflow the constant pool. The transform + // must skip it and still write a valid class rather than throw UTF8 string too large. + StringBuilder big = new StringBuilder(); + for (int i = 0; i < 40000; i++) { + big.append('a'); + } + String huge = big.toString(); + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Huge", null, "java/lang/Object", null); + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "big", "()Ljava/lang/String;", null, null); + m.visitCode(); + m.visitLdcInsn(huge); + m.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 3); + byte[] out = t.transform(w.toByteArray()); // must not throw + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.Huge", out); + assertEquals(huge, c.getMethod("big").invoke(null)); + // A helper-level check that the guard is doing the classifying. + assertFalse("oversized ciphertext must be rejected by the fit check", + StringEncryptTransform.fitsConstantPool(mostlyThreeByte())); + } + + private static String mostlyThreeByte() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 30000; i++) { + sb.append('\u0800'); // smallest 3-byte modified-UTF-8 char; 30000 * 3 = 90000 > 65535 + } + return sb.toString(); + } + /** Defines transformed bytes as a fresh class distinct from the already-loaded fixture. */ private static final class ByteLoader extends ClassLoader { Class define(String name, byte[] b) { diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Iface.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Iface.java index 4e3c7fd1cc4..447f708c4a4 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Iface.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Iface.java @@ -24,6 +24,9 @@ /** A Java 8 interface with an executable default/static method carrying string literals. */ public interface Iface { + /** An implicitly-constant String field whose plaintext lives in a ConstantValue attribute. */ + String TOKEN = "interface constant secret"; + default String secret() { return "interface default secret"; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 9c2eb71902b..0a5d3e7b36f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2511,6 +2511,16 @@ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildEx lastHardeningMapping = mapping.isFile() ? mapping : null; lastHardeningMappingId = readMappingId(mapping); lastHardeningR8Keep = r8Keep.isFile() ? readFileToString(r8Keep) : ""; + // On Android the engine does not rename (R8 is the sole renamer), so its mapping -- + // and thus its mapping id -- is empty. R8 still produces a real mapping.txt later, + // uploaded for this build+platform. Give the crash report a stable, build-scoped id + // derived from the build key so a report can be tied to that R8 mapping; an empty id + // would leave hardened Android crashes unretraceable. + if ((lastHardeningMappingId == null || lastHardeningMappingId.length() == 0) + && !hardeningRenameSupported() + && hardenBoolArg(request, "harden.rename", true)) { + lastHardeningMappingId = downstreamMappingId(request); + } // Propagate the mapping id / hardened flag / level into the request BEFORE the // builder generates its stubs, so the stubs stamp them as runtime properties // (Hardening.isHardened(), the crash report's mappingId/hardenLevel). @@ -2687,6 +2697,29 @@ public String resolveMappingId(BuildRequest request) { return request.getArg("cn1.mappingId", ""); } + /** + * A stable mapping id for a build whose rename is produced by a downstream tool (R8 on Android) + * rather than the engine, so the engine's own mapping id is empty. Derived from the build key + * and platform as a SHA-256 hex string, matching the engine mapping id's format, so a hardened + * crash report can be tied to the R8 mapping.txt uploaded for this build+platform. + */ + private String downstreamMappingId(BuildRequest request) { + String seed = resolveBuildKey(request) + ":" + hardeningPlatform(request); + try { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(seed.getBytes("UTF-8")); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } catch (Exception e) { + // No SHA-256 (impossible on a supported JDK) -- fall back to a non-empty encoded key. + return buildKeyEncoded(request); + } + } + /** * Loads global local builder properties from user's home directory. */ diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java index d7a1a54d3b8..8d3179fbaa2 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java @@ -422,13 +422,17 @@ private File writeLauncher(File workDir, String launcherName, String packageName + ifaceName + ".class, " + ifaceName + "Impl.class);"); } } - pw.println(" ParparVMBootstrap.bootstrap(new " + mainClass + "());"); - // bootstrap() runs Display.init followed by the app's init/start synchronously, so - // Display is live once it returns; stamp the hardening metadata now so - // Hardening.isHardened() and crash reports carry the mapping id / level on this port - // too (parity with iOS and Android). hardeningRuntimeProperties emits 8-space-indented + // Stamp the hardening metadata after Display.init but BEFORE the lifecycle's init/start, + // so Hardening.isHardened() and any crash raised during startup already carry the mapping + // id / level (parity with iOS and Android). bootstrap(lifecycle, afterInit) invokes the + // runnable at exactly that point; a post-bootstrap stamp would miss startup because + // bootstrap runs init/start inline. hardeningRuntimeProperties emits // Display.getInstance().setProperty(...) lines. + pw.println(" ParparVMBootstrap.bootstrap(new " + mainClass + "(), new Runnable() {"); + pw.println(" public void run() {"); pw.print(hardeningRuntimeProperties(request)); + pw.println(" }"); + pw.println(" });"); pw.println(" }"); pw.println("}"); } finally { From 107abbb0dd40c95f841d1c95400654360b7c1322 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:11:07 +0700 Subject: [PATCH 021/110] Fix Vale gate in App-Hardening doc: drop adverb, use contraction The round-14 line-numbers paragraph tripped Vale (Microsoft.Adverbs on 'deliberately', Microsoft.Contractions on 'that is'). Reword to 'kept' and "that's". Co-Authored-By: Claude Opus 4.8 --- docs/developer-guide/App-Hardening.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer-guide/App-Hardening.asciidoc b/docs/developer-guide/App-Hardening.asciidoc index 765a519c0cb..56955b684b9 100644 --- a/docs/developer-guide/App-Hardening.asciidoc +++ b/docs/developer-guide/App-Hardening.asciidoc @@ -95,7 +95,7 @@ The level is the one decision most projects need to make. The individual switche |Symbol/mapping upload |-- |required |required |required |=== -Line numbers are deliberately *kept*, not stripped: the transform preserves the `SourceFile` and `LineNumberTable` attributes (and Android's generated R8 configuration keeps the same) so a crash from a hardened build still retraces to a file and line against the retained mapping. What renaming removes is the local-variable and parameter *names*; the method and class names are replaced by the mapping, not deleted. If you need a build with no line information at all, that is a separate choice you make in your own ProGuard/R8 configuration, and it makes crash reports unretraceable. +Line numbers are *kept*, not stripped: the transform preserves the `SourceFile` and `LineNumberTable` attributes (and Android's generated R8 configuration keeps the same) so a crash from a hardened build still retraces to a file and line against the retained mapping. What renaming removes is the local-variable and parameter *names*; the method and class names are replaced by the mapping, not deleted. If you need a build with no line information at all, that's a separate choice you make in your own ProGuard/R8 configuration, and it makes crash reports unretraceable. Higher levels cost build time, a little binary size and a little startup time. Measure the trade-off for your own app before committing to `paranoid`; the honest number for your codebase is the one that matters, not a headline figure. From f7c4e2696733785108d462890f5f1cd74dd68166 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:40:52 +0700 Subject: [PATCH 022/110] Fix SpotBugs DM_DEFAULT_ENCODING in CrashProtection.safeRawStack The stack-capture used new PrintStream(bout) and bout.toString(), both relying on the platform default encoding -- flagged by the core-unittests SpotBugs zero-findings gate (it only surfaced now because earlier build-test runs died at runner setup before reaching it). Encode explicitly as UTF-8 on both ends so a non-ASCII exception message can't garble differently per device. CLDC11's PrintStream/ByteArrayOutputStream compile-time stubs lacked the charset overloads (ParparVM's JavaAPI already has them), so add PrintStream(OutputStream,boolean,String) and ByteArrayOutputStream.toString(String) signatures there, mirroring the JDK, for the core's ANT/CLDC compile. Co-Authored-By: Claude Opus 4.8 --- CodenameOne/src/com/codename1/crash/CrashProtection.java | 7 +++++-- Ports/CLDC11/src/java/io/ByteArrayOutputStream.java | 5 +++++ Ports/CLDC11/src/java/io/PrintStream.java | 5 +++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/CrashProtection.java b/CodenameOne/src/com/codename1/crash/CrashProtection.java index f898721032e..1220018fef6 100644 --- a/CodenameOne/src/com/codename1/crash/CrashProtection.java +++ b/CodenameOne/src/com/codename1/crash/CrashProtection.java @@ -260,10 +260,13 @@ private static String safeRawStack(Throwable t) { // JavaScript engine's Error().stack on the JS port (where getStackTrace() has no // structured frames to offer). On the JVM ports it is the standard full trace. java.io.ByteArrayOutputStream bout = new java.io.ByteArrayOutputStream(); - java.io.PrintStream ps = new java.io.PrintStream(bout); + // Encode explicitly as UTF-8 on both ends rather than relying on the platform default + // (which SpotBugs flags and which would garble a non-ASCII exception message differently + // per device); the pair must agree, so the PrintStream and the readback share the charset. + java.io.PrintStream ps = new java.io.PrintStream(bout, true, "UTF-8"); t.printStackTrace(ps); ps.flush(); - String s = bout.toString(); + String s = bout.toString("UTF-8"); return s.length() == 0 ? null : s; } catch (Throwable ignored) { return null; diff --git a/Ports/CLDC11/src/java/io/ByteArrayOutputStream.java b/Ports/CLDC11/src/java/io/ByteArrayOutputStream.java index f03c1ddb040..59ca3736a2b 100644 --- a/Ports/CLDC11/src/java/io/ByteArrayOutputStream.java +++ b/Ports/CLDC11/src/java/io/ByteArrayOutputStream.java @@ -68,6 +68,11 @@ public java.lang.String toString(){ return null; //TODO codavaj!! } + /// Converts the buffer's contents into a string, translating bytes into characters according to the named charset. + public java.lang.String toString(java.lang.String charsetName) throws java.io.UnsupportedEncodingException { + return null; //TODO codavaj!! + } + /// Writes len bytes from the specified byte array starting at offset off to this byte array output stream. public void write(byte[] b, int off, int len){ return; //TODO codavaj!! diff --git a/Ports/CLDC11/src/java/io/PrintStream.java b/Ports/CLDC11/src/java/io/PrintStream.java index 105577e0895..6c587035333 100644 --- a/Ports/CLDC11/src/java/io/PrintStream.java +++ b/Ports/CLDC11/src/java/io/PrintStream.java @@ -32,6 +32,11 @@ public PrintStream(java.io.OutputStream out){ //TODO codavaj!! } + /// Create a new print stream that encodes with the named charset, optionally flushing automatically. + public PrintStream(java.io.OutputStream out, boolean autoFlush, java.lang.String charsetName) throws java.io.UnsupportedEncodingException { + //TODO codavaj!! + } + /// Flush the stream and check its error state. The internal error state is set to true when the underlying output stream throws an IOException, and when the setError method is invoked. public boolean checkError(){ return false; //TODO codavaj!! From 30e4d616161cce1b3248323fdf427eaac1e607b3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:38:44 +0700 Subject: [PATCH 023/110] Address Codex review round 15 (#5527) - Always pass the Codename One framework jar to ProGuard as a library jar (every builder has codenameOneJar), so an application override such as a custom Component.paint is never renamed apart from the framework method it overrides -- which would break virtual dispatch. The cn1.hardening.libraryJars arg is only set on the CN1BuildMojo path, so it can't be the sole source when hardening runs through buildNoException. - Omit -dontpreverify for the real-JVM targets (JavaSE/desktop) so ProGuard regenerates StackMapTable frames; a class it emitted unchanged would otherwise throw VerifyError on a Java 7+ JVM. The ParparVM/JS ports translate away and keep the flag. Test BuiltinKeepRulesTest. - Retrace now emits every inlined frame (MappingFile.retraceAll / MappingChain.retraceAll): an R8-optimized mapping records the inlined callee and its caller for one obfuscated frame, and returning only the first mis-identified the call path. Test inlinedFramesAreAllEmittedInOrder. - Skip interface constant/method encryption for pre-Java-8 interfaces: the decoder is a concrete static method invoked from , both invalid before class-file v52, so a legacy interface would fail verification. - BuildHintEditor grouped-Select values: only treat the last char as the delimiter when it is punctuation; a plain comma list like modern,legacy,custom (no trailing delimiter) is split on commas instead of on 'm'. Co-Authored-By: Claude Opus 4.8 --- .../impl/javase/BuildHintEditor.java | 14 +++- .../codename1/hardening/BuiltinKeepRules.java | 21 +++++- .../codename1/hardening/HardeningEngine.java | 2 +- .../codename1/hardening/ProGuardRunner.java | 9 +-- .../hardening/StringEncryptTransform.java | 8 +++ .../hardening/BuiltinKeepRulesTest.java | 68 +++++++++++++++++++ .../com/codename1/retrace/MappingChain.java | 18 +++++ .../com/codename1/retrace/MappingFile.java | 48 +++++++------ .../com/codename1/retrace/RetraceMain.java | 11 ++- .../codename1/retrace/MappingFileTest.java | 19 ++++++ .../java/com/codename1/builders/Executor.java | 10 ++- 11 files changed, 196 insertions(+), 32 deletions(-) create mode 100644 maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintEditor.java b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintEditor.java index f1562717a28..861a9ace953 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintEditor.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintEditor.java @@ -200,12 +200,20 @@ private void loadBuildHintModels() { valuesString = System.getProperty("codename1.arg.{{ "+model.name+" }}.values"); } if (valuesString != null) { - String separator = ""+valuesString.charAt(valuesString.length()-1); + // The historical format is delimiter-TERMINATED: the last character is the + // separator (so any delimiter could be used, e.g. "a;b;c;"). Grouped + // registrations, and BuildHintSchemaDefaults, instead use a plain + // comma-separated list with no trailing delimiter ("modern,legacy,custom", + // "false,true"). Treating the last char as the delimiter there would split + // "custom" on 'm'. So: only use the last char as the delimiter when it is a + // punctuation (non-word) character; otherwise split on comma. + char last = valuesString.charAt(valuesString.length() - 1); + String separator = Character.isLetterOrDigit(last) ? "," : ("" + last); ArrayList values = new ArrayList(); values.add(""); - for (String value : valuesString.split(separator)) { + for (String value : valuesString.split(java.util.regex.Pattern.quote(separator))) { if (!value.trim().isEmpty()) { - values.add(value); + values.add(value.trim()); } } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java index 7907825b3e3..5d9451a7136 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java @@ -83,12 +83,26 @@ public static List rules(String mainClass) { /** The global ProGuard flags the engine always sets. Kept here so the Android R8 export can share them. */ public static List flags() { + return flags(null); + } + + /** + * The global ProGuard flags, tuned for {@code platform}. On the real-JVM targets (JavaSE / + * desktop) {@code -dontpreverify} is omitted so ProGuard regenerates {@code StackMapTable} + * frames: without them a class ProGuard emitted unchanged (not rewritten by the string or + * control-flow transforms) throws {@code VerifyError} on a Java 7+ JVM. The ParparVM ports + * translate to C and the JavaScript port to JS, so their frames are never JVM-verified and the + * flag stays (preverification there only costs time). + */ + public static List flags(String platform) { List r = new ArrayList(); // ParparVM culls and R8 shrinks; shrinking/optimizing here only risks // "works in debug, NPEs in release". Rename and encrypt, nothing else. r.add("-dontshrink"); r.add("-dontoptimize"); - r.add("-dontpreverify"); + if (!isRealJvmTarget(platform)) { + r.add("-dontpreverify"); + } // Class files are written to a directory and builds run on a case-insensitive // filesystem, so mixed-case names would collide. r.add("-dontusemixedcaseclassnames"); @@ -103,6 +117,11 @@ public static List flags() { return r; } + /** True for the ports whose hardened classes are executed on a real JVM (so frames are verified). */ + static boolean isRealJvmTarget(String platform) { + return "javase".equals(platform) || "desktop".equals(platform); + } + /** * The app-level keep rules only, in R8/ProGuard syntax, so Android's generated {@code proguard.cfg} * can append them. The flags are not included -- Android manages its own R8 flags. diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index f1ef5e62e63..dd90b70ffc6 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -163,7 +163,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi deriveSeed(cfg, req.getBuildKey())); File renamedJar = new File(workDir, "renamed.jar"); ProGuardRunner.rename(classesJar, renamedJar, mappingFile, - req.getLibraryJars(), keepRules, dict, workDir); + req.getLibraryJars(), keepRules, dict, workDir, cfg.getPlatform()); renamed = JarDemuxer.readClasses(renamedJar); renamedCount = countRenamed(inClasses.keySet(), renamed.keySet()); hierarchyJar = renamedJar; diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java index e5b9a943d0b..2707f7f422c 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java @@ -56,10 +56,10 @@ private ProGuardRunner() { */ public static void rename(File classesJar, File outJar, File mappingFile, List libraryJars, List keepRules, File dictionary, - File workDir) throws HardeningException { + File workDir, String platform) throws HardeningException { File config = new File(workDir, "cn1-hardening.pro"); try { - writeConfig(config, classesJar, outJar, mappingFile, libraryJars, keepRules, dictionary); + writeConfig(config, classesJar, outJar, mappingFile, libraryJars, keepRules, dictionary, platform); } catch (IOException e) { throw new HardeningException("Could not write ProGuard configuration", e); } @@ -87,7 +87,8 @@ public static void rename(File classesJar, File outJar, File mappingFile, } private static void writeConfig(File config, File classesJar, File outJar, File mappingFile, - List libraryJars, List keepRules, File dictionary) + List libraryJars, List keepRules, File dictionary, + String platform) throws IOException { FileOutputStream fo = new FileOutputStream(config); try { @@ -108,7 +109,7 @@ private static void writeConfig(File config, File classesJar, File outJar, File w.write("-classobfuscationdictionary " + quote(dictionary) + "\n"); w.write("-obfuscationdictionary " + quote(dictionary) + "\n"); w.write("-packageobfuscationdictionary " + quote(dictionary) + "\n"); - for (String flag : BuiltinKeepRules.flags()) { + for (String flag : BuiltinKeepRules.flags(platform)) { w.write(flag + "\n"); } if (keepRules != null) { diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 8549e77c694..c8c5afce0c2 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -123,6 +123,14 @@ public byte[] transform(byte[] classBytes) { return classBytes; } boolean isInterface = (cn.access & Opcodes.ACC_INTERFACE) != 0; + // The decoder is a concrete static method, and (for interface constants) it is invoked from + // . Static/private methods and in an interface are only valid from class-file + // version 52 (Java 8). A pre-Java-8 interface therefore cannot host the decoder, and such an + // interface has no default/static method bodies to hold LDC literals anyway, so skip it whole + // rather than emit a class that fails verification. + if (isInterface && (cn.version & 0xFFFF) < Opcodes.V1_8) { + return classBytes; + } int base = keyBase(cn.name); boolean changed = false; diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java new file mode 100644 index 00000000000..50d1a881a66 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.hardening; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.List; +import org.junit.Test; + +/** The global ProGuard flags, especially the platform-dependent preverification. */ +public class BuiltinKeepRulesTest { + + @Test + public void realJvmTargetsKeepStackMapFrames() { + // On JavaSE/desktop the hardened classes run on a real JVM, so -dontpreverify must be omitted + // or a class ProGuard emitted unchanged (no frames) throws VerifyError on Java 7+. + List javase = BuiltinKeepRules.flags("javase"); + assertFalse("JavaSE output must be preverified", javase.contains("-dontpreverify")); + assertFalse("desktop output must be preverified", + BuiltinKeepRules.flags("desktop").contains("-dontpreverify")); + } + + @Test + public void translatedTargetsSkipPreverification() { + // The ParparVM ports translate to C and JS, so their frames are never JVM-verified; + // -dontpreverify stays (preverifying would only cost time). + assertTrue(BuiltinKeepRules.flags("ios").contains("-dontpreverify")); + assertTrue(BuiltinKeepRules.flags("mac").contains("-dontpreverify")); + assertTrue(BuiltinKeepRules.flags("javascript").contains("-dontpreverify")); + assertTrue("the no-arg default keeps the historical behaviour", + BuiltinKeepRules.flags().contains("-dontpreverify")); + } + + @Test + public void lineTablesAreAlwaysKept() { + // Retracing depends on SourceFile + LineNumberTable regardless of platform. + for (String p : new String[] {"ios", "javase", "and"}) { + boolean kept = false; + for (String f : BuiltinKeepRules.flags(p)) { + if (f.contains("SourceFile") && f.contains("LineNumberTable")) { + kept = true; + } + } + assertTrue("line tables kept for " + p, kept); + } + } +} diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingChain.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingChain.java index 6523771413f..4711be83ab2 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingChain.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingChain.java @@ -58,6 +58,24 @@ public Frame retrace(Frame frame) { return f; } + /** + * Retraces one frame through every mapping, expanding inlined frames: each mapping can turn a + * single frame into several (an inlined callee plus its caller), and the next mapping is applied + * to each resulting frame in turn. Returns at least one frame. + */ + public List retraceAll(Frame frame) { + List current = new ArrayList(); + current.add(frame); + for (MappingFile m : mappings) { + List next = new ArrayList(); + for (Frame f : current) { + next.addAll(m.retraceAll(f)); + } + current = next; + } + return current; + } + public boolean isEmpty() { return mappings.isEmpty(); } diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java index 8503b9d58a7..ee26b03f250 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -181,34 +181,42 @@ private void parseMemberLine(ClassMapping cm, String line) { * source lines on real frames. */ public Frame retrace(Frame obfuscated) { + // The first (innermost) frame; retraceAll is the full expansion including inlined callers. + return retraceAll(obfuscated).get(0); + } + + /** + * Inverts one obfuscated frame into one or more original frames. An optimized R8 mapping records + * several methods for the same obfuscated name and line range -- the inlined callee(s) and the + * caller they were inlined into -- and all of them describe that single physical frame. Returning + * only the first would silently drop the inlined callers and mis-identify the call path, so this + * emits every record whose range covers the line, in R8's order (innermost first). Always returns + * at least one frame (the input unchanged when the class is unknown). + */ + public List retraceAll(Frame obfuscated) { ClassMapping cm = byObfuscated.get(obfuscated.getClassName()); if (cm == null) { - return obfuscated; + return java.util.Collections.singletonList(obfuscated); } int observed = obfuscated.getLineNumber(); - String originalMethod = obfuscated.getMethodName(); - int mappedLine = observed; - List candidates = cm.methods.get(obfuscated.getMethodName()); - if (candidates != null && !candidates.isEmpty()) { - MethodMapping m = pickByLine(candidates, observed); - originalMethod = m.originalName; - // Translate the observed obfuscated line back to the original source line when the - // mapping carries a distinct original range (R8 / optimized ProGuard). - mappedLine = m.mapLine(observed); - } String originalClass = cm.originalName; String file = simpleSourceFile(originalClass); - return new Frame(originalClass, originalMethod, file, mappedLine); - } - - private MethodMapping pickByLine(List candidates, int line) { - // Prefer a candidate whose obfuscated line range contains the frame's line. - for (MethodMapping m : candidates) { - if (m.startLine != 0 && line >= m.startLine && line <= m.endLine) { - return m; + List candidates = cm.methods.get(obfuscated.getMethodName()); + List out = new ArrayList(); + if (candidates != null && !candidates.isEmpty()) { + for (MethodMapping m : candidates) { + if (m.startLine != 0 && observed >= m.startLine && observed <= m.endLine) { + out.add(new Frame(originalClass, m.originalName, file, m.mapLine(observed))); + } + } + if (out.isEmpty()) { + MethodMapping m = candidates.get(0); + out.add(new Frame(originalClass, m.originalName, file, m.mapLine(observed))); } + } else { + out.add(new Frame(originalClass, obfuscated.getMethodName(), file, observed)); } - return candidates.get(0); + return out; } private static String simpleSourceFile(String fqcn) { diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/RetraceMain.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/RetraceMain.java index 65eae16570c..afc1b3b864f 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/RetraceMain.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/RetraceMain.java @@ -63,8 +63,15 @@ public static void main(String[] args) throws Exception { return; } for (Frame f : frames) { - Frame out = chain.isEmpty() ? f : chain.retrace(f); - System.out.println(" " + out); + if (chain.isEmpty()) { + System.out.println(" " + f); + } else { + // One obfuscated frame can expand into several original frames (R8 inlining); + // print them all, innermost first. + for (Frame out : chain.retraceAll(f)) { + System.out.println(" " + out); + } + } } } diff --git a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java index 87a6232f1e3..d1c51f1374e 100644 --- a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -80,6 +80,25 @@ public void singleLineOriginalRangeCollapses() throws Exception { assertEquals(40, mf.retrace(new Frame("zqaaaa", "a", "zqaaaa.java", 1)).getLineNumber()); } + @Test + public void inlinedFramesAreAllEmittedInOrder() throws Exception { + // R8 inlining: two method records share the obfuscated name 'a' and obfuscated line 1 -- + // the inlined callee and the caller it was inlined into. retraceAll must emit BOTH, innermost + // first, or the reconstructed stack loses the inlined call path. + MappingFile mf = MappingFile.parse( + "com.example.Outer -> x:\n" + + " 1:1:void inlinedCallee():10:10 -> a\n" + + " 1:1:void caller():20:20 -> a\n"); + java.util.List frames = mf.retraceAll(new Frame("x", "a", "x.java", 1)); + assertEquals(2, frames.size()); + assertEquals("inlinedCallee", frames.get(0).getMethodName()); + assertEquals(10, frames.get(0).getLineNumber()); + assertEquals("caller", frames.get(1).getMethodName()); + assertEquals(20, frames.get(1).getLineNumber()); + // The single-frame retrace() stays backward compatible: it returns the innermost frame. + assertEquals("inlinedCallee", mf.retrace(new Frame("x", "a", "x.java", 1)).getMethodName()); + } + @Test public void unknownClassPassesThroughUnchanged() throws Exception { MappingFile mf = MappingFile.parse(MAPPING); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 0a5d3e7b36f..5f19cb2b1b7 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2377,6 +2377,14 @@ protected boolean hardeningRenameSupported() { */ protected java.util.List hardeningLibraryJars(BuildRequest request) { java.util.List jars = new java.util.ArrayList(); + // Always include the Codename One framework jar: every builder receives it, and it carries + // the framework superclasses ProGuard must see so it never renames an application override + // (e.g. a custom Component.paint) apart from the fixed framework method -- which would break + // virtual dispatch. cn1.hardening.libraryJars (below) is only set on the CN1BuildMojo entry + // and is absent when hardening runs through buildNoException, so it can't be relied on alone. + if (codenameOneJar != null && codenameOneJar.exists()) { + jars.add(codenameOneJar); + } String raw = request.getArg("cn1.hardening.libraryJars", ""); if (raw == null || raw.length() == 0) { // Fallback: the maven plugin publishes the compile classpath here (a single injection @@ -2387,7 +2395,7 @@ protected java.util.List hardeningLibraryJars(BuildRequest request) { for (String p : raw.split(java.util.regex.Pattern.quote(File.pathSeparator))) { if (p != null && p.trim().length() > 0) { File f = new File(p.trim()); - if (f.exists()) { + if (f.exists() && !jars.contains(f)) { jars.add(f); } } From ba739f594828045b551a76f6bf45a2d97f839d36 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:13:57 +0700 Subject: [PATCH 024/110] Address Codex review round 16 (#5527) - Retrace: keep an inlined method's own declaring class. An R8 inline record can name a method from another class (com.example.Callee.run); MappingFile now splits the declaring class off so the frame reports Callee/Callee.java instead of gluing it onto the enclosing class. Test inlinedMethodFromAnotherClassKeepsItsOwnClass. - Preflight parses harden..enabled with the shared tri-state rules (false/0/off), so a local/source build opted out via =off or =0 is no longer preflight-rejected. - Android mapping id is now unique per build: downstreamMappingId folds the hardened jar's bytes into the SHA-256 (was buildKey:platform only), so two builds that reuse a build key but differ in code get distinct ids, as resolveMappingId promises. - String encryption never skips a class on a decoder-name clash: it resolves a non-colliding decoder name instead. Skipping left that class's literals in plaintext while an equal literal elsewhere was encrypted+interned, which breaks a valid literal == on ParparVM (whose intern pool does not hold the compile-time literals). Test encryptsEvenWhenDecoderNameCollides. (Re-enabling constant-pool literal registration in the VM intern pool was rejected: intern() is an O(n) linear scan, so it would regress every app's startup and runtime.) Co-Authored-By: Claude Opus 4.8 --- .../hardening/StringEncryptTransform.java | 68 +++++++++++++------ .../hardening/StringEncryptTransformTest.java | 31 +++++++++ .../com/codename1/retrace/MappingFile.java | 30 ++++++-- .../codename1/retrace/MappingFileTest.java | 17 +++++ .../java/com/codename1/builders/Executor.java | 23 ++++++- .../com/codename1/maven/CN1BuildMojo.java | 18 ++++- 6 files changed, 156 insertions(+), 31 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index c8c5afce0c2..dc6d7254ea0 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -118,10 +118,6 @@ public byte[] transform(byte[] classBytes) { ClassNode cn = new ClassNode(); new ClassReader(classBytes).accept(cn, ClassReader.SKIP_FRAMES); - // If the class already defines a member colliding with the decoder, leave it alone. - if (hasDecoderCollision(cn)) { - return classBytes; - } boolean isInterface = (cn.access & Opcodes.ACC_INTERFACE) != 0; // The decoder is a concrete static method, and (for interface constants) it is invoked from // . Static/private methods and in an interface are only valid from class-file @@ -132,6 +128,14 @@ public byte[] transform(byte[] classBytes) { return classBytes; } + // Pick a decoder name that does not collide with an existing member, so a class is NEVER + // skipped for a name clash. Skipping would leave that class's literals in plaintext while an + // equal literal in another class was encrypted+interned; on ParparVM, whose intern pool does + // not contain the compile-time literals, the two would then fail a valid literal '==' compare. + // Never skipping keeps encryption applied by-value across the whole jar, so all occurrences of + // a value are decoded through the shared intern pool and stay reference-equal. + String decoderName = resolveDecoderName(cn); + int base = keyBase(cn.name); boolean changed = false; @@ -144,10 +148,10 @@ public byte[] transform(byte[] classBytes) { if (mn.instructions == null) { continue; } - if (DECODER_NAME.equals(mn.name)) { + if (decoderName.equals(mn.name)) { continue; } - changed |= encryptMethodLiterals(cn, mn, base, isInterface); + changed |= encryptMethodLiterals(cn, mn, base, isInterface, decoderName); } } @@ -155,20 +159,21 @@ public byte[] transform(byte[] classBytes) { // interfaces. A Java 8 interface may carry a for non-constant field initialization, // so an interface constant's plaintext can be moved to a decoder call there just as a class // field's is -- otherwise "String TOKEN = \"secret\"" would still leak the plaintext. - changed |= encryptStaticFinalStrings(cn, base, isInterface); + changed |= encryptStaticFinalStrings(cn, base, isInterface, decoderName); if (!changed) { return classBytes; } - addDecoder(cn, base, isInterface); + addDecoder(cn, base, isInterface, decoderName); ClassWriter cw = new FrameClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, hierarchy); cn.accept(cw); return cw.toByteArray(); } - private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boolean isInterface) { + private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boolean isInterface, + String decoderName) { boolean changed = false; AbstractInsnNode insn = mn.instructions.getFirst(); while (insn != null) { @@ -188,7 +193,7 @@ private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boo // writes a Methodref instead of an InterfaceMethodref and throws // IncompatibleClassChangeError at run time. mn.instructions.insert(ldc, new MethodInsnNode( - Opcodes.INVOKESTATIC, cn.name, DECODER_NAME, DECODER_DESC, isInterface)); + Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, isInterface)); encryptedCount++; changed = true; } @@ -199,7 +204,8 @@ private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boo return changed; } - private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInterface) { + private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInterface, + String decoderName) { if (cn.fields == null) { return false; } @@ -221,7 +227,7 @@ private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInte init.add(new LdcInsnNode(cipher)); // itf=true when the decoder lives in an interface, else the JVM emits a Methodref // instead of an InterfaceMethodref and throws IncompatibleClassChangeError. - init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, DECODER_NAME, DECODER_DESC, isInterface)); + init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, isInterface)); init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, fn.name, fn.desc)); encryptedCount++; changed = true; @@ -281,12 +287,12 @@ private void prependToClinit(ClassNode cn, InsnList init) { } } - private void addDecoder(ClassNode cn, int base, boolean isInterface) { + private void addDecoder(ClassNode cn, int base, boolean isInterface, String decoderName) { // A Java 8 interface may only have public static methods (private statics are 9+), so the // decoder is public there; in a class it stays private. int access = (isInterface ? Opcodes.ACC_PUBLIC : Opcodes.ACC_PRIVATE) | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC; - MethodNode m = new MethodNode(Opcodes.ASM9, access, DECODER_NAME, DECODER_DESC, null, null); + MethodNode m = new MethodNode(Opcodes.ASM9, access, decoderName, DECODER_DESC, null, null); InsnList in = m.instructions; // char[] c = s.toCharArray(); (local 1) in.add(new VarInsnNode(Opcodes.ALOAD, 0)); @@ -338,13 +344,35 @@ private void addDecoder(ClassNode cn, int base, boolean isInterface) { cn.methods.add(m); } - private boolean hasDecoderCollision(ClassNode cn) { - if (cn.methods == null) { - return false; + /** + * A decoder method name for {@code cn} that collides with no existing member. Starts from the + * base name and lengthens the {@code $} suffix until unused, so a class is never skipped for a + * clash (which would leave its literals in plaintext and break cross-class literal {@code ==}). + */ + private String resolveDecoderName(ClassNode cn) { + String name = DECODER_NAME; + while (memberExists(cn, name)) { + name = name + "$"; } - for (MethodNode mn : cn.methods) { - if (DECODER_NAME.equals(mn.name) && DECODER_DESC.equals(mn.desc)) { - return true; + return name; + } + + private boolean memberExists(ClassNode cn, String name) { + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + // Same descriptor would be an outright clash; a differently-typed method of the same + // name is legal, but the decoder is also referenced by name from , so keep it + // simple and avoid the name entirely. + if (name.equals(mn.name)) { + return true; + } + } + } + if (cn.fields != null) { + for (FieldNode fn : cn.fields) { + if (name.equals(fn.name)) { + return true; + } } } return false; diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index c01935a95d1..92fe97cfe4a 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -159,6 +159,37 @@ public void encryptsInterfaceConstantValueField() throws Exception { assertEquals("interface constant secret", c.getField("TOKEN").get(null)); } + @Test + public void encryptsEvenWhenDecoderNameCollides() throws Exception { + // A class that already declares a member named "zqdec$" must NOT be skipped: skipping would + // leave its literal in plaintext while an equal literal elsewhere was encrypted, breaking a + // valid literal == on ParparVM. The transform picks a non-colliding decoder name instead. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Clash", null, "java/lang/Object", null); + // A pre-existing member named exactly like the decoder. + w.visitField(org.objectweb.asm.Opcodes.ACC_PRIVATE | org.objectweb.asm.Opcodes.ACC_STATIC, + "zqdec$", "I", null, null).visitEnd(); + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "secret", "()Ljava/lang/String;", null, null); + m.visitCode(); + m.visitLdcInsn("this is a clash secret value"); + m.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 11); + byte[] out = t.transform(w.toByteArray()); + assertTrue("the clashing class must still be encrypted, not skipped", t.getEncryptedCount() >= 1); + assertFalse("plaintext must be gone despite the name clash", + StringEncryptTransform.containsStringLiteral(out, "this is a clash secret value")); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.Clash", out); + assertEquals("this is a clash secret value", c.getMethod("secret").invoke(null)); + } + @Test public void oversizedLiteralIsLeftPlaintextNotCrashing() throws Exception { // A large-but-valid ASCII literal (40000 chars = 40000 UTF-8 bytes, under the 65535 limit) diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java index ee26b03f250..46d720b8660 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -43,14 +43,16 @@ public final class MappingFile { private static final class MethodMapping { final String originalName; + final String declaringClass; // FQ class an inlined method came from, or null for this class final int startLine; // obfuscated range start (0 if none) final int endLine; // obfuscated range end final int originalStartLine; // original range start (0 if none / same) final int originalEndLine; // original range end (== start for a single line) - MethodMapping(String originalName, int startLine, int endLine, + MethodMapping(String originalName, String declaringClass, int startLine, int endLine, int originalStartLine, int originalEndLine) { this.originalName = originalName; + this.declaringClass = declaringClass; this.startLine = startLine; this.endLine = endLine; this.originalStartLine = originalStartLine; @@ -166,13 +168,23 @@ private void parseMemberLine(ClassMapping cm, String line) { int paren = left.indexOf('('); String beforeParen = left.substring(0, paren).trim(); int sp = beforeParen.lastIndexOf(' '); - String originalMethod = sp < 0 ? beforeParen : beforeParen.substring(sp + 1); + String qualifiedMethod = sp < 0 ? beforeParen : beforeParen.substring(sp + 1); + // An R8 inline record can name a method from ANOTHER class, fully qualified + // ("com.example.Callee.run"). Split the declaring class off so the retraced frame reports + // Callee.run / Callee.java rather than gluing the callee's FQ name onto the enclosing class. + String declaringClass = null; + String originalMethod = qualifiedMethod; + int lastDot = qualifiedMethod.lastIndexOf('.'); + if (lastDot > 0) { + declaringClass = qualifiedMethod.substring(0, lastDot); + originalMethod = qualifiedMethod.substring(lastDot + 1); + } List list = cm.methods.get(obfName); if (list == null) { list = new ArrayList(); cm.methods.put(obfName, list); } - list.add(new MethodMapping(originalMethod, startLine, endLine, originalStartLine, originalEndLine)); + list.add(new MethodMapping(originalMethod, declaringClass, startLine, endLine, originalStartLine, originalEndLine)); } /** @@ -206,12 +218,11 @@ public List retraceAll(Frame obfuscated) { if (candidates != null && !candidates.isEmpty()) { for (MethodMapping m : candidates) { if (m.startLine != 0 && observed >= m.startLine && observed <= m.endLine) { - out.add(new Frame(originalClass, m.originalName, file, m.mapLine(observed))); + out.add(frameFor(m, originalClass, file, observed)); } } if (out.isEmpty()) { - MethodMapping m = candidates.get(0); - out.add(new Frame(originalClass, m.originalName, file, m.mapLine(observed))); + out.add(frameFor(candidates.get(0), originalClass, file, observed)); } } else { out.add(new Frame(originalClass, obfuscated.getMethodName(), file, observed)); @@ -219,6 +230,13 @@ public List retraceAll(Frame obfuscated) { return out; } + /** Builds a frame for one method record, honoring an inlinee's own declaring class/source file. */ + private Frame frameFor(MethodMapping m, String enclosingClass, String enclosingFile, int observed) { + String cls = m.declaringClass != null ? m.declaringClass : enclosingClass; + String file = m.declaringClass != null ? simpleSourceFile(m.declaringClass) : enclosingFile; + return new Frame(cls, m.originalName, file, m.mapLine(observed)); + } + private static String simpleSourceFile(String fqcn) { int d = fqcn.lastIndexOf('.'); String simple = d < 0 ? fqcn : fqcn.substring(d + 1); diff --git a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java index d1c51f1374e..7d4cd0a0331 100644 --- a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -99,6 +99,23 @@ public void inlinedFramesAreAllEmittedInOrder() throws Exception { assertEquals("inlinedCallee", mf.retrace(new Frame("x", "a", "x.java", 1)).getMethodName()); } + @Test + public void inlinedMethodFromAnotherClassKeepsItsOwnClass() throws Exception { + // The inlinee 'a' at obf line 1 is Callee.run from a DIFFERENT class; the retraced frame must + // report Callee/Callee.java, not the enclosing Outer with Callee.run glued on as the method. + MappingFile mf = MappingFile.parse( + "com.example.Outer -> x:\n" + + " 1:1:void com.example.Callee.run():12:12 -> a\n" + + " 1:1:void outerMethod():30:30 -> a\n"); + java.util.List frames = mf.retraceAll(new Frame("x", "a", "x.java", 1)); + assertEquals(2, frames.size()); + assertEquals("com.example.Callee", frames.get(0).getClassName()); + assertEquals("run", frames.get(0).getMethodName()); + assertEquals("Callee.java", frames.get(0).getFileName()); + assertEquals("com.example.Outer", frames.get(1).getClassName()); + assertEquals("outerMethod", frames.get(1).getMethodName()); + } + @Test public void unknownClassPassesThroughUnchanged() throws Exception { MappingFile mf = MappingFile.parse(MAPPING); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 5f19cb2b1b7..6ede534ab4f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2527,7 +2527,7 @@ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildEx if ((lastHardeningMappingId == null || lastHardeningMappingId.length() == 0) && !hardeningRenameSupported() && hardenBoolArg(request, "harden.rename", true)) { - lastHardeningMappingId = downstreamMappingId(request); + lastHardeningMappingId = downstreamMappingId(request, hardened); } // Propagate the mapping id / hardened flag / level into the request BEFORE the // builder generates its stubs, so the stubs stamp them as runtime properties @@ -2711,11 +2711,28 @@ public String resolveMappingId(BuildRequest request) { * and platform as a SHA-256 hex string, matching the engine mapping id's format, so a hardened * crash report can be tied to the R8 mapping.txt uploaded for this build+platform. */ - private String downstreamMappingId(BuildRequest request) { + private String downstreamMappingId(BuildRequest request, File hardenedJar) { String seed = resolveBuildKey(request) + ":" + hardeningPlatform(request); try { java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); - byte[] digest = md.digest(seed.getBytes("UTF-8")); + md.update(seed.getBytes("UTF-8")); + // Fold in the hardened application jar's bytes so two builds that reuse a build key but + // differ in code get distinct ids -- resolveMappingId promises to distinguish a rebuilt + // app that reused a build key. A byte-identical rebuild keeps the same id, matching its + // identical R8 mapping. + if (hardenedJar != null && hardenedJar.isFile()) { + java.io.InputStream in = new java.io.FileInputStream(hardenedJar); + try { + byte[] buf = new byte[65536]; + int n; + while ((n = in.read(buf)) > 0) { + md.update(buf, 0, n); + } + } finally { + in.close(); + } + } + byte[] digest = md.digest(); StringBuilder sb = new StringBuilder(digest.length * 2); for (byte b : digest) { sb.append(Character.forDigit((b >> 4) & 0xF, 16)); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index a8678896984..1e850c203bd 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -201,8 +201,8 @@ private void applyHardeningPreflight() throws MojoFailureException { if (hardenPlatform == null) { hardenPlatform = normalizeHardenPlatform(platform); } - if (hardenPlatform != null && "false".equalsIgnoreCase( - settings.getProperty("codename1.arg.harden." + hardenPlatform + ".enabled", "true").trim())) { + if (hardenPlatform != null && isHardenFalse( + settings.getProperty("codename1.arg.harden." + hardenPlatform + ".enabled", "true"))) { level = "off"; } boolean allowLocal = "true".equalsIgnoreCase( @@ -261,6 +261,20 @@ private static String hardenPlatformForBuildTarget(String buildTarget) { return null; } + /** + * True when a {@code harden.*} boolean setting reads as disabled, using the same tri-state rules + * as the engine's {@code HardeningConfig.boolTri}: {@code false}, {@code 0} and {@code off} all + * mean off. Recognizing only the literal {@code false} here would preflight-reject a + * local/source build that {@code harden..enabled=off} had actually turned off. + */ + private static boolean isHardenFalse(String value) { + if (value == null) { + return false; + } + String t = value.trim().toLowerCase(); + return "false".equals(t) || "0".equals(t) || "off".equals(t); + } + /** Maps {@code codename1.platform} to the {@code harden..enabled} opt-out key. */ private static String normalizeHardenPlatform(String platform) { if (platform == null) { From 5435b58b4351b23615473a8b2ca1a9999c79978d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:54:04 +0700 Subject: [PATCH 025/110] Address Codex review round 18 (#5527) - Keep package names during renaming (-keeppackagenames): resources are copied verbatim and never pass through ProGuard, so a package-relative Screen.class.getResource("icon.png") -- which resolves under the class's package -- would return null if the package were renamed while com/foo/icon.png stayed put. Class simple names, members and strings are still obfuscated. Test packageNamesAreKeptSoResourcesResolve. - Document that the Android mapping id is necessarily a pre-R8 build-INPUT identifier (the app carries it as a compile-time constant; R8's mapping.txt does not exist until after compilation). Correctness against R8 non-determinism comes from the daemon uploading the produced R8 mapping.txt keyed by this same id (see the BuildDaemon PR), so a crash report's id selects the exact mapping that build shipped. Co-Authored-By: Claude Opus 4.8 --- .../java/com/codename1/hardening/BuiltinKeepRules.java | 6 ++++++ .../com/codename1/hardening/BuiltinKeepRulesTest.java | 9 +++++++++ .../src/main/java/com/codename1/builders/Executor.java | 8 ++++++++ 3 files changed, 23 insertions(+) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java index 5d9451a7136..d6b5720bd9d 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java @@ -106,6 +106,12 @@ public static List flags(String platform) { // Class files are written to a directory and builds run on a case-insensitive // filesystem, so mixed-case names would collide. r.add("-dontusemixedcaseclassnames"); + // Keep package names. Resources are copied verbatim by JarDemuxer and never pass through + // ProGuard, so a package-relative lookup such as Screen.class.getResource("icon.png") -- which + // resolves under the class's (renamed) package -- would return null if the package were renamed + // while com/foo/icon.png stayed put. Class simple names, methods, fields and strings are still + // obfuscated; only the package path, which resource loading depends on, is preserved. + r.add("-keeppackagenames"); r.add("-dontnote"); r.add("-dontwarn"); // Keep SourceFile + LineNumberTable: ParparVM translates the line table into its diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java index 50d1a881a66..0a8ec7f8426 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java @@ -52,6 +52,15 @@ public void translatedTargetsSkipPreverification() { BuiltinKeepRules.flags().contains("-dontpreverify")); } + @Test + public void packageNamesAreKeptSoResourcesResolve() { + // Resources are copied verbatim and never renamed, so a package-relative getResource would + // break if the package were renamed; -keeppackagenames must always be present. + for (String p : new String[] {"ios", "javase", "javascript", "win"}) { + assertTrue("package names kept for " + p, BuiltinKeepRules.flags(p).contains("-keeppackagenames")); + } + } + @Test public void lineTablesAreAlwaysKept() { // Retracing depends on SourceFile + LineNumberTable regardless of platform. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 6ede534ab4f..24dbe8f3712 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2711,6 +2711,14 @@ public String resolveMappingId(BuildRequest request) { * and platform as a SHA-256 hex string, matching the engine mapping id's format, so a hardened * crash report can be tied to the R8 mapping.txt uploaded for this build+platform. */ + // The id is necessarily fixed BEFORE R8 runs -- the app carries it as a compile-time constant, + // and R8's own mapping.txt does not exist until after the app is compiled, so the id cannot be a + // hash of that mapping. It is therefore a build-INPUT identifier (build key + platform + hardened + // jar bytes): unique per build content, deterministic for a byte-identical rebuild. Correctness + // against R8 non-determinism does not come from the id's inputs but from the upload: the daemon + // uploads the produced R8 mapping.txt keyed by THIS same id, so a crash report's id selects the + // exact mapping that build shipped even if a reused build key or a different R8 result produced a + // different mapping. private String downstreamMappingId(BuildRequest request, File hardenedJar) { String seed = resolveBuildKey(request) + ":" + hardeningPlatform(request); try { From 245c14c73a0975dfd01b9eac7019d952cde83f15 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:36:44 +0700 Subject: [PATCH 026/110] Narrow keep rules and rewrite hardening docs per project-owner review Keep rules (Codename One has no reflection and no serialization): - Drop the Class.forName / string-constant reflection scanning. InputJarKeepScanner now finds native interfaces (phase 1) and keeps exactly their generated Impl / Stub peers (phase 2), replacing the over-broad **Impl / **Stub. - Remove the serialization keeps (Serializable members, serialVersionUID, readObject/writeObject/ writeReplace/readResolve, Externalizable) -- serialization is not supported. - Remove the PropertyBusinessObject member-name keep: a property's JSON key/DB column is the string passed to its Property, not the field name, so renaming the field is safe. - Drop -keeppackagenames: there is no getResource for nested packages, so packages are obfuscated; only the main class (already kept by name) needs its package preserved. - Strip SourceFile (retrace reconstructs the file name from the class), keep LineNumberTable, for DexGuard parity. - Tests updated: scannerKeepsNativeInterfacePeers, androidExportsNativeInterfaceKeepsToR8, packageNamesAreNotKept, lineNumbersKeptButSourceFileStripped. Docs (docs/developer-guide/App-Hardening.asciidoc): - State that obfuscation is on by default for every Codename One app, and that iOS compiles to native machine code (hard to reverse engineer) while names/strings still leak as text -- which is the gap this closes. Frame it as a tool for banking/government/high-risk apps under serious scrutiny. - Explain that hardening runs only on the build server to keep the transform/decoder/dictionary off the client, which itself raises reverse-engineering cost. - Remove the reflection and name-bound-persistence guidance; note PropertyBusinessObject works without special handling. Remove the marketing/'measure the trade-off' paragraph. Drop 'honest' wording here and in Crash-Protection.asciidoc. Co-Authored-By: Claude Opus 4.8 --- docs/developer-guide/App-Hardening.asciidoc | 27 ++--- .../developer-guide/Crash-Protection.asciidoc | 2 +- docs/developer-guide/languagetool-accept.txt | 1 + .../codename1/hardening/BuiltinKeepRules.java | 42 +++---- .../hardening/InputJarKeepScanner.java | 107 +++++++++--------- .../hardening/BuiltinKeepRulesTest.java | 25 ++-- .../hardening/HardeningEngineTest.java | 57 +++++----- 7 files changed, 123 insertions(+), 138 deletions(-) diff --git a/docs/developer-guide/App-Hardening.asciidoc b/docs/developer-guide/App-Hardening.asciidoc index 56955b684b9..9c1e4f0f0f9 100644 --- a/docs/developer-guide/App-Hardening.asciidoc +++ b/docs/developer-guide/App-Hardening.asciidoc @@ -1,13 +1,13 @@ [[app-hardening]] == App Hardening -Every shipped app is a program someone else can read. The class and method names survive into the binary, the string constants sit in plain sight, and the control flow is exactly what you wrote. On Android a release build is run through R8, which renames the Java names -- but on the other ports even that much isn't true: the iOS and native builds translate your code to C through ParparVM and the class names, method names and every string literal end up in the binary as readable text. +Every Codename One app is already obfuscated by default. On Android the release build runs through R8, which renames the Java names. On iOS and the other native ports your code is compiled to native machine code through ParparVM, which is hard to reverse engineer on its own -- but the class names, method names and string literals still travel into the binary as readable text, so a reader who can't follow the machine code can still read the labels and the constants. -App Hardening closes that gap across *all* the ports from one place. It renames classes, methods and fields; encrypts string constants so they're not present as plaintext in the binary; and obfuscates control flow -- and it does this to the merged application before each platform build, so Android, iOS, JavaScript and the native desktop targets are all covered by one transform and one mapping. +App Hardening is the Enterprise layer that closes that remaining gap across *all* the ports from one place. It renames classes, methods and fields; encrypts string constants so they're not present as plaintext in the binary; and obfuscates control flow -- and it does this to the merged application before each platform build, so Android, iOS, JavaScript and the native desktop targets are all covered by one transform and one mapping. -WARNING: App Hardening doesn't make an app impossible to reverse engineer, and no product does. What it changes is the cost: turning a class named `LoginController` with a string `"invalid password"` into a class named `zqab` with an encrypted constant moves the first afternoon of a reverse-engineering effort from "read it" to "reconstruct it." Be careful not to promise more than that, internally or in marketing. It's one layer; pair it with <> so the statement your backend trusts is made by hardware the attacker doesn't control. +This is a tool for apps that face serious security scrutiny -- banking, payments, government and other high-risk targets. It raises the cost of static analysis and tampering; it's one layer, so pair it with <> so the statement your backend trusts is made by hardware the attacker doesn't control. -This is an *Enterprise* feature. A build that asks for it without an Enterprise subscription *fails with an explanation* rather than producing an unhardened binary -- a binary that looks protected but isn't is worse than one that never claimed to be. +App Hardening is an *Enterprise* feature. A build that asks for it without an Enterprise subscription *fails with an explanation* rather than producing an unhardened binary that would look protected without being so. === What it changes, per port @@ -89,26 +89,21 @@ The level is the one decision most projects need to make. The individual switche | |`off` |`standard` |`aggressive` |`paranoid` |Class/method/field renaming |-- |yes |yes |yes -|String encryption |-- |constants |all |all + reflective names +|String encryption |-- |constants |all |all |Control-flow obfuscation |-- |-- |yes |yes + opaque predicates |Local-variable debug stripping |-- |yes |yes |yes |Symbol/mapping upload |-- |required |required |required |=== -Line numbers are *kept*, not stripped: the transform preserves the `SourceFile` and `LineNumberTable` attributes (and Android's generated R8 configuration keeps the same) so a crash from a hardened build still retraces to a file and line against the retained mapping. What renaming removes is the local-variable and parameter *names*; the method and class names are replaced by the mapping, not deleted. If you need a build with no line information at all, that's a separate choice you make in your own ProGuard/R8 configuration, and it makes crash reports unretraceable. - -Higher levels cost build time, a little binary size and a little startup time. Measure the trade-off for your own app before committing to `paranoid`; the honest number for your codebase is the one that matters, not a headline figure. +The source file name (`SourceFile`) is stripped, matching DexGuard. `LineNumberTable` is kept so a hardened crash still retraces to a line number against the mapping; the retrace reconstructs the file name from the (retraced) class name. Renaming also removes the local-variable and parameter names. === Keeping what must not be renamed -Renaming is safe for code the compiler and runtime resolve by symbol, and unsafe for code resolved by *name*. The engine keeps the obvious cases automatically -- the main class and its generated stub, native-interface implementations and their peers, `enum` `values()`/`valueOf()`, serialization members, and any class named by a string constant that appears in the jar (a `Class.forName` target, a GUI-builder reference). - -Two categories deserve special attention: +Renaming is safe for code the compiler and runtime resolve by symbol. Codename One has no runtime reflection -- `Class.forName` never resolved an obfuscated application class -- so there is no reflective seam to protect, and there is no serialization to keep members for. What must survive is the small set the build resolves by *name*: the main class and its generated stub, the generated router and annotation bootstraps, and each native interface with its generated `Impl`/`Stub` peer. The engine finds the native interfaces in the input and keeps exactly those peers, and keeps the rest of that set automatically. -* *Name-bound persistence.* A `PropertyBusinessObject`'s property names *are* the JSON keys and the database column names. Renaming them would change the on-disk schema and the wire format, which corrupts data on the next app upgrade rather than throwing. The engine keeps these member names automatically. -* *Runtime reflection you construct dynamically.* If you build a class name at runtime from pieces the analysis can't follow, add a `harden.keep` rule for it. +`PropertyBusinessObject` properties are safe without any special handling: a property's JSON key and database column come from the *string* passed to its `Property`, not from the field name, so renaming the field doesn't change the on-disk schema or the wire format. String encryption decodes those strings back to the same value at runtime. -When you enable a hardening level, review your app for these name-bound patterns before the first hardened cloud build: reflective `Class.forName` targets built from dynamic strings, GUI-builder resources that reference components by class name, and any framework registration that resolves an implementation by name. The automatic keep analysis catches the common cases; a `harden.keep` rule covers anything it can't see. +If you have a class the build resolves by a name the automatic analysis can't see, add a `harden.keep` rule for it. === Crash reports from a hardened build @@ -116,7 +111,9 @@ Hardening and Crash Protection are designed together. The build server retains t === Local and source builds aren't hardened -Hardening runs on the Codename One build server. A local or source-project target (`*-source`, `local-*`) never reaches the server, so its output isn't hardened; the build fails the pre-flight rather than mislead you, unless you set `harden.allowUnhardenedLocalBuild=true`. The simulator is never obfuscated either -- it runs your `target/classes` directly. App code can read `com.codename1.security.hardening.Hardening.isHardened()` to tell a hardened build apart from one of these. +Hardening runs on the Codename One build server, and only there. Running it server-side keeps the hardening implementation itself off the client: the exact transforms, the decoder shapes and the dictionary stay on infrastructure the attacker doesn't have, which is part of what makes a hardened binary harder to reverse engineer -- an attacker can't study the tool that produced it. + +A local or source-project target (`*-source`, `local-*`) never reaches the server, so its output isn't hardened; the build fails the pre-flight rather than mislead you, unless you set `harden.allowUnhardenedLocalBuild=true`. The simulator is never obfuscated either -- it runs your `target/classes` directly. App code can read `com.codename1.security.hardening.Hardening.isHardened()` to tell a hardened build apart from one of these. === Hardening and App Shield diff --git a/docs/developer-guide/Crash-Protection.asciidoc b/docs/developer-guide/Crash-Protection.asciidoc index 7bcdd15bfef..7a62f5d2767 100644 --- a/docs/developer-guide/Crash-Protection.asciidoc +++ b/docs/developer-guide/Crash-Protection.asciidoc @@ -86,7 +86,7 @@ The Codename One crash-protection client runs incoming messages through a scrubb - `rawStack` -- the pre-rendered Java stack captured via `printStackTrace`, including the cause chain and any verbatim platform formatting. It complements the structured `frames` (which `getStackTrace()` now populates on every port) and is the readable trace on the JavaScript port, where the JavaScript engine's stack has no structured frames - `traceFormat` -- how the server should read `rawStack`: `structured`, `parparvm-text`, `js-error`, or `none`. Derived, never guessed - `mappingId` -- the id of the obfuscation mapping a hardened build shipped with, so a report ties to the exact mapping even if a rebuild reused the build key; empty for unhardened builds -- `hardenLevel` -- the hardening level of the build, so the server can give an honest reason for an unretraceable report +- `hardenLevel` -- the hardening level of the build, so the server can give a specific reason for an unretraceable report - `clientTs` === Crash reports from a hardened build diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 7b1d49ef8d7..01087a2255b 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -49,6 +49,7 @@ iapdemo ParparVM RoboVM TeaVM +DexGuard teavm teavmdbg LWUIT diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java index d6b5720bd9d..7504040017b 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java @@ -30,8 +30,10 @@ * what the input jar contains. These exist because the builders generate stub * source after hardening that names classes literally and then compiles * it against the hardened classes -- the main class and its {@code Stub}, the - * generated router and annotation bootstraps, native-interface peers, and the - * usual reflective seams (enums, serialization, {@code native} members). + * generated router and annotation bootstraps, native-interface types, {@code native} + * members, and {@code enum} {@code values()}/{@code valueOf()}. Codename One has no + * reflection and does not support serialization, so no {@code Class.forName}, + * {@code Serializable}/{@code Externalizable} or property-name keeps are needed. */ public final class BuiltinKeepRules { @@ -59,25 +61,15 @@ public static List rules(String mainClass) { for (String b : BOOTSTRAPS) { r.add("-keep class cn1app." + b + " { *; }"); } - // Native interfaces are matched to their implementation by name. + // Native interfaces are bound to their platform implementation by name. Keep the interface + // itself here; the specific Impl / Stub are found by scanning the input + // (InputJarKeepScanner) and kept individually, rather than the over-broad **Impl / **Stub. r.add("-keep class * implements com.codename1.system.NativeInterface { *; }"); - r.add("-keep class **Impl { *; }"); - r.add("-keep class **Stub { *; }"); // JNI/native method names must not move. r.add("-keepclasseswithmembernames,includedescriptorclasses class * { native ; }"); - // Reflective seams the JDK itself relies on. + // enum values()/valueOf(String) resolve constants by name, so they are kept -- this is + // ordinary language behaviour, not reflection. r.add("-keepclassmembers enum * { public static **[] values(); public static ** valueOf(java.lang.String); }"); - r.add("-keepclassmembers class * implements java.io.Serializable { " - + "static final long serialVersionUID; " - + "private void writeObject(java.io.ObjectOutputStream); " - + "private void readObject(java.io.ObjectInputStream); " - + "java.lang.Object writeReplace(); java.lang.Object readResolve(); }"); - r.add("-keep class * implements java.io.Externalizable { *; }"); - // PropertyBusinessObject property/field names ARE the JSON/ORM column names; - // renaming them silently changes the on-disk schema and the wire format, which - // corrupts data on the next app upgrade rather than throwing. Keep the member - // names (the class itself may still be renamed). - r.add("-keepclassmembernames class * implements com.codename1.properties.PropertyBusinessObject { *; }"); return r; } @@ -106,20 +98,14 @@ public static List flags(String platform) { // Class files are written to a directory and builds run on a case-insensitive // filesystem, so mixed-case names would collide. r.add("-dontusemixedcaseclassnames"); - // Keep package names. Resources are copied verbatim by JarDemuxer and never pass through - // ProGuard, so a package-relative lookup such as Screen.class.getResource("icon.png") -- which - // resolves under the class's (renamed) package -- would return null if the package were renamed - // while com/foo/icon.png stayed put. Class simple names, methods, fields and strings are still - // obfuscated; only the package path, which resource loading depends on, is preserved. - r.add("-keeppackagenames"); r.add("-dontnote"); r.add("-dontwarn"); - // Keep SourceFile + LineNumberTable: ParparVM translates the line table into its - // on-device debug-line info, and the crash retrace passes device line numbers through - // rather than reconstructing them, so stripping the tables would make every hardened - // trace report unknown/-1 lines. The renamed names still hide the code; line tables don't. + // Keep LineNumberTable so a hardened crash still reports its true line (the crash retrace + // passes device line numbers through, and ParparVM turns the table into on-device debug-line + // info). SourceFile is NOT kept -- the retrace synthesizes the file name from the class name, + // so the original .java name is stripped, matching DexGuard. r.add("-keepattributes Exceptions,InnerClasses,Signature,EnclosingMethod,*Annotation*," - + "SourceFile,LineNumberTable"); + + "LineNumberTable"); return r; } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java index 0375de38087..1cac10a8cd0 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java @@ -29,89 +29,88 @@ import java.util.Set; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; -import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Opcodes; /** - * Tier 2 keep rules, derived from the input classes with ASM. This covers what - * ProGuard cannot infer declaratively: a class named by a string constant that is - * then resolved by reflection ({@code Class.forName}, {@code UIBuilder}, the - * annotation-generated mappers). Over-keeping here is safe -- it costs a little - * obfuscation coverage; under-keeping would break the app at runtime -- so any app - * class whose name appears verbatim as a string constant anywhere in the jar is - * kept. + * Tier 2 keep rules, derived from the input classes with ASM. Codename One has no reflection -- + * {@code Class.forName} never resolved an obfuscated app class -- so there is nothing to keep for a + * class named only by a string. What ProGuard cannot infer declaratively is the naming + * convention that binds a native interface to its generated peer: for a native interface + * {@code com.foo.Bar} the build produces {@code com.foo.BarImpl} / {@code com.foo.BarStub} and + * resolves them by name. This scanner finds the native interfaces (phase 1) and keeps exactly those + * peers (phase 2), which is far narrower than the previous {@code **Impl} / {@code **Stub}. */ public final class InputJarKeepScanner { - private final Set classBinaryNames = new LinkedHashSet(); - private final Set stringConstants = new LinkedHashSet(); + private static final String NATIVE_INTERFACE = "com/codename1/system/NativeInterface"; + + /** internal name -> its direct super-interfaces (from the class's interfaces[]). */ + private final java.util.Map interfacesOf = + new java.util.HashMap(); + private final Set nativeInterfaceTypes = new LinkedHashSet(); /** Scans every class in {@code classesByInternalName} (keyed {@code a/b/C}). */ public void scan(Map classesByInternalName) { - for (Map.Entry e : classesByInternalName.entrySet()) { - classBinaryNames.add(e.getKey().replace('/', '.')); - } for (byte[] classBytes : classesByInternalName.values()) { ClassReader cr = new ClassReader(classBytes); - cr.accept(new ConstantCollector(), ClassReader.SKIP_FRAMES); + cr.accept(new HierarchyCollector(), ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG + | ClassReader.SKIP_FRAMES); + } + // A type is a native interface if NativeInterface is in its transitive super-interface + // closure. Resolve transitively across the classes we saw (an interface may extend another + // native interface rather than NativeInterface directly). + for (String type : interfacesOf.keySet()) { + if (extendsNativeInterface(type, new LinkedHashSet())) { + nativeInterfaceTypes.add(type); + } + } + } + + private boolean extendsNativeInterface(String type, Set visiting) { + if (!visiting.add(type)) { + return false; + } + String[] ifaces = interfacesOf.get(type); + if (ifaces == null) { + return false; } + for (String i : ifaces) { + if (NATIVE_INTERFACE.equals(i) || extendsNativeInterface(i, visiting)) { + return true; + } + } + return false; } - /** The derived keep rules. */ + /** The derived keep rules: the generated {@code Impl}/{@code Stub} peer of each native interface. */ public List keepRules() { List rules = new ArrayList(); - Set kept = new LinkedHashSet(); - for (String s : stringConstants) { - String candidate = s.trim(); - // Accept both dotted and slash forms of a reference. - String dotted = candidate.replace('/', '.'); - if (classBinaryNames.contains(dotted) && kept.add(dotted)) { - rules.add("-keep class " + dotted + " { *; }"); - } + for (String type : nativeInterfaceTypes) { + String dotted = type.replace('/', '.'); + rules.add("-keep class " + dotted + "Impl { *; }"); + rules.add("-keep class " + dotted + "Stub { *; }"); } return rules; } - /** Visible for testing: the class names that were kept for reflection safety. */ - List reflectivelyReferencedClasses() { + /** Visible for testing: the native interface types found in the input (dotted names). */ + List nativeInterfaces() { List out = new ArrayList(); - Set seen = new LinkedHashSet(); - for (String s : stringConstants) { - String dotted = s.trim().replace('/', '.'); - if (classBinaryNames.contains(dotted) && seen.add(dotted)) { - out.add(dotted); - } + for (String type : nativeInterfaceTypes) { + out.add(type.replace('/', '.')); } return out; } - private final class ConstantCollector extends ClassVisitor { - ConstantCollector() { + private final class HierarchyCollector extends ClassVisitor { + HierarchyCollector() { super(Opcodes.ASM9); } @Override - public MethodVisitor visitMethod(int access, String name, String descriptor, - String signature, String[] exceptions) { - return new MethodVisitor(Opcodes.ASM9) { - @Override - public void visitLdcInsn(Object value) { - if (value instanceof String) { - stringConstants.add((String) value); - } - } - }; - } - - @Override - public org.objectweb.asm.FieldVisitor visitField(int access, String name, String descriptor, - String signature, Object value) { - // A reflective class name may live only in a static-final String field's ConstantValue - // attribute, never as an LDC (e.g. read by an external framework). Collect those too. - if (value instanceof String) { - stringConstants.add((String) value); - } - return null; + public void visit(int version, int access, String name, String signature, + String superName, String[] interfaces) { + interfacesOf.put(name, interfaces == null ? new String[0] : interfaces); } } } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java index 0a8ec7f8426..41a559774f6 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java @@ -53,25 +53,30 @@ public void translatedTargetsSkipPreverification() { } @Test - public void packageNamesAreKeptSoResourcesResolve() { - // Resources are copied verbatim and never renamed, so a package-relative getResource would - // break if the package were renamed; -keeppackagenames must always be present. + public void packageNamesAreNotKept() { + // Codename One has no getResource for nested packages, so package names are obfuscated too; + // -keeppackagenames must NOT be present. for (String p : new String[] {"ios", "javase", "javascript", "win"}) { - assertTrue("package names kept for " + p, BuiltinKeepRules.flags(p).contains("-keeppackagenames")); + assertFalse("packages must be obfuscated for " + p, + BuiltinKeepRules.flags(p).contains("-keeppackagenames")); } } @Test - public void lineTablesAreAlwaysKept() { - // Retracing depends on SourceFile + LineNumberTable regardless of platform. + public void lineNumbersKeptButSourceFileStripped() { + // Retracing needs LineNumberTable; SourceFile is stripped (the retrace synthesizes the file + // name from the class), matching DexGuard. for (String p : new String[] {"ios", "javase", "and"}) { - boolean kept = false; + boolean lineKept = false; + boolean sourceKept = false; for (String f : BuiltinKeepRules.flags(p)) { - if (f.contains("SourceFile") && f.contains("LineNumberTable")) { - kept = true; + if (f.startsWith("-keepattributes")) { + lineKept = f.contains("LineNumberTable"); + sourceKept = f.contains("SourceFile"); } } - assertTrue("line tables kept for " + p, kept); + assertTrue("LineNumberTable kept for " + p, lineKept); + assertFalse("SourceFile stripped for " + p, sourceKept); } } } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index 28d8df708e2..ebe2e1a42e3 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -89,18 +89,13 @@ private byte[] resourceBytes(String internal) throws Exception { return b.toByteArray(); } - /** - * A synthetic class whose only reference to {@code targetBinaryName} is a static-final String - * field carrying it as a {@code ConstantValue} attribute -- never an LDC. Models a class name a - * framework reads reflectively from a constant field. - */ - private static byte[] classWithConstantNamingField(String internalName, String targetBinaryName) { + /** A synthetic native interface: {@code interface extends NativeInterface}. */ + private static byte[] nativeInterface(String internalName) { org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); - cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, - internalName, null, "java/lang/Object", null); - cw.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC - | org.objectweb.asm.Opcodes.ACC_FINAL, "TARGET", "Ljava/lang/String;", - null, targetBinaryName).visitEnd(); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_ABSTRACT | org.objectweb.asm.Opcodes.ACC_INTERFACE, + internalName, null, "java/lang/Object", + new String[]{"com/codename1/system/NativeInterface"}); cw.visitEnd(); return cw.toByteArray(); } @@ -335,34 +330,36 @@ public void javascriptSkipsStringEncryption() throws Exception { } @Test - public void scannerKeepsClassNamedOnlyByAFieldConstant() throws Exception { - // The class name lives solely in a static-final String field's ConstantValue attribute, - // never as an LDC, so a method-instruction-only scan would miss it. - byte[] ref = classWithConstantNamingField( - "com/codename1/hardening/fixture/Ref", "com.codename1.hardening.fixture.Helper"); + public void scannerKeepsNativeInterfacePeers() throws Exception { + // Phase 1: find the native interface. Phase 2: keep ITS generated Impl/Stub peer -- narrow, + // not the old blanket **Impl / **Stub. Map classes = new HashMap(); - classes.put("com/codename1/hardening/fixture/Ref", ref); + classes.put("app/MyNative", nativeInterface("app/MyNative")); classes.put(HELPER, resourceBytes(HELPER)); InputJarKeepScanner scanner = new InputJarKeepScanner(); scanner.scan(classes); - assertTrue("class named by a field ConstantValue must be kept", - scanner.keepRules().contains( - "-keep class com.codename1.hardening.fixture.Helper { *; }")); + java.util.List rules = scanner.keepRules(); + assertTrue("the native interface's Impl peer must be kept", + rules.contains("-keep class app.MyNativeImpl { *; }")); + assertTrue("the native interface's Stub peer must be kept", + rules.contains("-keep class app.MyNativeStub { *; }")); + // A plain class is NOT kept -- there is no reflection to keep it for. + for (String rule : rules) { + assertFalse("a non-native class must not be kept: " + rule, + rule.contains("hardening.fixture.Helper")); + } } @Test - public void androidExportsReflectionKeepsToR8() throws Exception { - // On Android the engine does not rename (R8 does), so the classes the scanner found - // reflectively must be written to the R8 keep file or R8 renames them out from under the - // reflective lookup. Ref names Helper only via a field constant. + public void androidExportsNativeInterfaceKeepsToR8() throws Exception { + // On Android the engine does not rename (R8 does), so the native-interface peer keeps plus + // the user's harden.keep must reach the R8 keep file. File jar = tmp.newFile("r8.jar"); FileOutputStream fo = new FileOutputStream(jar); ZipOutputStream zos = new ZipOutputStream(fo); putClass(zos, SECRETS); - putClass(zos, HELPER); - zos.putNextEntry(new ZipEntry("com/codename1/hardening/fixture/Ref.class")); - zos.write(classWithConstantNamingField( - "com/codename1/hardening/fixture/Ref", "com.codename1.hardening.fixture.Helper")); + zos.putNextEntry(new ZipEntry("app/MyNative.class")); + zos.write(nativeInterface("app/MyNative")); zos.closeEntry(); zos.finish(); fo.close(); @@ -382,8 +379,8 @@ public void androidExportsReflectionKeepsToR8() throws Exception { assertTrue(r.isHardened()); assertTrue("engine must emit the R8 keep file", r8Keep.isFile()); String keep = new String(Files.readAllBytes(r8Keep.toPath()), Charset.forName("UTF-8")); - assertTrue("reflectively referenced class must reach R8", - keep.contains("-keep class com.codename1.hardening.fixture.Helper { *; }")); + assertTrue("native interface peer must reach R8", + keep.contains("-keep class app.MyNativeImpl { *; }")); assertTrue("the main class must reach R8", keep.contains("com.codename1.hardening.fixture.Secrets")); assertTrue("the user's harden.keep must reach R8", From c5404f27200011acd8a4df3c7f902cd1a2f33fde Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:39:26 +0700 Subject: [PATCH 027/110] Address Codex review round 19: fix the real ones, push back on one Verified ParparVM deduplicates its constant pool (Parser.java constantPool.indexOf), so equal literals share one object and == works before hardening -- which makes several of these real, not noise. - Retrace: keep a real reported SourceFile (Kotlin Screen.kt, a package-private class's Main.java) instead of fabricating .java, but still synthesize when the reported name is just the obfuscated class name with an extension. Test keepsRealReportedSourceFileButNotObfuscatedPlaceholder. - String encryption oversize skip is now class-INDEPENDENT: shouldEncrypt rejects a value whose worst-case ciphertext (3 UTF-8 bytes/char) could overflow the constant pool, so the same value is never encrypted in one class and left plaintext in another (which would break cross-class == on the deduped pool). - Hoist each distinct method-body literal to a synthetic static field decoded ONCE in , with its LDC sites rewritten to GETSTATIC, so a literal in a hot loop is not re-decoded and re-interned (an O(n) scan on ParparVM) per access. Interfaces keep per-access decode. Test repeatedLiteralIsHoistedAndDecodedOnce. - Build-hint editor no longer advertises a paranoid 'reflective-name hiding' transform that does not exist (paranoid only raises control-flow intensity). Pushed back (documented, not implemented): a hardened app literal is a different object from an equal un-encrypted framework literal, so cross-boundary literal == can change. The only complete fix is re-enabling VM constant-pool interning, which is an O(n)-per-intern regression for every app to serve a reference-identity guarantee valid code shouldn't rely on across a library boundary. Documented the .equals() caveat instead. Co-Authored-By: Claude Opus 4.8 --- .../impl/javase/BuildHintSchemaDefaults.java | 5 +- docs/developer-guide/App-Hardening.asciidoc | 4 +- .../hardening/StringEncryptTransform.java | 127 ++++++++++++++---- .../hardening/StringEncryptTransformTest.java | 32 +++++ .../com/codename1/retrace/MappingFile.java | 36 ++++- .../codename1/retrace/MappingFileTest.java | 16 +++ 6 files changed, 186 insertions(+), 34 deletions(-) diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java index acd45bd516a..42c9899de6a 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java @@ -86,9 +86,8 @@ private static void registerHardening() { set("{{#hardening#harden.level}}.values", "off,standard,aggressive,paranoid,"); set("{{#hardening#harden.level}}.description", "off = no hardening. standard = renaming + constant-string encryption. " - + "aggressive = + all-string encryption + control flow. paranoid = + opaque " - + "predicates + reflective-name hiding. Higher levels cost build time, size and " - + "startup; measure before choosing paranoid."); + + "aggressive = + all-string encryption + control flow. paranoid = + stronger " + + "control-flow obfuscation."); set("{{#hardening#harden.strings}}.label", "String encryption"); set("{{#hardening#harden.strings}}.type", "Select"); diff --git a/docs/developer-guide/App-Hardening.asciidoc b/docs/developer-guide/App-Hardening.asciidoc index 9c1e4f0f0f9..3e31670658c 100644 --- a/docs/developer-guide/App-Hardening.asciidoc +++ b/docs/developer-guide/App-Hardening.asciidoc @@ -23,7 +23,9 @@ The transform runs on the merged application jar, at the bytecode level, before |String constant encryption |iOS, Android, Windows, Linux, desktop -|Both channels are handled: the `LDC` string literals in method bodies *and* the `ConstantValue` attribute of `static final String` fields, which would otherwise leak into the ParparVM C constant pool even after the readers were encrypted. The decoder is synthesized into each class with a per-class key, so there is no single framework method to hook. Not applied on the JavaScript port, where a string literal can be a live reference into the native bridge. +|Both channels are handled: the `LDC` string literals in method bodies *and* the `ConstantValue` attribute of `static final String` fields, which would otherwise leak into the ParparVM C constant pool even after the readers were encrypted. Each distinct literal is decoded once (per class) and interned, so equal encrypted literals stay reference-equal to each other. The decoder is synthesized into each class with a per-class key, so there is no single framework method to hook. Not applied on the JavaScript port, where a string literal can be a live reference into the native bridge. + +One caveat: an encrypted app literal is a different object from an equal *un-encrypted* literal elsewhere -- for example a literal of the same value returned by the framework, which isn't hardened. Reference (`==`) comparison of string literals across that boundary can therefore change. Compare strings with `.equals()`, which is unaffected; `==` on distinct string values was never a guarantee to rely on. |Control-flow obfuscation |Android, desktop diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index dc6d7254ea0..a983a00b362 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -60,6 +60,7 @@ public final class StringEncryptTransform { /** Synthesized per-class decoder; the {@code $} keeps it clear of any real app member. */ static final String DECODER_NAME = "zqdec$"; + private static final String HOISTED_FIELD_PREFIX = "zqL$"; static final String DECODER_DESC = "(Ljava/lang/String;)Ljava/lang/String;"; private final boolean encryptAllStrings; @@ -143,15 +144,22 @@ public byte[] transform(byte[] classBytes) { // encrypted; in "constants" mode only literals whose value was declared as a // static-final String constant somewhere in the jar -- which is exactly the set javac // inlined at these read sites -- so ordinary incidental literals are left alone. + // + // For a normal class each distinct literal is hoisted to a synthetic static field decoded + // ONCE in , and its LDC sites become a GETSTATIC of that field, so a literal in a hot + // loop is not re-decoded (and re-interned -- an O(n) scan on ParparVM) on every iteration. An + // interface can't host that (its fields are public), so there the literal is decoded per + // access; interface method bodies are rare and not hot loops. if (cn.methods != null) { - for (MethodNode mn : cn.methods) { - if (mn.instructions == null) { - continue; - } - if (decoderName.equals(mn.name)) { - continue; + if (isInterface) { + for (MethodNode mn : cn.methods) { + if (mn.instructions == null || decoderName.equals(mn.name)) { + continue; + } + changed |= encryptMethodLiterals(cn, mn, base, isInterface, decoderName); } - changed |= encryptMethodLiterals(cn, mn, base, isInterface, decoderName); + } else { + changed |= hoistMethodLiterals(cn, base, decoderName); } } @@ -182,21 +190,16 @@ private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boo LdcInsnNode ldc = (LdcInsnNode) insn; if (ldc.cst instanceof String && shouldEncryptLiteral((String) ldc.cst)) { String plain = (String) ldc.cst; - String cipher = encode(plain, base); - // The XOR key spans 0..0xFFFF, so an ASCII literal can encrypt into mostly - // 3-byte (modified) UTF-8 characters; a large-but-valid literal could then exceed - // the 65535-byte constant-pool limit and make ASM throw while writing the class. - // Leave such a literal in plaintext rather than fail the whole build. - if (fitsConstantPool(cipher)) { - ldc.cst = cipher; - // The itf flag must be true when the decoder lives in an interface, or the JVM - // writes a Methodref instead of an InterfaceMethodref and throws - // IncompatibleClassChangeError at run time. - mn.instructions.insert(ldc, new MethodInsnNode( - Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, isInterface)); - encryptedCount++; - changed = true; - } + // shouldEncrypt already rejected any value whose ciphertext could overflow the + // constant pool, using a class-independent bound, so the encode result fits. + ldc.cst = encode(plain, base); + // The itf flag must be true when the decoder lives in an interface, or the JVM + // writes a Methodref instead of an InterfaceMethodref and throws + // IncompatibleClassChangeError at run time. + mn.instructions.insert(ldc, new MethodInsnNode( + Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, isInterface)); + encryptedCount++; + changed = true; } } insn = next; @@ -204,6 +207,68 @@ private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boo return changed; } + /** + * Hoists each distinct encryptable method-body literal in {@code cn} to a synthetic static field + * decoded once in {@code }, and rewrites its LDC sites to a GETSTATIC of that field. So a + * literal read in a loop pays the decode + intern cost once at class load instead of on every + * access. Fields are private and synthetic; the decoded value is interned, so all sites of one + * value -- and equal values in other classes -- stay reference-equal. + */ + private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) { + // 1. Collect the distinct values, in first-seen order for a stable field naming. + java.util.LinkedHashMap valueToField = new java.util.LinkedHashMap(); + for (MethodNode mn : cn.methods) { + if (mn.instructions == null || decoderName.equals(mn.name)) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String) { + String v = (String) ((LdcInsnNode) insn).cst; + if (shouldEncryptLiteral(v) && !valueToField.containsKey(v)) { + valueToField.put(v, HOISTED_FIELD_PREFIX + valueToField.size()); + } + } + } + } + if (valueToField.isEmpty()) { + return false; + } + // 2. Add a field per value and decode it once in (before the original body, so a + // literal used within itself reads the already-initialized field). + if (cn.fields == null) { + cn.fields = new java.util.ArrayList(); + } + InsnList init = new InsnList(); + for (java.util.Map.Entry e : valueToField.entrySet()) { + cn.fields.add(new FieldNode(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC, + e.getValue(), "Ljava/lang/String;", null, null)); + init.add(new LdcInsnNode(encode(e.getKey(), base))); + init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, false)); + init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, e.getValue(), "Ljava/lang/String;")); + encryptedCount++; + } + prependToClinit(cn, init); + // 3. Replace each LDC of a hoisted value with a GETSTATIC of its field. + for (MethodNode mn : cn.methods) { + if (mn.instructions == null || decoderName.equals(mn.name)) { + continue; + } + AbstractInsnNode insn = mn.instructions.getFirst(); + while (insn != null) { + AbstractInsnNode next = insn.getNext(); + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String) { + String field = valueToField.get((String) ((LdcInsnNode) insn).cst); + if (field != null) { + mn.instructions.set(insn, new FieldInsnNode(Opcodes.GETSTATIC, cn.name, field, + "Ljava/lang/String;")); + } + } + insn = next; + } + } + return true; + } + private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInterface, String decoderName) { if (cn.fields == null) { @@ -215,16 +280,12 @@ private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInte boolean isStatic = (fn.access & Opcodes.ACC_STATIC) != 0; if (isStatic && fn.value instanceof String && shouldEncrypt((String) fn.value)) { String plain = (String) fn.value; - String cipher = encode(plain, base); - // Skip a literal whose ciphertext would overflow the 65535-byte constant-pool limit - // (the XOR key can widen ASCII into 3-byte UTF-8); leaving it as-is beats failing. - if (!fitsConstantPool(cipher)) { - continue; - } + // shouldEncrypt already rejected any value whose ciphertext could overflow the + // constant pool (class-independent bound), so the encode result fits. // Strip the ConstantValue so the plaintext leaves the class file entirely // (this is the slot ParparVM would otherwise dump into the C constant pool). fn.value = null; - init.add(new LdcInsnNode(cipher)); + init.add(new LdcInsnNode(encode(plain, base))); // itf=true when the decoder lives in an interface, else the JVM emits a Methodref // instead of an InterfaceMethodref and throws IncompatibleClassChangeError. init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, isInterface)); @@ -383,6 +444,14 @@ private boolean shouldEncrypt(String s) { if (s == null || s.length() <= 2) { return false; } + // Skip a literal whose ciphertext could overflow the 65535-byte constant-pool limit and make + // ASM throw. The XOR key varies per class, so decide from the plaintext's WORST case -- every + // character widening to a 3-byte modified-UTF-8 character. That bound is class-INDEPENDENT, so + // the same value is skipped in every class rather than encrypted in one and left plaintext in + // another, which would break a valid cross-class literal == on ParparVM's deduplicated pool. + if ((long) s.length() * 3 > 65535) { + return false; + } return true; } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 92fe97cfe4a..0a0717446fd 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -159,6 +159,38 @@ public void encryptsInterfaceConstantValueField() throws Exception { assertEquals("interface constant secret", c.getField("TOKEN").get(null)); } + @Test + public void repeatedLiteralIsHoistedAndDecodedOnce() throws Exception { + // The same value is used by two methods. It must be hoisted to ONE synthetic field decoded + // once (encryptedCount == 1, not 2), and both reads must return that one interned object. + String shared = "a shared hoisted secret literal"; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Hoist", null, "java/lang/Object", null); + for (String name : new String[] {"a", "b"}) { + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, name, "()Ljava/lang/String;", null, null); + m.visitCode(); + m.visitLdcInsn(shared); + m.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + } + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 21); + byte[] out = t.transform(w.toByteArray()); + assertEquals("a repeated literal must be hoisted to a single decoded field", 1, t.getEncryptedCount()); + assertFalse(StringEncryptTransform.containsStringLiteral(out, shared)); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.Hoist", out); + Object a = c.getMethod("a").invoke(null); + Object b = c.getMethod("b").invoke(null); + assertEquals(shared, a); + org.junit.Assert.assertSame("both reads share one interned object", a, b); + } + @Test public void encryptsEvenWhenDecoderNameCollides() throws Exception { // A class that already declares a member named "zqdec$" must NOT be skipped: skipping would diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java index 46d720b8660..c6f7df8e998 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -212,7 +212,14 @@ public List retraceAll(Frame obfuscated) { } int observed = obfuscated.getLineNumber(); String originalClass = cm.originalName; - String file = simpleSourceFile(originalClass); + // For the enclosing class, keep the filename the frame actually reported when it is a real + // source name that renaming can't reconstruct -- a Kotlin frame carries Screen.kt, and a + // package-private class carries the file it was declared in (Main.java). But when the reported + // name is just the obfuscated class name with an extension (SourceFile renamed to match the + // obfuscated class, or a ParparVM synthesized .java), it carries no information, so + // synthesize .java from the retraced class instead. + String file = preferredSourceFile(obfuscated.getFileName(), obfuscated.getClassName(), + originalClass); List candidates = cm.methods.get(obfuscated.getMethodName()); List out = new ArrayList(); if (candidates != null && !candidates.isEmpty()) { @@ -237,6 +244,33 @@ private Frame frameFor(MethodMapping m, String enclosingClass, String enclosingF return new Frame(cls, m.originalName, file, m.mapLine(observed)); } + /** + * The source file to report for the enclosing class. Keeps a real reported name (Screen.kt, + * Main.java) but synthesizes {@code .java} when the reported name is empty or is + * just the obfuscated class name with an extension (a renamed/synthesized placeholder that would + * otherwise leak an obfuscated name into the retraced stack). + */ + private static String preferredSourceFile(String reported, String obfClassName, String originalClass) { + if (reported == null || reported.length() == 0) { + return simpleSourceFile(originalClass); + } + int dot = reported.lastIndexOf('.'); + String reportedBase = dot > 0 ? reported.substring(0, dot) : reported; + String obfSimple = obfClassName; + int sep = Math.max(obfSimple.lastIndexOf('.'), obfSimple.lastIndexOf('/')); + if (sep >= 0) { + obfSimple = obfSimple.substring(sep + 1); + } + int dollar = obfSimple.indexOf('$'); + if (dollar > 0) { + obfSimple = obfSimple.substring(0, dollar); + } + if (reportedBase.equals(obfSimple)) { + return simpleSourceFile(originalClass); + } + return reported; + } + private static String simpleSourceFile(String fqcn) { int d = fqcn.lastIndexOf('.'); String simple = d < 0 ? fqcn : fqcn.substring(d + 1); diff --git a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java index 7d4cd0a0331..d574831f920 100644 --- a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -116,6 +116,22 @@ public void inlinedMethodFromAnotherClassKeepsItsOwnClass() throws Exception { assertEquals("outerMethod", frames.get(1).getMethodName()); } + @Test + public void keepsRealReportedSourceFileButNotObfuscatedPlaceholder() throws Exception { + MappingFile mf = MappingFile.parse( + "com.example.Screen -> a:\n" + + " void onClick() -> b\n"); + // A real Kotlin source name the class name can't reconstruct is kept. + assertEquals("Screen.kt", + mf.retrace(new Frame("a", "b", "Screen.kt", 5)).getFileName()); + // A placeholder equal to the obfuscated class name is replaced by the retraced class's file. + assertEquals("Screen.java", + mf.retrace(new Frame("a", "b", "a.java", 5)).getFileName()); + // No reported name -> synthesized from the retraced class. + assertEquals("Screen.java", + mf.retrace(new Frame("a", "b", "", 5)).getFileName()); + } + @Test public void unknownClassPassesThroughUnchanged() throws Exception { MappingFile mf = MappingFile.parse(MAPPING); From 9c547482e1268417cd8fd1092fd5b74a7ede5a01 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:39:17 +0700 Subject: [PATCH 028/110] Address Codex round 20: fix two real hoisting bugs - Hoisted field names are now collision-free: an input class may already declare a zqL$N field (reachable on Android, where the engine doesn't rename first), and adding a duplicate would make the class fail to load. Resolve each name against existing members, as the decoder name already is. Test hoistedFieldNameDoesNotCollideWithExistingField. - Rewrite LDC->GETSTATIC BEFORE inserting the initializer, so the initializer's own ciphertext LDCs are never rescanned. Otherwise, when one literal is the XOR encoding of another for the class's seed (the transform is involutive), the initializer read a not-yet-assigned field and passed null to the decoder -> ExceptionInInitializerError on load. Test injectedInitializerCiphertextIsNotRewritten (keyBase made package-visible to construct the pair). Co-Authored-By: Claude Opus 4.8 --- .../hardening/StringEncryptTransform.java | 59 +++++++++++------ .../hardening/StringEncryptTransformTest.java | 63 +++++++++++++++++++ 2 files changed, 103 insertions(+), 19 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index a983a00b362..e63ead18dd7 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -215,8 +215,18 @@ private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boo * value -- and equal values in other classes -- stay reference-equal. */ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) { - // 1. Collect the distinct values, in first-seen order for a stable field naming. + // 1. Collect the distinct values, in first-seen order, each mapped to a field name that does + // NOT collide with an existing member (an input class may already declare a zqL$N field; + // on Android the engine doesn't rename first, so this is reachable and a duplicate field + // would make the class fail to load). + java.util.Set taken = new java.util.HashSet(); + if (cn.fields != null) { + for (FieldNode f : cn.fields) { + taken.add(f.name); + } + } java.util.LinkedHashMap valueToField = new java.util.LinkedHashMap(); + int counter = 0; for (MethodNode mn : cn.methods) { if (mn.instructions == null || decoderName.equals(mn.name)) { continue; @@ -225,7 +235,13 @@ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String) { String v = (String) ((LdcInsnNode) insn).cst; if (shouldEncryptLiteral(v) && !valueToField.containsKey(v)) { - valueToField.put(v, HOISTED_FIELD_PREFIX + valueToField.size()); + String fname; + do { + fname = HOISTED_FIELD_PREFIX + counter; + counter++; + } while (taken.contains(fname)); + taken.add(fname); + valueToField.put(v, fname); } } } @@ -233,22 +249,11 @@ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) if (valueToField.isEmpty()) { return false; } - // 2. Add a field per value and decode it once in (before the original body, so a - // literal used within itself reads the already-initialized field). - if (cn.fields == null) { - cn.fields = new java.util.ArrayList(); - } - InsnList init = new InsnList(); - for (java.util.Map.Entry e : valueToField.entrySet()) { - cn.fields.add(new FieldNode(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC, - e.getValue(), "Ljava/lang/String;", null, null)); - init.add(new LdcInsnNode(encode(e.getKey(), base))); - init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, false)); - init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, e.getValue(), "Ljava/lang/String;")); - encryptedCount++; - } - prependToClinit(cn, init); - // 3. Replace each LDC of a hoisted value with a GETSTATIC of its field. + // 2. Replace each LDC of a hoisted value with a GETSTATIC of its field, in the ORIGINAL method + // bodies. This happens BEFORE the initializer is inserted, so the initializer's own + // ciphertext LDCs are never rescanned -- otherwise a ciphertext that happens to equal + // another hoisted plaintext (the XOR encoding is involutive) would be rewritten into a read + // of a not-yet-assigned field and pass null to the decoder. for (MethodNode mn : cn.methods) { if (mn.instructions == null || decoderName.equals(mn.name)) { continue; @@ -266,6 +271,21 @@ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) insn = next; } } + // 3. Add a field per value and decode it once in , prepended so it runs before the + // original body (a hoisted value used within reads the already-initialized field). + if (cn.fields == null) { + cn.fields = new java.util.ArrayList(); + } + InsnList init = new InsnList(); + for (java.util.Map.Entry e : valueToField.entrySet()) { + cn.fields.add(new FieldNode(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC, + e.getValue(), "Ljava/lang/String;", null, null)); + init.add(new LdcInsnNode(encode(e.getKey(), base))); + init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, false)); + init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, e.getValue(), "Ljava/lang/String;")); + encryptedCount++; + } + prependToClinit(cn, init); return true; } @@ -484,7 +504,8 @@ static String decode(String enc, int base) { return encode(enc, base); } - private int keyBase(String internalName) { + /** Package-visible so a test can construct a value whose ciphertext equals another value. */ + int keyBase(String internalName) { int h = seed; for (int i = 0; i < internalName.length(); i++) { h = h * 31 + internalName.charAt(i); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 0a0717446fd..accb61fa5bb 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -191,6 +191,69 @@ public void repeatedLiteralIsHoistedAndDecodedOnce() throws Exception { org.junit.Assert.assertSame("both reads share one interned object", a, b); } + @Test + public void hoistedFieldNameDoesNotCollideWithExistingField() throws Exception { + // The class already declares a field named exactly like the first generated hoisted name. + // The transform must pick a different name, not add a duplicate field. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/FieldClash", null, "java/lang/Object", null); + w.visitField(org.objectweb.asm.Opcodes.ACC_PRIVATE | org.objectweb.asm.Opcodes.ACC_STATIC, + "zqL$0", "Ljava/lang/String;", null, null).visitEnd(); + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "get", "()Ljava/lang/String;", null, null); + m.visitCode(); + m.visitLdcInsn("a clashing hoist secret value"); + m.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 31); + byte[] out = t.transform(w.toByteArray()); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.FieldClash", out); + assertEquals("a clashing hoist secret value", c.getMethod("get").invoke(null)); + } + + @Test + public void injectedInitializerCiphertextIsNotRewritten() throws Exception { + // Construct two literals A and B where B is exactly the ciphertext of A for this class's key. + // The hoisted initializer's LDC of A's ciphertext (== B) must NOT be rewritten into a read of + // B's field, or reads an unassigned field and throws ExceptionInInitializerError. + String owner = "app/Involutive"; + StringEncryptTransform probe = new StringEncryptTransform(true, 99); + int base = probe.keyBase(owner); + String a = "an involutive secret literal"; + String b = StringEncryptTransform.encode(a, base); // ciphertext of A == plaintext B + + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + owner, null, "java/lang/Object", null); + addStringGetter(w, "a", a); + addStringGetter(w, "b", b); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 99); + byte[] out = t.transform(w.toByteArray()); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.Involutive", out); // must not throw on + assertEquals(a, c.getMethod("a").invoke(null)); + assertEquals(b, c.getMethod("b").invoke(null)); + } + + private static void addStringGetter(org.objectweb.asm.ClassWriter w, String name, String value) { + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, name, "()Ljava/lang/String;", null, null); + m.visitCode(); + m.visitLdcInsn(value); + m.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + } + @Test public void encryptsEvenWhenDecoderNameCollides() throws Exception { // A class that already declares a member named "zqdec$" must NOT be skipped: skipping would From 9c12bc2ebdf143d7d198253cdfe0b17b70bdffcf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:24:59 +0700 Subject: [PATCH 029/110] Address Codex round 21 (mapping-id + idempotence) - Idempotence uses trusted build state, not an input-jar marker. A META-INF/CN1-HARDENED resource could be added under src/main/resources or inherited from a dependency without the classes being hardened, which would silently skip the transform. Skip only when cn1.hardened (set by this method after it actually hardens) is already true. - Derive the Android mapping id whenever hardening succeeded on Android, not only when harden.rename is on: R8 renames whenever minification is on (independent of the engine's rename), and when it is off an identity map is uploaded -- both need a non-empty id. - The downstream mapping id is now a per-BUILD nonce (adds a unique run stamp), so two builds that reuse a build key but produce different R8 mappings (android.proguardKeep / R8 version / other minification change) get distinct ids and never overwrite each other's uploaded mapping. Co-Authored-By: Claude Opus 4.8 --- .../java/com/codename1/builders/Executor.java | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 24dbe8f3712..b1f1842f9e7 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2480,8 +2480,13 @@ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildEx log("cn1-hardening: forced off for this local build; building unhardened"); return sourceZip; } - if (isAlreadyHardened(sourceZip)) { - log("cn1-hardening: input already hardened; skipping"); + // Idempotence via trusted build state, not an input-jar marker: a META-INF/CN1-HARDENED + // resource could be added under src/main/resources or inherited from a dependency without the + // classes actually being hardened, which would silently skip the transform. cn1.hardened is + // set by this method only after it really hardens, so this suppresses only a second call in + // the same build. + if ("true".equals(request.getArg("cn1.hardened", "false"))) { + log("cn1-hardening: already hardened in this build; skipping"); return sourceZip; } try { @@ -2520,13 +2525,13 @@ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildEx lastHardeningMappingId = readMappingId(mapping); lastHardeningR8Keep = r8Keep.isFile() ? readFileToString(r8Keep) : ""; // On Android the engine does not rename (R8 is the sole renamer), so its mapping -- - // and thus its mapping id -- is empty. R8 still produces a real mapping.txt later, - // uploaded for this build+platform. Give the crash report a stable, build-scoped id - // derived from the build key so a report can be tied to that R8 mapping; an empty id - // would leave hardened Android crashes unretraceable. + // and thus its mapping id -- is empty. Derive a per-build id regardless of + // harden.rename: R8 still renames whenever minification is on (independent of the + // engine's rename), and when minification is off an identity map is uploaded -- both + // need a non-empty id to key the mapping the app carries. An empty id would leave + // hardened Android crashes unretraceable. if ((lastHardeningMappingId == null || lastHardeningMappingId.length() == 0) - && !hardeningRenameSupported() - && hardenBoolArg(request, "harden.rename", true)) { + && !hardeningRenameSupported()) { lastHardeningMappingId = downstreamMappingId(request, hardened); } // Propagate the mapping id / hardened flag / level into the request BEFORE the @@ -2713,14 +2718,15 @@ public String resolveMappingId(BuildRequest request) { */ // The id is necessarily fixed BEFORE R8 runs -- the app carries it as a compile-time constant, // and R8's own mapping.txt does not exist until after the app is compiled, so the id cannot be a - // hash of that mapping. It is therefore a build-INPUT identifier (build key + platform + hardened - // jar bytes): unique per build content, deterministic for a byte-identical rebuild. Correctness - // against R8 non-determinism does not come from the id's inputs but from the upload: the daemon - // uploads the produced R8 mapping.txt keyed by THIS same id, so a crash report's id selects the - // exact mapping that build shipped even if a reused build key or a different R8 result produced a - // different mapping. + // hash of that mapping. It is a per-BUILD nonce (build key + platform + hardened jar bytes + a + // unique run stamp), so every build -- even a byte-identical rebuild, or two builds that reuse a + // build key but produce different R8 mappings because android.proguardKeep or the R8 version + // changed -- gets a distinct id. The daemon uploads that build's R8 mapping.txt keyed by THIS id, + // so a crash report's id selects the exact mapping that build shipped and no upload overwrites + // another build's mapping in the same slot. private String downstreamMappingId(BuildRequest request, File hardenedJar) { - String seed = resolveBuildKey(request) + ":" + hardeningPlatform(request); + String seed = resolveBuildKey(request) + ":" + hardeningPlatform(request) + + ":" + System.nanoTime() + ":" + System.identityHashCode(hardenedJar); try { java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); md.update(seed.getBytes("UTF-8")); From f47cffb45460aee61b4aa18fc0671e290f7f5626 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:06:16 +0700 Subject: [PATCH 030/110] Address Codex round 22 (docs): correct engine-secrecy claim, note annotation exclusion - The 'server-side keeps the engine off the client' claim was false: the engine is open source (in this repo and shipped inside the Maven plugin). Rewrite the section to describe the real server boundary -- Enterprise entitlement enforcement, per-build seeding of the dictionary/decoder keys, and server-side mapping custody -- rather than algorithm secrecy. - Document that string values stored in annotation metadata (annotation values / defaults) are not encrypted and remain readable in the binary; Codename One has no reflection so the app can't read them back, but a secret placed there is not hidden. Co-Authored-By: Claude Opus 4.8 --- docs/developer-guide/App-Hardening.asciidoc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/developer-guide/App-Hardening.asciidoc b/docs/developer-guide/App-Hardening.asciidoc index 3e31670658c..73cc94beb93 100644 --- a/docs/developer-guide/App-Hardening.asciidoc +++ b/docs/developer-guide/App-Hardening.asciidoc @@ -27,6 +27,8 @@ The transform runs on the merged application jar, at the bytecode level, before One caveat: an encrypted app literal is a different object from an equal *un-encrypted* literal elsewhere -- for example a literal of the same value returned by the framework, which isn't hardened. Reference (`==`) comparison of string literals across that boundary can therefore change. Compare strings with `.equals()`, which is unaffected; `==` on distinct string values was never a guarantee to rely on. +One exclusion: a string used as an *annotation value* (or an annotation-method default) is stored by javac in the annotation metadata, not as an `LDC` or a field constant, so it's not encrypted and remains readable in the binary. Codename One has no runtime reflection, so your app can't read that value back anyway -- but don't place a secret in an annotation and expect it hidden. + |Control-flow obfuscation |Android, desktop |An opaque predicate guarded by a value the decompiler can't fold. Left off the ParparVM native ports, where it fights the translator's optimizer and the arithmetic reducer, and off JavaScript, where it inflates the bundle. Never applied to constructors. @@ -113,7 +115,7 @@ Hardening and Crash Protection are designed together. The build server retains t === Local and source builds aren't hardened -Hardening runs on the Codename One build server, and only there. Running it server-side keeps the hardening implementation itself off the client: the exact transforms, the decoder shapes and the dictionary stay on infrastructure the attacker doesn't have, which is part of what makes a hardened binary harder to reverse engineer -- an attacker can't study the tool that produced it. +Hardening runs on the Codename One build server, and only there. The engine itself is open source -- it lives in this repository and ships inside the Maven plugin -- so its protection isn't the secrecy of the algorithm. What the server boundary provides is enforcement and custody: the Enterprise entitlement is checked where the account lives (a client can't grant itself the feature), the renaming dictionary and decoder keys are seeded per build so the exact obfuscation differs each time and can't be predicted without the build key, and the obfuscation mapping is retained server-side for symbolication rather than handed to the client. A local or source-project target (`*-source`, `local-*`) never reaches the server, so its output isn't hardened; the build fails the pre-flight rather than mislead you, unless you set `harden.allowUnhardenedLocalBuild=true`. The simulator is never obfuscated either -- it runs your `target/classes` directly. App code can read `com.codename1.security.hardening.Hardening.isHardened()` to tell a hardened build apart from one of these. From e195c2110c0bd40378077939d96298a59bb04dc2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:33:47 +0700 Subject: [PATCH 031/110] Security: never trust client-supplied cn1.hardened / hardenLevel / mappingId hardeningRuntimeProperties stamps cn1.hardened / cn1.hardenLevel / cn1.mappingId into the artifact, but when harden.level is off/omitted hardenSourceJar returned without setting them -- so a request could supply cn1.hardened=true, an arbitrary level and a forged mapping id and ship an artifact whose Hardening.isHardened() lies, with the engine and the entitlement gate never running. Clear those three reserved arguments at the top of hardenSourceJar (they are engine OUTPUTS, not inputs); they are set again only from a verified hardening run. Idempotence now uses a non-forgeable per-instance flag instead of the (now-cleared) cn1.hardened argument. Co-Authored-By: Claude Opus 4.8 --- .../java/com/codename1/builders/Executor.java | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index b1f1842f9e7..03c99c0913f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2404,6 +2404,7 @@ protected java.util.List hardeningLibraryJars(BuildRequest request) { return jars; } + private boolean hardeningRanThisBuild; private File lastHardeningMapping; private String lastHardeningMappingId = ""; private String lastHardeningR8Keep = ""; @@ -2470,6 +2471,12 @@ protected boolean hardenBoolArg(BuildRequest request, String key, boolean def) { * the plugin and the cloud daemon and never shares a classloader with the caller. */ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildException { + // cn1.hardened / cn1.hardenLevel / cn1.mappingId are engine OUTPUTS, never inputs. Clear any + // supplied values up front so the stubs never stamp a hardened state the engine didn't + // actually produce; they are set again below only from a verified hardening run. + request.putArgument("cn1.hardened", "false"); + request.putArgument("cn1.hardenLevel", "off"); + request.putArgument("cn1.mappingId", ""); String level = request.getArg("harden.level", "off"); if (level == null || level.trim().length() == 0 || "off".equalsIgnoreCase(level.trim())) { return sourceZip; @@ -2480,12 +2487,10 @@ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildEx log("cn1-hardening: forced off for this local build; building unhardened"); return sourceZip; } - // Idempotence via trusted build state, not an input-jar marker: a META-INF/CN1-HARDENED - // resource could be added under src/main/resources or inherited from a dependency without the - // classes actually being hardened, which would silently skip the transform. cn1.hardened is - // set by this method only after it really hardens, so this suppresses only a second call in - // the same build. - if ("true".equals(request.getArg("cn1.hardened", "false"))) { + // Idempotence via a non-forgeable per-instance flag, not an input-jar marker (a spurious + // META-INF/CN1-HARDENED resource must not skip the transform) and not the cn1.hardened arg + // (just cleared above so a client can't forge it). Suppresses only a second call in this build. + if (hardeningRanThisBuild) { log("cn1-hardening: already hardened in this build; skipping"); return sourceZip; } @@ -2540,6 +2545,7 @@ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildEx request.putArgument("cn1.mappingId", lastHardeningMappingId); request.putArgument("cn1.hardened", "true"); request.putArgument("cn1.hardenLevel", level.trim().toLowerCase()); + hardeningRanThisBuild = true; log("cn1-hardening: applied, mappingId=" + lastHardeningMappingId); return hardened; } From 2867390dee5d0dd375747e49f0fc12c1cbddcb6a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:46:40 +0700 Subject: [PATCH 032/110] Address Codex round 26 (#5527): preserve metadata on idempotent path + Android SourceFile - hardenSourceJar now checks the idempotence flag BEFORE clearing the reserved outputs. The round-25 clearing ran first, so a second (nested/delegated) call erased the first run's cn1.hardened / hardenLevel / mappingId before returning, and the stub then stamped the transformed jar as unhardened. Reordered: the flag check returns early with the first run's outputs intact. - On a hardened build, AndroidGradleBuilder strips SourceFile from R8's kept attributes so the doc's DexGuard-parity claim holds for Android too (LineNumberTable kept; retrace synthesizes the file name). Only when android.proguardKeepOverride wasn't overridden. Co-Authored-By: Claude Opus 4.8 --- .../builders/AndroidGradleBuilder.java | 11 +++++++++++ .../java/com/codename1/builders/Executor.java | 19 ++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index cbc4b2e899d..f30e18bc4e0 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -5498,6 +5498,17 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } String keepOverride = request.getArg("android.proguardKeepOverride", "Exceptions, InnerClasses, Signature, Deprecated, SourceFile, LineNumberTable, *Annotation*, EnclosingMethod"); + // On a hardened build, strip SourceFile from R8's kept attributes for DexGuard parity (the + // crash retrace reconstructs the file name from the retraced class; LineNumberTable is kept so + // lines still retrace). Only when the developer didn't supply their own attribute list. + String hardenLvl = request.getArg("harden.level", "off"); + boolean hardeningOn = hardenLvl != null && hardenLvl.trim().length() > 0 + && !"off".equalsIgnoreCase(hardenLvl.trim()) + && !"false".equalsIgnoreCase(request.getArg("harden.and.enabled", "true")) + && request.getArg("android.proguardKeepOverride", null) == null; + if (hardeningOn) { + keepOverride = keepOverride.replace("SourceFile, ", "").replace(", SourceFile", "").replace("SourceFile,", ""); + } String keepFirebase = "-keep class com.google.android.gms.** { *; }\n\n" + "-keep class com.google.firebase.** { *; }\n\n"; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 03c99c0913f..c675b164035 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2471,9 +2471,17 @@ protected boolean hardenBoolArg(BuildRequest request, String key, boolean def) { * the plugin and the cloud daemon and never shares a classloader with the caller. */ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildException { + // Idempotence FIRST, via a non-forgeable per-instance flag (not an input-jar marker: a + // spurious META-INF/CN1-HARDENED resource must not skip the transform). On a second call in + // this build -- a nested/delegated invocation -- the verified cn1.hardened / cn1.hardenLevel / + // cn1.mappingId from the first run are already in the request, so return WITHOUT touching them. + if (hardeningRanThisBuild) { + log("cn1-hardening: already hardened in this build; skipping"); + return sourceZip; + } // cn1.hardened / cn1.hardenLevel / cn1.mappingId are engine OUTPUTS, never inputs. Clear any - // supplied values up front so the stubs never stamp a hardened state the engine didn't - // actually produce; they are set again below only from a verified hardening run. + // supplied values so the stubs never stamp a hardened state the engine didn't actually + // produce; they are set again below only from a verified hardening run. request.putArgument("cn1.hardened", "false"); request.putArgument("cn1.hardenLevel", "off"); request.putArgument("cn1.mappingId", ""); @@ -2487,13 +2495,6 @@ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildEx log("cn1-hardening: forced off for this local build; building unhardened"); return sourceZip; } - // Idempotence via a non-forgeable per-instance flag, not an input-jar marker (a spurious - // META-INF/CN1-HARDENED resource must not skip the transform) and not the cn1.hardened arg - // (just cleared above so a client can't forge it). Suppresses only a second call in this build. - if (hardeningRanThisBuild) { - log("cn1-hardening: already hardened in this build; skipping"); - return sourceZip; - } try { File engine = getResourceAsFile("/cn1-hardening.jar", ".jar"); File workDir = new File(sourceZip.getParentFile(), "cn1-harden-work"); From 78c0e90dcc9791566217d47c43b41a9b59372934 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:53:29 +0700 Subject: [PATCH 033/110] Doc: qualify SourceFile stripping for the unminified Android case R8 (and the keepOverride change) only runs when android.enableProguard is on, so an explicitly unminified Android build (enableProguard=false / onDeviceDebug) doesn't rename and keeps SourceFile along with its class names. Document that this is a debug configuration -- not the release artifact hardening targets -- rather than implying SourceFile is stripped there. Co-Authored-By: Claude Opus 4.8 --- docs/developer-guide/App-Hardening.asciidoc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/developer-guide/App-Hardening.asciidoc b/docs/developer-guide/App-Hardening.asciidoc index 73cc94beb93..d5bc0a650fb 100644 --- a/docs/developer-guide/App-Hardening.asciidoc +++ b/docs/developer-guide/App-Hardening.asciidoc @@ -99,7 +99,9 @@ The level is the one decision most projects need to make. The individual switche |Symbol/mapping upload |-- |required |required |required |=== -The source file name (`SourceFile`) is stripped, matching DexGuard. `LineNumberTable` is kept so a hardened crash still retraces to a line number against the mapping; the retrace reconstructs the file name from the (retraced) class name. Renaming also removes the local-variable and parameter names. +On a renamed build the source file name (`SourceFile`) is stripped, matching DexGuard: the engine-renamed ports drop it, and on a minified Android release R8 drops it too. `LineNumberTable` is kept so a hardened crash still retraces to a line number against the mapping; the retrace reconstructs the file name from the (retraced) class name. Renaming also removes the local-variable and parameter names. + +An explicitly *unminified* Android build (`android.enableProguard=false`, which `android.onDeviceDebug` also forces) doesn't rename at all -- only string encryption and control-flow obfuscation apply -- so its class names, and `SourceFile` along with them, remain. That's a debug configuration, not the release artifact hardening targets. === Keeping what must not be renamed From e6aebfc106605c13008b0187f47f5a236b61344b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:00:41 +0700 Subject: [PATCH 034/110] Address Codex round 27 (#5527): gate SourceFile on verified hardening, retrace ambiguity, control-flow guard collision - SourceFile stripping is now gated on the VERIFIED cn1.hardened output (set only after a successful, entitled engine run), not on harden.level. The old harden.and.enabled check recognized only the literal 'false', so an =off/0 opt-out (or a non-off level with every transform disabled) still lost SourceFile. Applied on both the CodenameOne-repo and daemon builders. - Retrace no longer fabricates the first candidate when a frame has no usable line: ProGuard/R8 can reuse one obfuscated name for several overloads, so retraceAll now emits every candidate to preserve the ambiguity. Test ambiguousFrameWithNoLineEmitsAllCandidates. - Control-flow guard field is now collision-free: a class that already declares a zq$cf field is still guarded (with a lengthened name) instead of being returned unchanged on a false 'already transformed' assumption. Test guardsAClassThatAlreadyDeclaresAGuardFieldName. Co-Authored-By: Claude Opus 4.8 --- .../hardening/ControlFlowTransform.java | 49 +++++++++++-------- .../hardening/ControlFlowTransformTest.java | 30 ++++++++++++ .../com/codename1/retrace/MappingFile.java | 8 ++- .../codename1/retrace/MappingFileTest.java | 20 ++++++++ .../builders/AndroidGradleBuilder.java | 17 +++---- 5 files changed, 94 insertions(+), 30 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java index d7e2f128838..592f556140f 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java @@ -90,9 +90,11 @@ public byte[] transform(byte[] classBytes) { if ((cn.access & Opcodes.ACC_INTERFACE) != 0) { return classBytes; } - if (hasGuardField(cn)) { - return classBytes; - } + // Pick a guard field name that collides with no existing member, so a class that happens to + // declare a zq$cf field (reachable on Android, where the engine doesn't rename first, or in a + // pre-obfuscated dependency) is still guarded instead of being returned unchanged on the false + // assumption that the collision means it was already transformed. + String guardField = resolveGuardField(cn); boolean changed = false; if (cn.methods != null) { @@ -101,7 +103,7 @@ public byte[] transform(byte[] classBytes) { continue; } for (int i = 0; i < intensity; i++) { - prependGuard(cn, mn); + prependGuard(cn, mn, guardField); } guardedMethods++; changed = true; @@ -111,8 +113,8 @@ public byte[] transform(byte[] classBytes) { return classBytes; } - addGuardField(cn); - initGuardField(cn); + addGuardField(cn, guardField); + initGuardField(cn, guardField); ClassWriter cw = new FrameClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, hierarchy); cn.accept(cw); @@ -134,10 +136,10 @@ private boolean isGuardable(MethodNode mn) { return true; } - private void prependGuard(ClassNode cn, MethodNode mn) { + private void prependGuard(ClassNode cn, MethodNode mn, String guardField) { InsnList pre = new InsnList(); LabelNode ok = new LabelNode(); - pre.add(new FieldInsnNode(Opcodes.GETSTATIC, cn.name, GUARD_FIELD, GUARD_DESC)); + pre.add(new FieldInsnNode(Opcodes.GETSTATIC, cn.name, guardField, GUARD_DESC)); // if (zq$cf > 0) goto ok; -- always taken at runtime, unprovable statically. pre.add(new JumpInsnNode(Opcodes.IFGT, ok)); // dead arm: throw new RuntimeException(); -- never reached. @@ -149,27 +151,34 @@ private void prependGuard(ClassNode cn, MethodNode mn) { mn.instructions.insert(pre); } - private boolean hasGuardField(ClassNode cn) { - if (cn.fields == null) { - return false; - } - for (FieldNode fn : cn.fields) { - if (GUARD_FIELD.equals(fn.name)) { - return true; + /** A guard field name not already declared by {@code cn} (lengthens the suffix until free). */ + private String resolveGuardField(ClassNode cn) { + String name = GUARD_FIELD; + if (cn.fields != null) { + boolean clash = true; + while (clash) { + clash = false; + for (FieldNode fn : cn.fields) { + if (name.equals(fn.name)) { + name = name + "$"; + clash = true; + break; + } + } } } - return false; + return name; } - private void addGuardField(ClassNode cn) { + private void addGuardField(ClassNode cn, String guardField) { if (cn.fields == null) { cn.fields = new java.util.ArrayList(); } cn.fields.add(new FieldNode(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC, - GUARD_FIELD, GUARD_DESC, null, null)); + guardField, GUARD_DESC, null, null)); } - private void initGuardField(ClassNode cn) { + private void initGuardField(ClassNode cn, String guardField) { InsnList init = new InsnList(); // zq$cf = Runtime.getRuntime().availableProcessors(); -- contractually >= 1 on every JVM, // and a runtime call the optimizer/decompiler cannot fold, so the guard is always taken and @@ -178,7 +187,7 @@ private void initGuardField(ClassNode cn) { "()Ljava/lang/Runtime;", false)); init.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/Runtime", "availableProcessors", "()I", false)); - init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, GUARD_FIELD, GUARD_DESC)); + init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, guardField, GUARD_DESC)); MethodNode clinit = null; if (cn.methods != null) { diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java index 17e5b17b418..a19a611e3f8 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java @@ -77,6 +77,36 @@ public void intenseGuardsVerifyAndPreserveBehaviour() throws Exception { assertEquals(5, c.getMethod("compute", int.class, int.class).invoke(null, 2, 3)); } + @Test + public void guardsAClassThatAlreadyDeclaresAGuardFieldName() throws Exception { + // A class already declares a field named exactly like the guard field. It must still be + // guarded (with a non-colliding field), not returned unchanged on a false "already + // transformed" assumption. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/GuardClash", null, "java/lang/Object", null); + w.visitField(org.objectweb.asm.Opcodes.ACC_PRIVATE | org.objectweb.asm.Opcodes.ACC_STATIC, + ControlFlowTransform.GUARD_FIELD, "I", null, null).visitEnd(); + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "add", "(II)I", null, null); + m.visitCode(); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 0); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 1); + m.visitInsn(org.objectweb.asm.Opcodes.IADD); + m.visitInsn(org.objectweb.asm.Opcodes.IRETURN); + m.visitMaxs(2, 2); + m.visitEnd(); + w.visitEnd(); + + ControlFlowTransform t = new ControlFlowTransform(); + byte[] out = t.transform(w.toByteArray()); + assertTrue("the clashing class must still be guarded, not skipped", t.getGuardedMethods() >= 1); + CheckClassAdapter.verify(new ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.GuardClash", out); + assertEquals(5, c.getMethod("add", int.class, int.class).invoke(null, 2, 3)); + } + // Renames the class internal name so the intense variant can load beside the plain one. private static byte[] rename(byte[] bytes, String from, String to) { org.objectweb.asm.ClassReader cr = new org.objectweb.asm.ClassReader(bytes); diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java index c6f7df8e998..eb47c73f672 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -229,7 +229,13 @@ public List retraceAll(Frame obfuscated) { } } if (out.isEmpty()) { - out.add(frameFor(candidates.get(0), originalClass, file, observed)); + // No line to disambiguate (Unknown Source, or the mapping records omit obfuscated + // ranges). ProGuard/R8 can reuse one obfuscated name for several overloads, so picking + // the first would name the wrong original method. Emit every candidate to preserve the + // ambiguity rather than fabricate a single answer. + for (MethodMapping m : candidates) { + out.add(frameFor(m, originalClass, file, observed)); + } } } else { out.add(new Frame(originalClass, obfuscated.getMethodName(), file, observed)); diff --git a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java index d574831f920..dd6e3a42ff7 100644 --- a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -23,6 +23,7 @@ package com.codename1.retrace; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import java.util.Arrays; import org.junit.Test; @@ -132,6 +133,25 @@ public void keepsRealReportedSourceFileButNotObfuscatedPlaceholder() throws Exce mf.retrace(new Frame("a", "b", "", 5)).getFileName()); } + @Test + public void ambiguousFrameWithNoLineEmitsAllCandidates() throws Exception { + // Two unrelated methods share one obfuscated name with no obfuscated line ranges. A frame + // with no usable line can't disambiguate, so retraceAll must emit BOTH originals rather than + // fabricating the first. + MappingFile mf = MappingFile.parse( + "com.example.C -> x:\n" + + " void alpha() -> a\n" + + " void beta() -> a\n"); + java.util.List frames = mf.retraceAll(new Frame("x", "a", "", 0)); + assertEquals(2, frames.size()); + java.util.Set names = new java.util.HashSet(); + for (Frame f : frames) { + names.add(f.getMethodName()); + } + assertTrue(names.contains("alpha")); + assertTrue(names.contains("beta")); + } + @Test public void unknownClassPassesThroughUnchanged() throws Exception { MappingFile mf = MappingFile.parse(MAPPING); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index f30e18bc4e0..040f2e46981 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -5498,15 +5498,14 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } String keepOverride = request.getArg("android.proguardKeepOverride", "Exceptions, InnerClasses, Signature, Deprecated, SourceFile, LineNumberTable, *Annotation*, EnclosingMethod"); - // On a hardened build, strip SourceFile from R8's kept attributes for DexGuard parity (the - // crash retrace reconstructs the file name from the retraced class; LineNumberTable is kept so - // lines still retrace). Only when the developer didn't supply their own attribute list. - String hardenLvl = request.getArg("harden.level", "off"); - boolean hardeningOn = hardenLvl != null && hardenLvl.trim().length() > 0 - && !"off".equalsIgnoreCase(hardenLvl.trim()) - && !"false".equalsIgnoreCase(request.getArg("harden.and.enabled", "true")) - && request.getArg("android.proguardKeepOverride", null) == null; - if (hardeningOn) { + // On a build the engine actually hardened, strip SourceFile from R8's kept attributes for + // DexGuard parity (the retrace reconstructs the file name from the retraced class; + // LineNumberTable is kept so lines still retrace). Gate on the VERIFIED cn1.hardened output + // (set only after a successful, entitled engine run), so an opt-out build (harden.and.enabled + // =off/0, or a level whose transforms are all disabled -> engine declines) keeps its + // metadata. Only when the developer didn't supply their own attribute list. + if ("true".equals(request.getArg("cn1.hardened", "false")) + && request.getArg("android.proguardKeepOverride", null) == null) { keepOverride = keepOverride.replace("SourceFile, ", "").replace(", SourceFile", "").replace("SourceFile,", ""); } From 7ba3a569ce51a703d76e848111f2f3f07fa60a01 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:03:15 +0700 Subject: [PATCH 035/110] Keep the generated SVGRegistry under its fixed name The transcoded-SVG registry com.codename1.generated.svg.SVGRegistry was absent from the keep rules, so rename-enabled builds (iOS/JS/Windows/Linux) could rename it. The platform builders probe for the class by its exact name to decide whether to emit installGlobal(), so after renaming they conclude no SVGs exist and silently drop SVG rendering. Keep it by name. Co-Authored-By: Claude Opus 4.8 --- .../main/java/com/codename1/hardening/BuiltinKeepRules.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java index 7504040017b..489f1a79bfb 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java @@ -58,6 +58,10 @@ public static List rules(String mainClass) { } // Generated registries the stub instantiates by literal name. r.add("-keep class com.codename1.router.generated.Routes { *; }"); + // The transcoded-SVG registry: the platform builders probe for this class by its exact name to + // decide whether to emit its installGlobal() call, so renaming it would make them conclude no + // SVGs were generated and silently drop SVG rendering from the hardened app. + r.add("-keep class com.codename1.generated.svg.SVGRegistry { *; }"); for (String b : BOOTSTRAPS) { r.add("-keep class cn1app." + b + " { *; }"); } From 35e2ae25188bec64f493a11fe0646e06a44fcb3a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:12:57 +0700 Subject: [PATCH 036/110] Address Codex round 29: gate R8 keep rules on verified hardening hardeningR8Keep gated on harden.level, so an Android opt-out (harden.and.enabled=off/0/false, where the engine declines) still appended keep rules -- including harden.keep -- to the ordinary R8 config, changing which names survive obfuscation. Gate on the verified cn1.hardened output instead. Also drop the stale PropertyBusinessObject fallback keep (its JSON/DB keys come from the string passed to Property, not the field name, so renaming the field is safe -- consistent with the engine keeps). Applied on both the CodenameOne-repo and daemon builders. Co-Authored-By: Claude Opus 4.8 --- .../builders/AndroidGradleBuilder.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 040f2e46981..6f80545e476 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -765,24 +765,24 @@ protected String hardeningPlatform(BuildRequest request) { * changes JSON/DB schema) must be handed to R8 here. Empty when hardening is off. */ private String hardeningR8Keep(BuildRequest request) { - String level = request.getArg("harden.level", "off"); - if (level == null || level.trim().length() == 0 || "off".equalsIgnoreCase(level.trim())) { + // Gate on the VERIFIED cn1.hardened output (set only after a successful, entitled engine run), + // not harden.level: an Android opt-out (harden.and.enabled=off/0/false, or a level whose + // transforms are all disabled) makes the engine decline, and these keep rules must not then + // change which names R8 obfuscates. + if (!"true".equals(request.getArg("cn1.hardened", "false"))) { return ""; } - // Prefer the full keep set the engine derived from the input jar: besides the name-bound - // property-object rule and the user's harden.keep, it covers the classes the ASM scanner - // found reflectively (Class.forName targets, META-INF/services providers, GUI-builder - // references). Those are invisible to R8, so without them R8 would rename a reflectively - // referenced class and the hardened release would fail to resolve its original name. + // Prefer the full keep set the engine derived from the input jar: the native-interface peers + // it found, plus the user's harden.keep. Those the automatic R8 analysis can't see, so without + // them R8 would rename a name-resolved class and the hardened release wouldn't resolve it. String engineKeep = getLastHardeningR8Keep(); if (engineKeep != null && engineKeep.trim().length() > 0) { return engineKeep.endsWith("\n") ? engineKeep : engineKeep + "\n"; } - // Fallback when the engine emitted no keep file (e.g. build() invoked without runBuild): - // keep at least the load-bearing rules so a hardened build still resolves. + // Fallback when the engine emitted no keep file (e.g. build() invoked without runBuild): the + // user's harden.keep. PropertyBusinessObject needs no keep -- its JSON/DB keys come from the + // string passed to its Property, not the field name, so renaming the field is safe. StringBuilder sb = new StringBuilder(); - sb.append("-keepclassmembernames class * implements " - + "com.codename1.properties.PropertyBusinessObject { *; }\n"); String keep = request.getArg("harden.keep", ""); if (keep != null && keep.trim().length() > 0) { // Newlines only: a ';' is legal inside a ProGuard rule body. From 7532a4c41c32edb6e5e3d291fa6aad5709d154cb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:29:59 +0700 Subject: [PATCH 037/110] Derive crash trace format from platform so JVM traces are not mislabeled js-error A stackless JVM/Android throwable whose only frames live in its cause chain renders, via printStackTrace, as a tab-indented JVM trace whose lines contain parentheses. The old heuristic looked only for the 4-space ParparVM shape and labeled every other non-empty body as js-error, so the server ran the JavaScript parser over a JVM trace and lost the cause chain. Derive the format from the platform: JavaScript ports return js-error, ParparVM C targets return parparvm-text, and an ordinary JVM printStackTrace body returns none so the server keeps the text verbatim. Adds TraceFormatTest and accepts unminified in the LanguageTool word list. Co-Authored-By: Claude Opus 4.8 --- .../codename1/crash/CrashReportPayload.java | 38 ++++++++--- docs/developer-guide/languagetool-accept.txt | 1 + .../com/codename1/crash/TraceFormatTest.java | 67 +++++++++++++++++++ 3 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java diff --git a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java index b575375209b..c73b0083f9b 100644 --- a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java +++ b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java @@ -110,13 +110,13 @@ final class CrashReportPayload { this.nativeLog = trim(nativeLog, MAX_NATIVE_LOG_LEN); this.nativeStack = trim(nativeStack, MAX_NATIVE_STACK_LEN); this.rawStack = trim(rawStack, MAX_RAW_STACK_LEN); - this.traceFormat = deriveTraceFormat(this.frames, this.rawStack); Display d = Display.getInstance(); + this.platform = d.getPlatformName(); + this.traceFormat = deriveTraceFormat(this.frames, this.rawStack, this.platform); this.buildKey = d.getProperty("build_key", ""); this.packageName = d.getProperty("package_name", ""); this.appName = d.getProperty("AppName", ""); this.appVersion = d.getProperty("AppVersion", ""); - this.platform = d.getPlatformName(); this.osVersion = d.getProperty("OSVer", ""); this.mappingId = d.getProperty("cn1.mappingId", ""); this.hardenLevel = d.getProperty("cn1.hardenLevel", ""); @@ -125,20 +125,24 @@ final class CrashReportPayload { this.clientTs = System.currentTimeMillis(); } - /// Derives the trace format from what we actually have. Structured - /// frames win; otherwise a raw stack whose first frame line begins - /// `" at "` is the ParparVM text format, and anything else with a - /// body is a JavaScript engine stack. Never a guess -- the server - /// relies on this to pick a parser. - private static String deriveTraceFormat(List frames, String rawStack) { + /// Derives the trace format from what we actually have, so the server picks the right parser -- + /// never a guess. Structured frames win. Otherwise: the JavaScript port's raw stack is a JS engine + /// stack; a ParparVM C target's is the " at .:" text; a JVM target + /// (Android/desktop) reaching here has an ordinary JVM printStackTrace (e.g. a stackless throwable + /// whose only frames are in its cause) that the JS parser must NOT touch. The old heuristic looked + /// only for the 4-space ParparVM shape and labeled everything else -- including the tab-indented + /// JVM trace -- as JavaScript; derive from the platform so that never happens. + static String deriveTraceFormat(List frames, String rawStack, String platform) { if (frames != null && !frames.isEmpty()) { return TRACE_STRUCTURED; } if (rawStack == null || rawStack.length() == 0) { return TRACE_NONE; } - // A ParparVM frame line is exactly " at .:"; a V8/JS - // frame carries a '(' or a URL. Look at the first " at " line. + if (isJavaScriptPlatform(platform)) { + return TRACE_JS; + } + // A ParparVM frame line is exactly " at .:" -- no '(', URL or '@'. int at = rawStack.indexOf(" at "); if (at >= 0) { int lineEnd = rawStack.indexOf('\n', at); @@ -147,7 +151,19 @@ private static String deriveTraceFormat(List frames, String rawStack) { return TRACE_PARPARVM; } } - return TRACE_JS; + // Not JavaScript and not the ParparVM text shape: an ordinary JVM printStackTrace body. There + // is no JVM raw parser, so report NONE and let the server keep the text verbatim rather than + // misparsing it as a JavaScript stack. + return TRACE_NONE; + } + + /// The JavaScript port's platform name; its raw stack is a JS engine {@code Error().stack}. + private static boolean isJavaScriptPlatform(String platform) { + if (platform == null) { + return false; + } + String p = platform.toLowerCase(); + return p.indexOf("html") >= 0 || p.indexOf("javascript") >= 0 || p.equals("js"); } static final class Frame { diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 01087a2255b..c7cf46d0fc4 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -644,4 +644,5 @@ unhardened unretraceable [Dd]eobfuscation [Mm]inif(y|ies|ied|ier|ication) +[Uu]nminif(y|ies|ied|ier|ication) [Rr]etrace(d|s|able)? diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java new file mode 100644 index 00000000000..a4719882c3f --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java @@ -0,0 +1,67 @@ +/* + * 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.crash; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** How the crash payload labels a raw stack so the server picks the right parser. */ +class TraceFormatTest { + + @Test + void emptyOrNoStack() { + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, null, "Android")); + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, "", "HTML5")); + } + + @Test + void javaScriptPortIsJsError() { + // The JavaScript port reports platform HTML5 and a JS engine stack. + assertEquals(CrashReportPayload.TRACE_JS, + CrashReportPayload.deriveTraceFormat(null, + "at run (http://host/app.js:12:34)\n", "HTML5")); + } + + @Test + void parparVmTextIsRecognized() { + assertEquals(CrashReportPayload.TRACE_PARPARVM, + CrashReportPayload.deriveTraceFormat(null, + " at com.foo.Bar.baz:42\n at com.foo.Bar.qux:7\n", "ios")); + } + + @Test + void jvmStackIsNotMislabeledJavaScript() { + // A stackless JVM/Android throwable whose only frames are in its cause: printStackTrace + // produces a tab-indented JVM trace with parentheses. It must NOT be labeled js-error. + String jvm = "java.lang.RuntimeException: boom\n" + + "\tat com.foo.Bar.baz(Bar.java:42)\n" + + "\tat com.foo.Bar.main(Bar.java:7)\n"; + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, jvm, "Android")); + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, jvm, "SE")); + } +} From bcd9de11bf9fb9c61bc04085d33c5646e88932a2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:53:36 +0700 Subject: [PATCH 038/110] Preserve JS stack coordinates when scrubbing; report unencryptable concat literals scrubRawStack scrubbed the whole raw stack through the message scrubber, so a minified JavaScript Error().stack frame like app.js:1:123456 had its six-plus-digit column masked to [num] and the js-error parser lost the location. It now masks long digit runs only on non-frame lines: frame/location lines (JVM/ParparVM 'at ...', Chrome 'at fn (url:line:col)', Firefox 'fn@url:line:col') keep their coordinates, while the free-form message line is still scrubbed for phone numbers, long ids and emails. StringEncryptTransform encrypts LDC and ConstantValue channels, but JDK 9+ javac compiles string concatenation to an invokedynamic whose literal fragments live in the StringConcatFactory recipe -- unreachable by those channels. The transform now counts those sites and the engine turns a non-zero total into a build warning, so a build compiled that way is reported rather than believed fully string-encrypted; the doc gains the exclusion and the -XDstringConcat=inline mitigation. Also positions the js literal first in a comparison (PMD LiteralsFirstInComparisons). Adds ConcatLiteralDetectionTest and PiiScrubberRawStackTest. Co-Authored-By: Claude Opus 4.8 --- .../codename1/crash/CrashReportPayload.java | 2 +- .../src/com/codename1/crash/PiiScrubber.java | 50 +++++++- docs/developer-guide/App-Hardening.asciidoc | 2 + .../codename1/hardening/HardeningEngine.java | 12 ++ .../hardening/StringEncryptTransform.java | 92 +++++++++++++++ .../hardening/ConcatLiteralDetectionTest.java | 110 ++++++++++++++++++ .../crash/PiiScrubberRawStackTest.java | 78 +++++++++++++ 7 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java diff --git a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java index c73b0083f9b..5326bf675db 100644 --- a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java +++ b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java @@ -163,7 +163,7 @@ private static boolean isJavaScriptPlatform(String platform) { return false; } String p = platform.toLowerCase(); - return p.indexOf("html") >= 0 || p.indexOf("javascript") >= 0 || p.equals("js"); + return p.indexOf("html") >= 0 || p.indexOf("javascript") >= 0 || "js".equals(p); } static final class Frame { diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index abce5094b8b..6c79e36b6e4 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -79,10 +79,19 @@ public String scrubFrame(String className, String methodName) { } /// Scrubs a pre-rendered stack string. On the ParparVM ports the whole - /// Java trace arrives as one string rather than structured frames, so a - /// stricter application can override this to redact aggressively. The - /// default applies the same message scrubbing (emails, long digit runs), - /// which is harmless on class/method/line text. + /// Java trace arrives as one string rather than structured frames, and on + /// the JavaScript port it is the engine's `Error().stack`. A stricter + /// application can override this to redact aggressively. + /// + /// The default scrubs emails everywhere, but applies long-digit-run masking + /// only to non-frame lines. A frame/location line carries no PII -- it is + /// class, method, file and line/column text -- and its numbers are exactly + /// what the server needs to symbolicate. In particular a minified + /// JavaScript bundle is often one line, so a `Error().stack` frame reads + /// `app.js:1:123456` where the six-plus-digit column would otherwise be + /// masked to `[num]`, destroying the location. Free-form lines (the leading + /// `ExceptionClass: message` line and any non-frame text) are still scrubbed, + /// since a message can carry a phone number or long id. /// /// #### Parameters /// @@ -92,7 +101,38 @@ public String scrubFrame(String className, String methodName) { /// /// the scrubbed stack string, or `null` if `rawStack` is `null`. public String scrubRawStack(String rawStack) { - return scrubMessage(rawStack); + if (rawStack == null) { + return null; + } + int len = rawStack.length(); + StringBuilder out = new StringBuilder(len); + int i = 0; + while (i < len) { + int nl = rawStack.indexOf('\n', i); + int lineEnd = nl < 0 ? len : nl; + String line = rawStack.substring(i, lineEnd); + String emailScrubbed = scrubEmails(line); + out.append(isFrameLine(line) ? emailScrubbed : scrubDigitRuns(emailScrubbed)); + if (nl < 0) { + break; + } + out.append('\n'); + i = nl + 1; + } + return out.toString(); + } + + /// True for a stack-trace line whose numeric tokens are source coordinates, + /// not PII: the JVM/ParparVM `at .(...)` / `at .:` + /// form, and the JavaScript engine forms (Chrome `at fn (url:line:col)`, + /// Firefox `fn@url:line:col`). Matching the location shape rather than a + /// specific engine keeps a real column offset from being masked. + private static boolean isFrameLine(String line) { + String t = line.trim(); + if (t.startsWith("at ")) { + return true; + } + return t.indexOf(".js:") >= 0 || t.indexOf("@http") >= 0 || t.indexOf("@file") >= 0; } /// Replaces all occurrences of an email-like substring with the form diff --git a/docs/developer-guide/App-Hardening.asciidoc b/docs/developer-guide/App-Hardening.asciidoc index d5bc0a650fb..3bf4915b9c9 100644 --- a/docs/developer-guide/App-Hardening.asciidoc +++ b/docs/developer-guide/App-Hardening.asciidoc @@ -29,6 +29,8 @@ One caveat: an encrypted app literal is a different object from an equal *un-enc One exclusion: a string used as an *annotation value* (or an annotation-method default) is stored by javac in the annotation metadata, not as an `LDC` or a field constant, so it's not encrypted and remains readable in the binary. Codename One has no runtime reflection, so your app can't read that value back anyway -- but don't place a secret in an annotation and expect it hidden. +Another exclusion: string concatenation such as `"prefix=" + value`, compiled by a JDK 9 or newer javac, becomes an `invokedynamic` whose literal fragments live in the concatenation recipe rather than an `LDC` or a field constant, so the encryption can't reach them and they stay readable. The engine counts these sites and the build report lists them, so a build that ships such literals says so rather than claiming complete string encryption. Compiling with `-XDstringConcat=inline` (or an older `-target`) makes javac emit `StringBuilder` calls whose literals the engine does encrypt. + |Control-flow obfuscation |Android, desktop |An opaque predicate guarded by a value the decompiler can't fold. Left off the ParparVM native ports, where it fights the translator's optimizer and the arithmetic reducer, and off JavaScript, where it inflates the bundle. Never applied to constructors. diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index dd90b70ffc6..16654ad3220 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -182,6 +182,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int seed = deriveSeed(cfg, req.getBuildKey()); int encryptedStrings = 0; + int concatLiterals = 0; boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); if (stringsApplied) { // In "constants" mode, first collect the values declared as static-final String @@ -202,6 +203,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi e.setValue(out); } encryptedStrings += t.getEncryptedCount(); + concatLiterals += t.getConcatLiteralCount(); } } @@ -284,6 +286,16 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi result.getWarnings().add("string encryption is not applied on platform '" + cfg.getPlatform() + "' (would break the JavaScript native bridge); skipped"); } + if (stringsApplied && concatLiterals > 0) { + // javac from JDK 9 compiles string concatenation to an invokedynamic whose literal + // fragments live in the StringConcatFactory recipe, not in an LDC or a ConstantValue, so + // the string channels cannot reach them. Report it rather than let a build believe it is + // fully string-encrypted; -XDstringConcat=inline (or an older -target) emits StringBuilder + // the engine does encrypt. + result.getWarnings().add(concatLiterals + " string-concatenation literal group(s) compiled " + + "to invokedynamic (JDK 9+ javac) were not encrypted; compile with " + + "-XDstringConcat=inline or an older -target to encrypt concatenation literals"); + } if (req.getReportFile() != null) { writeReport(req.getReportFile(), cfg, result); } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index e63ead18dd7..4e311102f0c 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -25,6 +25,7 @@ import java.util.List; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Handle; import org.objectweb.asm.Opcodes; import org.objectweb.asm.Type; import org.objectweb.asm.tree.AbstractInsnNode; @@ -34,6 +35,7 @@ import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.InsnNode; import org.objectweb.asm.tree.IntInsnNode; +import org.objectweb.asm.tree.InvokeDynamicInsnNode; import org.objectweb.asm.tree.LdcInsnNode; import org.objectweb.asm.tree.MethodInsnNode; import org.objectweb.asm.tree.MethodNode; @@ -63,11 +65,16 @@ public final class StringEncryptTransform { private static final String HOISTED_FIELD_PREFIX = "zqL$"; static final String DECODER_DESC = "(Ljava/lang/String;)Ljava/lang/String;"; + /** {@code StringConcatFactory} recipe markers: an ordinary argument slot and a constant slot. */ + private static final char TAG_ARG = '\u0001'; + private static final char TAG_CONST = '\u0002'; + private final boolean encryptAllStrings; private final int seed; private final ClassLoader hierarchy; private final java.util.Set constantValues; private int encryptedCount; + private int concatLiteralCount; public StringEncryptTransform(boolean encryptAllStrings, int seed) { this(encryptAllStrings, seed, null, null); @@ -114,6 +121,22 @@ public int getEncryptedCount() { return encryptedCount; } + /** + * The number of {@code invokedynamic} string-concatenation sites carrying plaintext literal text + * that this transform did NOT encrypt. javac from JDK 9 on compiles {@code "a" + b} to an + * {@code invokedynamic} bound to {@link java.lang.invoke.StringConcatFactory}, storing the literal + * fragments in the bootstrap recipe/constant arguments rather than as {@code LDC} instructions. + * The engine encrypts {@code LDC} and {@code ConstantValue} channels; a recipe cannot be rewritten + * to a decode call because {@code StringConcatFactory} interprets it at link time, so those + * fragments would ship in cleartext. They are counted and reported rather than silently left, + * so a build compiled that way is not believed fully string-encrypted. Compiling with + * {@code -XDstringConcat=inline} (or an older {@code -target}) emits {@code StringBuilder} the + * engine encrypts. + */ + public int getConcatLiteralCount() { + return concatLiteralCount; + } + /** Encrypts {@code classBytes}, returning the transformed bytes (or the input if nothing changed). */ public byte[] transform(byte[] classBytes) { ClassNode cn = new ClassNode(); @@ -137,6 +160,12 @@ public byte[] transform(byte[] classBytes) { // a value are decoded through the shared intern pool and stay reference-equal. String decoderName = resolveDecoderName(cn); + // Count invokedynamic string-concatenation literals we cannot encrypt (see + // getConcatLiteralCount). Done before the mutating passes so it is independent of them; the + // engine turns a non-zero total into a build warning so plaintext concat fragments are never + // silently shipped. + concatLiteralCount += countConcatLiterals(cn); + int base = keyBase(cn.name); boolean changed = false; @@ -207,6 +236,69 @@ private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boo return changed; } + /** + * Counts the {@code invokedynamic} string-concatenation sites in {@code cn} that carry plaintext + * the engine cannot reach. A {@code makeConcatWithConstants} recipe embeds the literal fragments of + * {@code "a" + b} directly (any character other than the U+0001 argument marker and the + * U+0002 constant marker), and additional constant fragments arrive as String bootstrap + * arguments after the recipe. Either form leaves cleartext in the constant pool that no + * {@code LDC}/{@code ConstantValue} pass touches. Counts the site once when it bears any literal + * text, so the reported number tracks concat sites rather than characters. + */ + private static int countConcatLiterals(ClassNode cn) { + if (cn.methods == null) { + return 0; + } + int count = 0; + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (!(insn instanceof InvokeDynamicInsnNode)) { + continue; + } + InvokeDynamicInsnNode indy = (InvokeDynamicInsnNode) insn; + Handle bsm = indy.bsm; + if (bsm == null + || !"java/lang/invoke/StringConcatFactory".equals(bsm.getOwner()) + || !"makeConcatWithConstants".equals(bsm.getName())) { + continue; + } + if (concatSiteHasLiteral(indy.bsmArgs)) { + count++; + } + } + } + return count; + } + + /** + * True when a {@code makeConcatWithConstants} site carries any plaintext: a recipe (the first + * bootstrap argument) with a character that is neither the U+0001 argument marker nor the + * U+0002 constant marker, or any String constant among the later bootstrap arguments. + */ + private static boolean concatSiteHasLiteral(Object[] bsmArgs) { + if (bsmArgs == null || bsmArgs.length == 0) { + return false; + } + if (bsmArgs[0] instanceof String) { + String recipe = (String) bsmArgs[0]; + for (int i = 0; i < recipe.length(); i++) { + char c = recipe.charAt(i); + if (c != TAG_ARG && c != TAG_CONST) { + return true; + } + } + } + for (int i = 1; i < bsmArgs.length; i++) { + if (bsmArgs[i] instanceof String) { + return true; + } + } + return false; + } + /** * Hoists each distinct encryptable method-body literal in {@code cn} to a synthetic static field * decoded once in {@code }, and rewrites its LDC sites to a GETSTATIC of that field. So a diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java new file mode 100644 index 00000000000..ab2ce0f5bbb --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java @@ -0,0 +1,110 @@ +/* + * 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.hardening; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Handle; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +/** + * JDK 9+ javac compiles {@code "a" + b} to an {@code invokedynamic} bound to + * {@code StringConcatFactory}, keeping the literal fragments in the bootstrap recipe rather than an + * {@code LDC}. The transform cannot encrypt those, so it must at least count them so the engine can + * warn. JDK 8 (this module's build JDK) never emits that shape, so the fixture is assembled directly. + * + *

Recipe markers: U+0001 is an ordinary argument slot, U+0002 a constant slot that + * pulls from the bootstrap arguments after the recipe. + */ +public class ConcatLiteralDetectionTest { + + private static final char ARG = '\u0001'; + private static final char CONST = '\u0002'; + + private static final Handle CONCAT_BSM = new Handle( + Opcodes.H_INVOKESTATIC, + "java/lang/invoke/StringConcatFactory", + "makeConcatWithConstants", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;" + + "Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)" + + "Ljava/lang/invoke/CallSite;", + false); + + /** A class with one literal-bearing concat, one constant-arg concat, and one pure-dynamic concat. */ + private static byte[] fixture() { + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "com/codename1/hardening/fixture/Concat", + null, "java/lang/Object", null); + + // "secret=" + x -> recipe "secret=" + ARG, the literal embedded directly in the recipe. + MethodVisitor m1 = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "withInlineLiteral", + "(Ljava/lang/String;)Ljava/lang/String;", null, null); + m1.visitCode(); + m1.visitVarInsn(Opcodes.ALOAD, 0); + m1.visitInvokeDynamicInsn("makeConcatWithConstants", "(Ljava/lang/String;)Ljava/lang/String;", + CONCAT_BSM, new Object[] {"secret=" + ARG}); + m1.visitInsn(Opcodes.ARETURN); + m1.visitMaxs(1, 1); + m1.visitEnd(); + + // x + "-sep-" + y -> recipe ARG + CONST + ARG with the constant passed as a bootstrap argument. + MethodVisitor m2 = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "withConstantArg", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", null, null); + m2.visitCode(); + m2.visitVarInsn(Opcodes.ALOAD, 0); + m2.visitVarInsn(Opcodes.ALOAD, 1); + m2.visitInvokeDynamicInsn("makeConcatWithConstants", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", + CONCAT_BSM, new Object[] {"" + ARG + CONST + ARG, "-sep-"}); + m2.visitInsn(Opcodes.ARETURN); + m2.visitMaxs(2, 2); + m2.visitEnd(); + + // x + y, no literal at all (recipe is just the two argument markers): must NOT be counted. + MethodVisitor m3 = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "pureDynamic", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", null, null); + m3.visitCode(); + m3.visitVarInsn(Opcodes.ALOAD, 0); + m3.visitVarInsn(Opcodes.ALOAD, 1); + m3.visitInvokeDynamicInsn("makeConcatWithConstants", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", + CONCAT_BSM, new Object[] {"" + ARG + ARG}); + m3.visitInsn(Opcodes.ARETURN); + m3.visitMaxs(2, 2); + m3.visitEnd(); + + cw.visitEnd(); + return cw.toByteArray(); + } + + @Test + public void countsLiteralBearingConcatSitesOnly() { + StringEncryptTransform t = new StringEncryptTransform(true, 42); + t.transform(fixture()); + // withInlineLiteral (recipe literal) + withConstantArg (String bootstrap arg) = 2; pureDynamic = 0. + assertEquals(2, t.getConcatLiteralCount()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java new file mode 100644 index 00000000000..1031240c4dc --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -0,0 +1,78 @@ +/* + * 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.crash; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** scrubRawStack keeps frame-line coordinates while still scrubbing message PII. */ +class PiiScrubberRawStackTest { + + private final PiiScrubber scrubber = new PiiScrubber(); + + @Test + void nullPassesThrough() { + assertEquals(null, scrubber.scrubRawStack(null)); + } + + @Test + void javaScriptColumnOffsetsSurvive() { + // A minified bundle is one line, so the column offset runs to six-plus digits. It is the + // location the js-error parser needs, so it must not be masked to [num]. + String stack = "TypeError: undefined is not a function\n" + + " at run (http://host/app.js:1:123456)\n" + + " at go (http://host/app.js:1:98765)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("app.js:1:123456") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("[num]") < 0, scrubbed); + } + + @Test + void firefoxFramesSurvive() { + String stack = "Error: boom\nrun@http://host/app.js:1:123456\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("app.js:1:123456") >= 0, scrubbed); + } + + @Test + void parparVmLineNumbersSurvive() { + String stack = " at com.foo.Bar.baz:123456\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("baz:123456") >= 0, scrubbed); + } + + @Test + void messageDigitsAndEmailsStillScrubbed() { + // The leading message line is free-form and can carry PII: a long id/phone is masked and an + // email is partially redacted, even though frame coordinates below are preserved. + String stack = "java.lang.RuntimeException: user 5551234567 test@example.com\n" + + " at com.foo.Bar.baz(Bar.java:42)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("[num]") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("5551234567") < 0, scrubbed); + assertTrue(scrubbed.indexOf("tes***@example.com") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("Bar.java:42") >= 0, scrubbed); + } +} From c31273cc9ec923920a24eda88d99ac79b5f69d7b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:49:32 +0700 Subject: [PATCH 039/110] Tighten JS frame detection; print replacement frames after setStackTrace on ParparVM PiiScrubber.isFrameLine treated any line containing '.js:', '@http' or '@file' as a frame, so a free-form message that merely mentions a file ('account 123456 failed in app.js: retry') was classified as a frame and its id bypassed digit-run scrubbing. The non-'at' case now requires an '@' AND an actual terminal :line:column location, which a message does not carry; a real Firefox/Safari frame still keeps its coordinate. Adds a regression test for the message-mentions-a-file case. ParparVM Throwable.printStackTrace unconditionally printed the native stack string, so an app that called setStackTrace() to sanitize or clear its frames still uploaded the original native frames through rawStack even though getStackTrace() honored the replacement. A new stackReplaced flag makes printStackTrace render the replacement frames (in the same ' at .:' shape parseStackString reads back) once setStackTrace ran; the default path is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 41 +++++++++++++++++-- .../crash/PiiScrubberRawStackTest.java | 13 ++++++ vm/JavaAPI/src/java/lang/Throwable.java | 34 +++++++++++++-- 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index 6c79e36b6e4..b3de7b53572 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -124,15 +124,48 @@ public String scrubRawStack(String rawStack) { /// True for a stack-trace line whose numeric tokens are source coordinates, /// not PII: the JVM/ParparVM `at .(...)` / `at .:` - /// form, and the JavaScript engine forms (Chrome `at fn (url:line:col)`, - /// Firefox `fn@url:line:col`). Matching the location shape rather than a - /// specific engine keeps a real column offset from being masked. + /// form (which every V8/Chrome JavaScript frame also uses), and the + /// Firefox/Safari `fn@url:line:column` form. + /// + /// The `at ` prefix is a strong frame signal. For the `@` form the prefix + /// is absent, so a bare substring test such as `.js:` would also match a + /// free-form message that merely mentions a file (`account 123456 failed in + /// app.js: retry`) and let its id through. So the non-`at` case requires an + /// `@` *and* an actual terminal `::` location, which a message + /// does not carry. private static boolean isFrameLine(String line) { String t = line.trim(); if (t.startsWith("at ")) { return true; } - return t.indexOf(".js:") >= 0 || t.indexOf("@http") >= 0 || t.indexOf("@file") >= 0; + return t.indexOf('@') >= 0 && endsWithLineColumn(t); + } + + /// True when `t` ends with a `::` location: two colon-separated + /// runs of digits, allowing a single trailing `)` (a wrapped frame). This is + /// the JavaScript engine frame location; a free-form message ending in text + /// (or a lone number) does not match, so its digits stay subject to scrubbing. + private static boolean endsWithLineColumn(String t) { + int end = t.length(); + if (end > 0 && t.charAt(end - 1) == ')') { + end--; + } + int i = end - 1; + int col = 0; + while (i >= 0 && t.charAt(i) >= '0' && t.charAt(i) <= '9') { + i--; + col++; + } + if (col == 0 || i < 0 || t.charAt(i) != ':') { + return false; + } + i--; + int lineDigits = 0; + while (i >= 0 && t.charAt(i) >= '0' && t.charAt(i) <= '9') { + i--; + lineDigits++; + } + return lineDigits > 0 && i >= 0 && t.charAt(i) == ':'; } /// Replaces all occurrences of an email-like substring with the form diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 1031240c4dc..03bf0e6352e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -63,6 +63,19 @@ void parparVmLineNumbersSurvive() { assertTrue(scrubbed.indexOf("baz:123456") >= 0, scrubbed); } + @Test + void messageMentioningAFileIsNotAFrame() { + // A free-form message can mention a file and an id; it is not a frame just because it contains + // ".js:", so its long id must still be masked. (No terminal :line:column, no leading "at ".) + String stack = "Error: account 123456 failed in app.js: retry\n" + + " at run (http://host/app.js:1:98765)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account [num] failed") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + // ...while the real frame below keeps its coordinate. + assertTrue(scrubbed.indexOf("app.js:1:98765") >= 0, scrubbed); + } + @Test void messageDigitsAndEmailsStillScrubbed() { // The leading message line is free-form and can carry PII: a long id/phone is masked and an diff --git a/vm/JavaAPI/src/java/lang/Throwable.java b/vm/JavaAPI/src/java/lang/Throwable.java index 1591ecaa7ed..8b04febdc10 100644 --- a/vm/JavaAPI/src/java/lang/Throwable.java +++ b/vm/JavaAPI/src/java/lang/Throwable.java @@ -40,6 +40,7 @@ public class Throwable{ private java.util.List suppressed; private StackTraceElement[] parsedStack; private boolean stackParsed; + private boolean stackReplaced; /** @@ -99,15 +100,15 @@ public java.lang.String getMessage(){ * The format of the backtrace information depends on the implementation. */ public void printStackTrace(){ - System.out.println(stack); + System.out.println(renderedStack()); if (cause != null) { System.out.println("Caused by "); cause.printStackTrace(); } } - + public void printStackTrace(java.io.PrintStream s) { - s.println(stack); + s.println(renderedStack()); if (cause != null) { s.println("Caused by "); cause.printStackTrace(s); @@ -115,12 +116,36 @@ public void printStackTrace(java.io.PrintStream s) { } public void printStackTrace(PrintWriter s) { - s.println(stack); + s.println(renderedStack()); if (cause != null) { s.println("Caused by "); cause.printStackTrace(s); } } + + /** + * The text to print for this throwable's own frames. By default this is the native + * pre-rendered stack string. Once setStackTrace() has replaced the frames (an app + * sanitizing or clearing its trace), the native string is stale and no longer matches + * getStackTrace(), so render the replacement frames instead -- in the same + * " at <fqcn>.<method>:<line>" shape parseStackString reads back, so a + * captured rawStack stays consistent with the structured frames. + */ + private String renderedStack() { + if(!stackReplaced) { + return stack; + } + StringBuilder sb = new StringBuilder(); + sb.append(toString()); + if(parsedStack != null) { + for(int i = 0 ; i < parsedStack.length ; i++) { + StackTraceElement e = parsedStack[i]; + sb.append('\n').append(" at ").append(e.getClassName()) + .append('.').append(e.getMethodName()).append(':').append(e.getLineNumber()); + } + } + return sb.toString(); + } public StackTraceElement[] getStackTrace() { @@ -149,6 +174,7 @@ public void setStackTrace(StackTraceElement[] el) { } parsedStack = copy; stackParsed = true; + stackReplaced = true; } /** From 2c0970473f5cb5a88012441d185ff5a7f094b914 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:47:35 +0700 Subject: [PATCH 040/110] Serialize hardening warnings in the report; correct PARANOID profile javadoc writeReport closed the JSON after transforms without emitting result.getWarnings(), so a report could advertise strings:all while omitting the known plaintext exclusion (JDK 9+ concat) the doc promises it surfaces. A consumer reading the report rather than the forked-process log got an inaccurate result. The report now carries a warnings array; reportSerializesWarnings covers it. The HardeningProfile.PARANOID javadoc still claimed reflective-name hiding, but the implementation only raises getControlFlowIntensity() from one guard to two and there is no reflective-name transform (Codename One has no runtime reflection). Corrected the javadoc to state what PARANOID actually does, matching the guide and build-hint UI. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/HardeningEngine.java | 13 ++++++++++ .../codename1/hardening/HardeningProfile.java | 9 +++++-- .../hardening/HardeningEngineTest.java | 26 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 16654ad3220..93cc1c28980 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -465,6 +465,19 @@ private static void writeReport(File reportFile, HardeningConfig cfg, HardeningR } sb.append('"').append(json(t.get(i))).append('"'); } + sb.append("],\n"); + // Serialize the warnings too: a warning records a known limitation (e.g. plaintext left in a + // JDK 9+ concat recipe, or a transform skipped as unsafe for the platform). A consumer reading + // the report -- not the forked-process log -- would otherwise see "transforms":["strings:all"] + // with no hint that some plaintext was excluded, which the doc promises the report surfaces. + sb.append(" \"warnings\": ["); + List w = r.getWarnings(); + for (int i = 0; i < w.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append('"').append(json(w.get(i))).append('"'); + } sb.append("]\n"); sb.append("}\n"); writeText(reportFile, sb.toString()); diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningProfile.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningProfile.java index 60c734b4835..0541a9c34a7 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningProfile.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningProfile.java @@ -32,9 +32,14 @@ public enum HardeningProfile { OFF, /** Class/method/field renaming plus encryption of constant strings. */ STANDARD, - /** Adds encryption of all strings and control-flow obfuscation. */ + /** Adds encryption of all strings and control-flow obfuscation (opaque predicates). */ AGGRESSIVE, - /** Adds opaque predicates and reflective-name hiding on top of aggressive. */ + /** + * Raises control-flow obfuscation intensity on top of aggressive: two opaque-predicate + * guards per eligible method instead of one ({@link HardeningConfig#getControlFlowIntensity()}). + * It adds no new kind of transform -- in particular there is no reflective-name hiding, since + * Codename One has no runtime reflection for such names to feed. + */ PARANOID; /** Parses a level name case-insensitively; returns {@code null} for an unknown value. */ diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index ebe2e1a42e3..6a62a8c051f 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -329,6 +329,32 @@ public void javascriptSkipsStringEncryption() throws Exception { assertTrue(r.getRenamedClasses() >= 1); } + @Test + public void reportSerializesWarnings() throws Exception { + // On the ParparVM native ports control-flow obfuscation is skipped as unsafe, which records a + // warning. String encryption still applies on iOS, so the build is hardened and a report is + // written (no ProGuard/rename needed). The warning must appear in the JSON report, not only in + // the forked-process log, or a consumer reading the report is told less than the truth. + File in = buildInputJar(); + File out = tmp.newFile("app-hardened-warn.jar"); + File mapping = tmp.newFile("mapping-warn.txt"); + File report = tmp.newFile("report-warn.json"); + Map hints = new HashMap(); + hints.put("harden.level", "aggressive"); + HardeningConfig cfg = HardeningConfig.from(hints, "ios", false); + HardeningRequest req = new HardeningRequest() + .inputJar(in).outputJar(out).mappingFile(mapping).reportFile(report) + .workDir(tmp.newFolder("work-warn")).config(cfg) + .mainClass("com.codename1.hardening.fixture.Secrets").buildKey("TESTKEY"); + HardeningResult r = HardeningEngine.harden(req); + assertTrue("string encryption should harden the iOS build", r.isHardened()); + assertFalse("the skipped control-flow pass should record a warning", r.getWarnings().isEmpty()); + String json = new String(Files.readAllBytes(report.toPath()), Charset.forName("UTF-8")); + assertTrue("report must contain a warnings array: " + json, json.indexOf("\"warnings\"") >= 0); + assertTrue("report must serialize the warning text: " + json, + json.indexOf("control-flow obfuscation is not applied") >= 0); + } + @Test public void scannerKeepsNativeInterfacePeers() throws Exception { // Phase 1: find the native interface. Phase 2: keep ITS generated Impl/Stub peer -- narrow, From 84fab7af214f5babd31e3e91048cb644b0b728ef Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:28:38 +0700 Subject: [PATCH 041/110] Route raw-stack messages through scrubMessage; base preflight on the effective transform set scrubRawStack passed non-frame lines through the static email/digit helpers, so an app that overrode scrubMessage() to redact app-specific tokens had them removed from messageScrubbed but still uploaded in rawStack. Non-frame lines now go through the overridable scrubMessage(); frame lines keep the built-in email-only pass so their line/column coordinates survive. HardeningPreflight was fed a level derived only from harden.level, so a local/source or on-device-debug build with harden.level=standard but every transform overridden off (harden.rename=false, harden.strings=off, harden.controlFlow=false) was rejected even though the engine treats it as SKIPPED_NOT_REQUESTED -- equivalent to off. The mojo now resolves the overrides to the effective transform set (hardeningRequestsAnyTransform, mirroring the engine's HardeningConfig/willApplyAnyTransform) and treats a request-nothing build as off. Adds a custom-scrubMessage raw-stack test and the preflight effective-transform truth table. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 9 +- .../com/codename1/maven/CN1BuildMojo.java | 92 +++++++++++++++++++ .../maven/HardeningPreflightTest.java | 38 ++++++++ .../crash/PiiScrubberRawStackTest.java | 21 +++++ 4 files changed, 158 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index b3de7b53572..1509433a398 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -100,6 +100,12 @@ public String scrubFrame(String className, String methodName) { /// #### Returns /// /// the scrubbed stack string, or `null` if `rawStack` is `null`. + /// + /// A free-form (non-frame) line is routed through {@link #scrubMessage(String)} + /// -- the overridable method -- so an app that redacts app-specific tokens there + /// redacts them in `rawStack` too, not only in the separately-scrubbed message. + /// A frame line instead gets only the built-in email pass: the virtual scrubber + /// masks long digit runs, which would destroy a frame's line/column coordinate. public String scrubRawStack(String rawStack) { if (rawStack == null) { return null; @@ -111,8 +117,7 @@ public String scrubRawStack(String rawStack) { int nl = rawStack.indexOf('\n', i); int lineEnd = nl < 0 ? len : nl; String line = rawStack.substring(i, lineEnd); - String emailScrubbed = scrubEmails(line); - out.append(isFrameLine(line) ? emailScrubbed : scrubDigitRuns(emailScrubbed)); + out.append(isFrameLine(line) ? scrubEmails(line) : scrubMessage(line)); if (nl < 0) { break; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 1e850c203bd..0d4770e9fc0 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -205,6 +205,14 @@ private void applyHardeningPreflight() throws MojoFailureException { settings.getProperty("codename1.arg.harden." + hardenPlatform + ".enabled", "true"))) { level = "off"; } + // Even at a non-off level, a build that has overridden every individual transform off + // (e.g. harden.rename=false, harden.strings=off, harden.controlFlow=false) requests nothing: + // the engine treats that as SKIPPED_NOT_REQUESTED, which is equivalent to off. Resolve the + // overrides to the effective transform set so such a build is not rejected on a local/source + // or on-device-debug target for a "hardening" it isn't actually asking for. + if (!"off".equalsIgnoreCase(level.trim()) && !hardeningRequestsAnyTransform(settings, level)) { + level = "off"; + } boolean allowLocal = "true".equalsIgnoreCase( settings.getProperty("codename1.arg.harden.allowUnhardenedLocalBuild", "false").trim()); boolean onDeviceDebug = "true".equalsIgnoreCase( @@ -275,6 +283,90 @@ private static boolean isHardenFalse(String value) { return "false".equals(t) || "0".equals(t) || "off".equals(t); } + /** + * True when, at this level, at least one hardening transform is still requested once the + * individual {@code harden.*} overrides are applied -- mirroring the engine's + * {@code HardeningConfig}/{@code willApplyAnyTransform} "is anything requested" decision (the + * platform-safety refinement is the server's, and only ever narrows this). A level whose every + * transform is overridden off requests nothing and is equivalent to {@code off}, so the preflight + * must not reject it. + */ + static boolean hardeningRequestsAnyTransform(Properties settings, String level) { + int rank = hardenLevelRank(level); + if (rank <= 0) { + return false; + } + // rank is 1..3 here (standard/aggressive/paranoid), so the standard-level defaults -- renaming + // and constant-string encryption -- are on unless explicitly overridden off. Control-flow is a + // default only from aggressive up. + boolean atLeastAggressive = rank >= 2; + boolean rename = hardenBoolTri( + settings.getProperty("codename1.arg.harden.rename"), true); + boolean stringsOn = hardenStringsRequested( + settings.getProperty("codename1.arg.harden.strings"), true); + boolean controlFlow = hardenBoolTri( + settings.getProperty("codename1.arg.harden.controlFlow"), atLeastAggressive); + return rename || stringsOn || controlFlow; + } + + /** off/empty/unknown = 0, standard = 1, aggressive = 2, paranoid = 3. */ + private static int hardenLevelRank(String level) { + if (level == null) { + return 0; + } + String v = level.trim().toLowerCase(); + if ("standard".equals(v)) { + return 1; + } + if ("aggressive".equals(v)) { + return 2; + } + if ("paranoid".equals(v)) { + return 3; + } + return 0; + } + + /** Tri-state boolean matching the engine's {@code HardeningConfig.boolTri}. */ + private static boolean hardenBoolTri(String value, boolean def) { + if (value == null) { + return def; + } + String t = value.trim().toLowerCase(); + if (t.isEmpty()) { + return def; + } + if ("true".equals(t) || "1".equals(t) || "2".equals(t) || "3".equals(t) || "on".equals(t)) { + return true; + } + if ("false".equals(t) || "0".equals(t) || "off".equals(t)) { + return false; + } + return def; + } + + /** + * Whether string encryption is requested, matching {@code HardeningConfig}'s {@code harden.strings} + * parsing: {@code off} disables; {@code constants}/{@code all} enable; anything else (including an + * unset value) falls back to the level default, which the CLI validates up front. + */ + private static boolean hardenStringsRequested(String strings, boolean def) { + if (strings == null) { + return def; + } + String v = strings.trim().toLowerCase(); + if (v.isEmpty()) { + return def; + } + if ("off".equals(v)) { + return false; + } + if ("constants".equals(v) || "all".equals(v)) { + return true; + } + return def; + } + /** Maps {@code codename1.platform} to the {@code harden..enabled} opt-out key. */ private static String normalizeHardenPlatform(String platform) { if (platform == null) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java index 4592577bc9a..2cf6f857260 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java @@ -80,4 +80,42 @@ public void onDeviceDebugWithHardeningFails() { assertTrue(r.isFailed()); assertTrue(r.getMessage().contains("on-device-debug")); } + + @Test + public void levelWithEveryTransformOverriddenOffRequestsNothing() { + // standard defaults to rename + constant-string encryption; overriding all of them off leaves + // nothing for the engine to do (SKIPPED_NOT_REQUESTED), so the mojo must treat it as off and + // not reject a local/source build for a "hardening" it isn't asking for. + java.util.Properties p = new java.util.Properties(); + p.setProperty("codename1.arg.harden.rename", "false"); + p.setProperty("codename1.arg.harden.strings", "off"); + p.setProperty("codename1.arg.harden.controlFlow", "false"); + assertFalse(CN1BuildMojo.hardeningRequestsAnyTransform(p, "standard")); + assertFalse(CN1BuildMojo.hardeningRequestsAnyTransform(p, "aggressive")); + assertFalse(CN1BuildMojo.hardeningRequestsAnyTransform(p, "paranoid")); + } + + @Test + public void aRemainingTransformStillCounts() { + // Any single transform left on means hardening IS requested. + java.util.Properties strings = new java.util.Properties(); + strings.setProperty("codename1.arg.harden.rename", "false"); + strings.setProperty("codename1.arg.harden.strings", "all"); + strings.setProperty("codename1.arg.harden.controlFlow", "false"); + assertTrue(CN1BuildMojo.hardeningRequestsAnyTransform(strings, "standard")); + + java.util.Properties rename = new java.util.Properties(); + rename.setProperty("codename1.arg.harden.strings", "off"); + rename.setProperty("codename1.arg.harden.controlFlow", "false"); + // rename unset -> defaults on at standard. + assertTrue(CN1BuildMojo.hardeningRequestsAnyTransform(rename, "standard")); + } + + @Test + public void defaultsAtStandardRequestHardening() { + // No overrides at a non-off level requests hardening (rename + constant strings by default). + assertTrue(CN1BuildMojo.hardeningRequestsAnyTransform(new java.util.Properties(), "standard")); + // off requests nothing regardless of overrides. + assertFalse(CN1BuildMojo.hardeningRequestsAnyTransform(new java.util.Properties(), "off")); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 03bf0e6352e..e1f223b9af3 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -76,6 +76,27 @@ void messageMentioningAFileIsNotAFrame() { assertTrue(scrubbed.indexOf("app.js:1:98765") >= 0, scrubbed); } + @Test + void customScrubMessageOverrideReachesRawStack() { + // An app that redacts an app-specific token by overriding scrubMessage must have it redacted + // in rawStack too, not only in the separately-scrubbed message. Frame lines stay untouched by + // the override so coordinates survive. + PiiScrubber custom = new PiiScrubber() { + public String scrubMessage(String message) { + if (message == null) { + return null; + } + return super.scrubMessage(message).replace("SECRET", "[redacted]"); + } + }; + String stack = "java.lang.RuntimeException: token SECRET rejected\n" + + " at run (http://host/app.js:1:98765)\n"; + String scrubbed = custom.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("[redacted]") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("SECRET") < 0, scrubbed); + assertTrue(scrubbed.indexOf("app.js:1:98765") >= 0, scrubbed); + } + @Test void messageDigitsAndEmailsStillScrubbed() { // The leading message line is free-form and can carry PII: a long id/phone is masked and an From 35b096967132b770e190aa59fef0b674941282f1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:43:25 +0700 Subject: [PATCH 042/110] Require a real location for 'at' frame lines; keep invalid harden.level failing fast The 'at ' branch of isFrameLine treated any line starting with 'at ' as a frame, so a message wrapped onto a line like 'at account 123456 failed' was classified as a frame and its id skipped scrubbing. The 'at ' form now requires a parenthesized location ((File.java:42), (url:line:col), (Native Method)) or a bare trailing : (ParparVM); the '@' form is unchanged. Adds a test for the at-prefixed message case. The effective-transform reduction rewrote any level with rank 0 to off, so a misspelled harden.level took the same path as a valid level with all transforms disabled and was silently accepted, defeating the client-side invalid-level validation. Reduction now runs only for a valid (rank >= 1) level via hardeningReducesToOff, so an unknown level reaches HardeningPreflight.check() and is rejected fast. Adds invalid-level and valid-all-off truth-table tests. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 33 +++++++++++++++---- .../com/codename1/maven/CN1BuildMojo.java | 16 +++++++-- .../maven/HardeningPreflightTest.java | 26 +++++++++++++++ .../crash/PiiScrubberRawStackTest.java | 13 ++++++++ 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index 1509433a398..d5f15178717 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -132,20 +132,39 @@ public String scrubRawStack(String rawStack) { /// form (which every V8/Chrome JavaScript frame also uses), and the /// Firefox/Safari `fn@url:line:column` form. /// - /// The `at ` prefix is a strong frame signal. For the `@` form the prefix - /// is absent, so a bare substring test such as `.js:` would also match a - /// free-form message that merely mentions a file (`account 123456 failed in - /// app.js: retry`) and let its id through. So the non-`at` case requires an - /// `@` *and* an actual terminal `::` location, which a message - /// does not carry. + /// Both forms require an actual frame location, not just the leading token: a + /// message can wrap onto a line that begins with `at ` (`printStackTrace` puts + /// `at account 123456 failed` on its own line) or that merely mentions a file, + /// and its id must still be scrubbed. So the `at ` form must carry a + /// parenthesized location (`(File.java:42)`, `(url:line:col)`, `(Native Method)`) + /// or a bare trailing `:` (ParparVM), and the `@` form must carry an `@` + /// and a terminal `::`. private static boolean isFrameLine(String line) { String t = line.trim(); if (t.startsWith("at ")) { - return true; + return (t.endsWith(")") && t.indexOf('(') >= 0) || endsWithColonNumber(t); } return t.indexOf('@') >= 0 && endsWithLineColumn(t); } + /// True when `t` ends with a `:` run (a trailing `)` allowed): the + /// ParparVM frame coordinate `at .:`, and also the tail of a + /// `::`. A message ending in text or a space-separated number does + /// not match, so its digits stay subject to scrubbing. + private static boolean endsWithColonNumber(String t) { + int end = t.length(); + if (end > 0 && t.charAt(end - 1) == ')') { + end--; + } + int i = end - 1; + int digits = 0; + while (i >= 0 && t.charAt(i) >= '0' && t.charAt(i) <= '9') { + i--; + digits++; + } + return digits > 0 && i >= 0 && t.charAt(i) == ':'; + } + /// True when `t` ends with a `::` location: two colon-separated /// runs of digits, allowing a single trailing `)` (a wrapped frame). This is /// the JavaScript engine frame location; a free-form message ending in text diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 0d4770e9fc0..b6e6af8ecab 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -209,8 +209,9 @@ private void applyHardeningPreflight() throws MojoFailureException { // (e.g. harden.rename=false, harden.strings=off, harden.controlFlow=false) requests nothing: // the engine treats that as SKIPPED_NOT_REQUESTED, which is equivalent to off. Resolve the // overrides to the effective transform set so such a build is not rejected on a local/source - // or on-device-debug target for a "hardening" it isn't actually asking for. - if (!"off".equalsIgnoreCase(level.trim()) && !hardeningRequestsAnyTransform(settings, level)) { + // or on-device-debug target for a "hardening" it isn't actually asking for. An unknown level + // is NOT reduced here -- it must reach the preflight so the invalid-level check rejects it. + if (hardeningReducesToOff(settings, level)) { level = "off"; } boolean allowLocal = "true".equalsIgnoreCase( @@ -291,6 +292,17 @@ private static boolean isHardenFalse(String value) { * transform is overridden off requests nothing and is equivalent to {@code off}, so the preflight * must not reject it. */ + /** + * True when a valid non-off level requests no transform once the {@code harden.*} + * overrides are applied, so it is equivalent to {@code off} and must not be rejected. An unknown + * or misspelled level (rank 0) returns {@code false} so it is left untouched and reaches + * {@link HardeningPreflight#check} -- which rejects it fast, client-side, rather than letting a + * cloud build be submitted for the forked engine to reject later. + */ + static boolean hardeningReducesToOff(Properties settings, String level) { + return hardenLevelRank(level) >= 1 && !hardeningRequestsAnyTransform(settings, level); + } + static boolean hardeningRequestsAnyTransform(Properties settings, String level) { int rank = hardenLevelRank(level); if (rank <= 0) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java index 2cf6f857260..d3260a76fb4 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java @@ -118,4 +118,30 @@ public void defaultsAtStandardRequestHardening() { // off requests nothing regardless of overrides. assertFalse(CN1BuildMojo.hardeningRequestsAnyTransform(new java.util.Properties(), "off")); } + + @Test + public void invalidLevelIsNotReducedToOff() { + // A misspelled level must NOT be silently rewritten to off by the no-transform reduction: it + // has to reach HardeningPreflight.check() so the client-side invalid-level validation fires. + java.util.Properties allOff = new java.util.Properties(); + allOff.setProperty("codename1.arg.harden.rename", "false"); + allOff.setProperty("codename1.arg.harden.strings", "off"); + allOff.setProperty("codename1.arg.harden.controlFlow", "false"); + assertFalse(CN1BuildMojo.hardeningReducesToOff(allOff, "stanadrd")); + assertFalse(CN1BuildMojo.hardeningReducesToOff(new java.util.Properties(), "stanadrd")); + // The invalid level still fails the preflight, unchanged. + assertTrue(HardeningPreflight.check("stanadrd", "ios-device", false, false).isFailed()); + } + + @Test + public void validLevelWithEveryTransformOffReducesToOff() { + java.util.Properties allOff = new java.util.Properties(); + allOff.setProperty("codename1.arg.harden.rename", "false"); + allOff.setProperty("codename1.arg.harden.strings", "off"); + allOff.setProperty("codename1.arg.harden.controlFlow", "false"); + assertTrue(CN1BuildMojo.hardeningReducesToOff(allOff, "standard")); + // A real request or plain off is not "reduced" (off has nothing to reduce). + assertFalse(CN1BuildMojo.hardeningReducesToOff(new java.util.Properties(), "standard")); + assertFalse(CN1BuildMojo.hardeningReducesToOff(allOff, "off")); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index e1f223b9af3..413689bdf6a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -76,6 +76,19 @@ void messageMentioningAFileIsNotAFrame() { assertTrue(scrubbed.indexOf("app.js:1:98765") >= 0, scrubbed); } + @Test + void messageLineStartingWithAtIsNotAFrame() { + // printStackTrace can wrap a message onto a line that begins with "at " but carries no frame + // location; its long id must still be scrubbed. A real JVM frame below keeps its line number. + String stack = "java.lang.RuntimeException: bad\n" + + "at account 123456 failed to load\n" + + "\tat com.foo.Bar.baz(Bar.java:4242)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account [num] failed") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("Bar.java:4242") >= 0, scrubbed); + } + @Test void customScrubMessageOverrideReachesRawStack() { // An app that redacts an app-specific token by overriding scrubMessage must have it redacted From fb92dc1943ec704345c6ca05afb537129097ec5f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:11:36 +0700 Subject: [PATCH 043/110] Split oversized hoist ; report legacy-interface constants; normalize ParparVM init frames Three review fixes: - Hoisting every distinct literal into one could exceed the JVM's 65535-byte method limit for a generated class with thousands of literals, so ASM threw MethodTooLargeException and aborted a hardened build whose input was valid. prependToClinit now splits a large initializer across synthetic helper methods (cutting after a PUTSTATIC, where the stack is empty) and has call them in order. Covered by oversizedInitializerIsSplitAcrossHelpers (8000 literals -> ~72KB unsplit -> now verifies, runs, and a probe value decodes). - A pre-Java-8 interface cannot host a /decoder, so its own static-final String constants were left plaintext with no record. They are now counted (getLegacyInterfaceConstantCount) and the engine turns a non-zero total into a build warning; every read of such a constant elsewhere was inlined and is still encrypted. - cn1-retrace looked up ParparVM constructor/static-initializer frames under the runtime sentinels __INIT__/__CLINIT__, but ProGuard mappings key them as /, so the frame missed its record and kept the sentinel with no line mapping. retraceAll now normalizes the sentinels before the lookup. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/HardeningEngine.java | 10 ++ .../hardening/StringEncryptTransform.java | 92 ++++++++++++++++++- .../hardening/StringEncryptTransformTest.java | 81 ++++++++++++++++ .../com/codename1/retrace/MappingFile.java | 24 ++++- .../codename1/retrace/MappingFileTest.java | 23 +++++ 5 files changed, 225 insertions(+), 5 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 93cc1c28980..3d315aebd23 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -183,6 +183,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int seed = deriveSeed(cfg, req.getBuildKey()); int encryptedStrings = 0; int concatLiterals = 0; + int legacyInterfaceConstants = 0; boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); if (stringsApplied) { // In "constants" mode, first collect the values declared as static-final String @@ -204,6 +205,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi } encryptedStrings += t.getEncryptedCount(); concatLiterals += t.getConcatLiteralCount(); + legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); } } @@ -296,6 +298,14 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "to invokedynamic (JDK 9+ javac) were not encrypted; compile with " + "-XDstringConcat=inline or an older -target to encrypt concatenation literals"); } + if (stringsApplied && legacyInterfaceConstants > 0) { + // A pre-Java-8 interface cannot host a /decoder, so its own static-final String + // constants stay plaintext in that class file. Reads elsewhere were inlined and are + // encrypted; report the declaring-interface leak rather than ship it unremarked. + result.getWarnings().add(legacyInterfaceConstants + " static-final String constant(s) on " + + "pre-Java-8 interface(s) were not encrypted (such interfaces cannot host the " + + "decoder); recompile the interface at -target 8+ to encrypt its constant pool"); + } if (req.getReportFile() != null) { writeReport(req.getReportFile(), cfg, result); } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 4e311102f0c..66c3bbde980 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -69,12 +69,23 @@ public final class StringEncryptTransform { private static final char TAG_ARG = '\u0001'; private static final char TAG_CONST = '\u0002'; + /** Synthesized initializer-chunk helpers, kept clear of any real member. */ + private static final String INIT_HELPER_PREFIX = "zqCI$"; + /** + * Cut a generated into helper methods once its initializer grows past this many + * instructions, so a class with thousands of hoisted/encrypted constants never exceeds the + * JVM's 65535-byte method limit. Each init unit is LDC -> INVOKESTATIC -> PUTSTATIC (~9 + * bytes), so this bound keeps every method well under the limit. + */ + private static final int MAX_CLINIT_INSNS = 4000; + private final boolean encryptAllStrings; private final int seed; private final ClassLoader hierarchy; private final java.util.Set constantValues; private int encryptedCount; private int concatLiteralCount; + private int legacyInterfaceConstantCount; public StringEncryptTransform(boolean encryptAllStrings, int seed) { this(encryptAllStrings, seed, null, null); @@ -137,6 +148,18 @@ public int getConcatLiteralCount() { return concatLiteralCount; } + /** + * The number of {@code static final String} constants on pre-Java-8 interfaces that this transform + * left in plaintext. Such an interface cannot host a {@code } or the decoder (both need + * class-file version 52), so its own {@code ConstantValue} attribute cannot be moved to a decode + * call. Every read of the constant elsewhere was inlined by javac to an {@code LDC} and is + * encrypted there, so the value is hidden at each use; only the declaring interface's constant pool + * still carries it. Counted and reported rather than shipped silently. + */ + public int getLegacyInterfaceConstantCount() { + return legacyInterfaceConstantCount; + } + /** Encrypts {@code classBytes}, returning the transformed bytes (or the input if nothing changed). */ public byte[] transform(byte[] classBytes) { ClassNode cn = new ClassNode(); @@ -149,6 +172,18 @@ public byte[] transform(byte[] classBytes) { // interface has no default/static method bodies to hold LDC literals anyway, so skip it whole // rather than emit a class that fails verification. if (isInterface && (cn.version & 0xFFFF) < Opcodes.V1_8) { + // A pre-Java-8 interface cannot host a /decoder, so its own static-final String + // ConstantValue attributes cannot be moved to a decode call and stay plaintext. Count the + // ones we would otherwise have encrypted so the engine reports the exclusion instead of + // silently shipping them; javac already inlined (and this pass encrypts) every read site. + if (cn.fields != null) { + for (FieldNode f : cn.fields) { + if ((f.access & Opcodes.ACC_STATIC) != 0 && (f.access & Opcodes.ACC_FINAL) != 0 + && f.value instanceof String && shouldEncryptLiteral((String) f.value)) { + legacyInterfaceConstantCount++; + } + } + } return classBytes; } @@ -377,7 +412,9 @@ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, e.getValue(), "Ljava/lang/String;")); encryptedCount++; } - prependToClinit(cn, init); + // hoistMethodLiterals runs only for a non-interface (interfaces decode per access), so the + // helper split, if it triggers, emits ordinary private static helpers. + prependToClinit(cn, init, false); return true; } @@ -407,7 +444,7 @@ private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInte } } if (changed) { - prependToClinit(cn, init); + prependToClinit(cn, init, isInterface); } return changed; } @@ -438,7 +475,56 @@ static boolean fitsConstantPool(String s) { return bytes <= 65535; } - private void prependToClinit(ClassNode cn, InsnList init) { + private void prependToClinit(ClassNode cn, InsnList init, boolean isInterface) { + // A generated class can hoist/encrypt thousands of constants; emitting every init unit into a + // single can blow past the 65535-byte method limit (ASM throws MethodTooLargeException + // even though every input method was valid). When the initializer is large, split it across + // synthetic helper methods and have call them in order. Each init unit ends with + // PUTSTATIC (stack empty), so cutting after a PUTSTATIC keeps every chunk verifiable. + if (init.size() <= MAX_CLINIT_INSNS) { + insertIntoClinit(cn, init); + return; + } + java.util.Set taken = new java.util.HashSet(); + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + taken.add(mn.name); + } + } + int access = (isInterface ? Opcodes.ACC_PUBLIC : Opcodes.ACC_PRIVATE) + | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC; + InsnList calls = new InsnList(); + InsnList chunk = new InsnList(); + int helperCounter = 0; + AbstractInsnNode insn = init.getFirst(); + while (insn != null) { + AbstractInsnNode next = insn.getNext(); + init.remove(insn); + chunk.add(insn); + boolean atBoundary = insn.getOpcode() == Opcodes.PUTSTATIC && chunk.size() >= MAX_CLINIT_INSNS; + if (atBoundary || next == null) { + String hname; + do { + hname = INIT_HELPER_PREFIX + helperCounter; + helperCounter++; + } while (taken.contains(hname)); + taken.add(hname); + MethodNode helper = new MethodNode(Opcodes.ASM9, access, hname, "()V", null, null); + helper.instructions = new InsnList(); + helper.instructions.add(chunk); + helper.instructions.add(new InsnNode(Opcodes.RETURN)); + cn.methods.add(helper); + // itf=true when the helper lives in an interface, or the JVM emits a Methodref instead + // of an InterfaceMethodref and throws IncompatibleClassChangeError at run time. + calls.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, hname, "()V", isInterface)); + chunk = new InsnList(); + } + insn = next; + } + insertIntoClinit(cn, calls); + } + + private void insertIntoClinit(ClassNode cn, InsnList init) { MethodNode clinit = null; if (cn.methods != null) { for (MethodNode mn : cn.methods) { diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index accb61fa5bb..a99b9b2c479 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -244,6 +244,87 @@ public void injectedInitializerCiphertextIsNotRewritten() throws Exception { assertEquals(b, c.getMethod("b").invoke(null)); } + @Test + public void preJava8InterfaceConstantIsCountedAsExcluded() throws Exception { + // A Java 7 interface cannot host a /decoder, so its own static-final String constant + // stays plaintext. The transform must not silently ship it: it leaves it and counts it so the + // engine can warn. + String secret = "legacy interface constant secret value"; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_7, org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_INTERFACE | org.objectweb.asm.Opcodes.ACC_ABSTRACT, + "app/LegacyIface", null, "java/lang/Object", null); + w.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "SECRET", "Ljava/lang/String;", null, secret) + .visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 55); + byte[] out = t.transform(w.toByteArray()); + assertEquals("nothing can be encrypted on a pre-Java-8 interface", 0, t.getEncryptedCount()); + assertEquals("its constant must be counted as an exclusion", 1, t.getLegacyInterfaceConstantCount()); + assertTrue("the constant is left as-is (reported, not silently dropped)", + StringEncryptTransform.containsStringLiteral(out, secret)); + } + + @Test + public void oversizedInitializerIsSplitAcrossHelpers() throws Exception { + // A generated class with enough distinct literals that a single would exceed the + // 65535-byte method limit. Hoisting must split the initializer across helper methods so the + // class still assembles, verifies and runs -- rather than throwing MethodTooLargeException. + // 8000 fields * ~9 bytes/init-unit ~= 72 KB, comfortably past the limit without the split. + int count = 8000; + String probeValue = "big_clinit_secret_literal_number_0"; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/BigClinit", null, "java/lang/Object", null); + // Spread the literals across small, individually-valid methods (each pops what it loads). + int perMethod = 200; + for (int start = 0; start < count; start += perMethod) { + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "m" + start, "()V", null, null); + m.visitCode(); + for (int i = start; i < start + perMethod && i < count; i++) { + m.visitLdcInsn("big_clinit_secret_literal_number_" + i); + m.visitInsn(org.objectweb.asm.Opcodes.POP); + } + m.visitInsn(org.objectweb.asm.Opcodes.RETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + } + // A probe that returns literal 0 so we can confirm a hoisted value decodes correctly. + addStringGetter(w, "probe", probeValue); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 4242); + byte[] out = t.transform(w.toByteArray()); + assertEquals("every distinct literal is hoisted once", count, t.getEncryptedCount()); + + // The output must verify (no MethodTooLargeException was thrown building it, and no method is + // too large or malformed). + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + + // The split actually happened: at least one synthetic initializer helper exists. + final boolean[] sawHelper = {false}; + new org.objectweb.asm.ClassReader(out).accept(new org.objectweb.asm.ClassVisitor( + org.objectweb.asm.Opcodes.ASM9) { + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, String desc, + String sig, String[] exceptions) { + if (name.startsWith("zqCI$")) { + sawHelper[0] = true; + } + return null; + } + }, org.objectweb.asm.ClassReader.SKIP_CODE); + assertTrue("the oversized initializer must be split into helper methods", sawHelper[0]); + + assertFalse(StringEncryptTransform.containsStringLiteral(out, probeValue)); + Class c = new ByteLoader().define("app.BigClinit", out); + assertEquals(probeValue, c.getMethod("probe").invoke(null)); + } + private static void addStringGetter(org.objectweb.asm.ClassWriter w, String name, String value) { org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC, name, "()Ljava/lang/String;", null, null); diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java index eb47c73f672..c707fd0011a 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -220,7 +220,12 @@ public List retraceAll(Frame obfuscated) { // synthesize .java from the retraced class instead. String file = preferredSourceFile(obfuscated.getFileName(), obfuscated.getClassName(), originalClass); - List candidates = cm.methods.get(obfuscated.getMethodName()); + // ParparVM records a constructor / static initializer under the runtime sentinel names + // __INIT__ / __CLINIT__ (BytecodeMethod), but a ProGuard mapping keys them as /. + // Normalize before the lookup, or the frame misses its method record and keeps the sentinel + // name with no line mapping. + String methodName = normalizeInitializer(obfuscated.getMethodName()); + List candidates = cm.methods.get(methodName); List out = new ArrayList(); if (candidates != null && !candidates.isEmpty()) { for (MethodMapping m : candidates) { @@ -238,11 +243,26 @@ public List retraceAll(Frame obfuscated) { } } } else { - out.add(new Frame(originalClass, obfuscated.getMethodName(), file, observed)); + out.add(new Frame(originalClass, methodName, file, observed)); } return out; } + /** + * Maps ParparVM's runtime initializer sentinels to the JVM names a ProGuard mapping uses, so a + * crash inside a constructor or static initializer resolves its method record. Any other name is + * returned unchanged. + */ + private static String normalizeInitializer(String methodName) { + if ("__INIT__".equals(methodName)) { + return ""; + } + if ("__CLINIT__".equals(methodName)) { + return ""; + } + return methodName; + } + /** Builds a frame for one method record, honoring an inlinee's own declaring class/source file. */ private Frame frameFor(MethodMapping m, String enclosingClass, String enclosingFile, int observed) { String cls = m.declaringClass != null ? m.declaringClass : enclosingClass; diff --git a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java index dd6e3a42ff7..03d4cc7b678 100644 --- a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -52,6 +52,29 @@ public void retracesClassAndMethod() throws Exception { assertEquals("MyForm.java", out.getFileName()); } + private static final String INIT_MAPPING = + "com.example.MyForm -> zqaaaa:\n" + + " 50:55:void () -> \n" + + " 70:72:void () -> \n"; + + @Test + public void normalizesParparVmConstructorSentinel() throws Exception { + // ParparVM records a constructor frame under the runtime sentinel __INIT__; the mapping keys it + // as . Without normalization the lookup misses and the frame keeps __INIT__. + MappingFile mf = MappingFile.parse(INIT_MAPPING); + Frame out = mf.retrace(new Frame("zqaaaa", "__INIT__", "zqaaaa.java", 52)); + assertEquals("com.example.MyForm", out.getClassName()); + assertEquals("", out.getMethodName()); + } + + @Test + public void normalizesParparVmStaticInitializerSentinel() throws Exception { + MappingFile mf = MappingFile.parse(INIT_MAPPING); + Frame out = mf.retrace(new Frame("zqaaaa", "__CLINIT__", "zqaaaa.java", 71)); + assertEquals("com.example.MyForm", out.getClassName()); + assertEquals("", out.getMethodName()); + } + @Test public void retracesMethodByLineRange() throws Exception { MappingFile mf = MappingFile.parse(MAPPING); From 09820153374f215b49411d3766b31610dcec12d1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:59:26 +0700 Subject: [PATCH 044/110] Validate parenthesized frame locations; reject every enableProguard value that leaves R8 off The 'at ' frame branch exempted a line from digit scrubbing whenever it merely started with 'at ' and contained parentheses, so a message wrapped onto 'at account 123456 failed (retry)' was treated as a frame and its id uploaded. hasParenLocation now requires the parenthesized content to be a real location -- ending in ':' (File.java:42, url:line:col) or the literals '(Native Method)' / '(Unknown Source)' -- so an incidental parenthetical is scrubbed while real frames keep their coordinates. Adds messageWithIncidentalParenthesesIsNotAFrame. The Android rename guard rejected only the literal 'false', but R8 minification is emitted only when android.enableProguard equals exactly 'true' (the minifyEnabled gate), so off/0/False/no disabled R8 yet slipped past and a rename profile could ship stamped rename:r8/hardened without renaming. The guard now mirrors that predicate via r8RenameRequiredButDisabled; covered by renameHardeningRejectsEveryValueThatLeavesR8Off. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 22 ++++++++++++++++- .../builders/AndroidGradleBuilder.java | 24 ++++++++++++++++--- .../AndroidGradleBuilderVersionTest.java | 15 ++++++++++++ .../crash/PiiScrubberRawStackTest.java | 15 ++++++++++++ 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index d5f15178717..10c453b3a82 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -142,11 +142,31 @@ public String scrubRawStack(String rawStack) { private static boolean isFrameLine(String line) { String t = line.trim(); if (t.startsWith("at ")) { - return (t.endsWith(")") && t.indexOf('(') >= 0) || endsWithColonNumber(t); + return hasParenLocation(t) || endsWithColonNumber(t); } return t.indexOf('@') >= 0 && endsWithLineColumn(t); } + /// True when `t` ends with a genuine parenthesized frame location, not just any parentheses: + /// `(File.java:42)` / `(url:line:col)` (content ending in `:`), or the JVM literals + /// `(Native Method)` / `(Unknown Source)`. A message wrapped onto an `at ...` line with an + /// incidental parenthetical (`at account 123456 failed (retry)`) does not match, so its digits + /// stay subject to scrubbing. + private static boolean hasParenLocation(String t) { + if (!t.endsWith(")")) { + return false; + } + int open = t.lastIndexOf('('); + if (open < 0) { + return false; + } + String inside = t.substring(open + 1, t.length() - 1); + if ("Native Method".equals(inside) || "Unknown Source".equals(inside)) { + return true; + } + return endsWithColonNumber(inside); + } + /// True when `t` ends with a `:` run (a trailing `)` allowed): the /// ParparVM frame coordinate `at .:`, and also the tail of a /// `::`. A message ending in text or a space-separated number does diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 6f80545e476..802ff2cf8a9 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -340,6 +340,18 @@ static boolean usesFcmPush(int detectedPushVersion, String messagingService, && hasFirebaseConfiguration); } + /** + * True when a rename-delivering hardening profile is requested but Android R8 -- the only renamer + * on Android -- is not enabled to deliver it. R8 minification is emitted only when + * {@code android.enableProguard} is exactly {@code "true"} (the {@code minifyEnabled} gate), so any + * other value ({@code off}, {@code 0}, {@code False}, {@code no}, ...) leaves the rename unfulfilled; + * the check must mirror that predicate, not just reject the literal {@code "false"}, or a rename + * profile would be stamped {@code rename:r8}/hardened and ship without renaming. + */ + static boolean r8RenameRequiredButDisabled(boolean renameRequested, String enableProguardArg) { + return renameRequested && !"true".equals(enableProguardArg); + } + static boolean usesHuaweiPush(int detectedPushVersion, String messagingService, boolean hasHuaweiConfiguration) { return detectedPushVersion == 3 @@ -848,10 +860,16 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc && hardenLevel != null && !"off".equalsIgnoreCase(hardenLevel.trim()) && hardenLevel.trim().length() > 0 && hardenBoolArg(request, "harden.rename", true); - if (hardenRenames && request.getArg("android.enableProguard", "true").equals("false")) { + // R8 minification is emitted only when android.enableProguard is exactly "true" (see the + // minifyEnabled gate below), so any other value -- off, 0, False, no -- leaves R8 off. Gate on + // that same predicate, not just the literal "false", or a rename profile would be stamped + // rename:r8/hardened and ship without renaming. + String enableProguard = request.getArg("android.enableProguard", "true"); + if (r8RenameRequiredButDisabled(hardenRenames, enableProguard)) { throw new BuildException("harden.level=" + hardenLevel + " requires Android's R8/ProGuard " - + "renaming, but android.enableProguard=false disables it. Enable R8, set " - + "harden.rename=false, or set harden.level=off."); + + "renaming, but android.enableProguard=" + enableProguard + " disables it (R8 runs " + + "only when android.enableProguard=true). Enable R8, set harden.rename=false, or " + + "set harden.level=off."); } if (useGradle8) { getGradleJavaHome(); // will throw build exception if JAVA17_HOME is not set diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java index bf6e1f7ee2a..8e53661bc78 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java @@ -37,6 +37,21 @@ void comparesMinorVersionsInsteadOfOnlyTheMajorVersion() { assertTrue(AndroidGradleBuilder.compareVersions("8.13.2", "8.13") > 0); } + @Test + void renameHardeningRejectsEveryValueThatLeavesR8Off() { + // R8 renames only when android.enableProguard is exactly "true"; every other value leaves it + // off. A rename profile must be rejected for all of them, not only the literal "false". + for (String off : new String[] {"false", "off", "0", "no", "False", "OFF", "", "yes"}) { + assertTrue(AndroidGradleBuilder.r8RenameRequiredButDisabled(true, off), + "rename requested + enableProguard=" + off + " must be rejected"); + } + // Exactly "true" enables R8, so a rename profile is fine. + assertFalse(AndroidGradleBuilder.r8RenameRequiredButDisabled(true, "true")); + // When rename is not requested, R8 being off is irrelevant. + assertFalse(AndroidGradleBuilder.r8RenameRequiredButDisabled(false, "off")); + assertFalse(AndroidGradleBuilder.r8RenameRequiredButDisabled(false, "true")); + } + @Test void typedPushAutoDetectsBothAndroidProviderConfigurations() { assertTrue(AndroidGradleBuilder.usesFcmPush(3, "auto", true)); diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 413689bdf6a..3fc5c8feac1 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -89,6 +89,21 @@ void messageLineStartingWithAtIsNotAFrame() { assertTrue(scrubbed.indexOf("Bar.java:4242") >= 0, scrubbed); } + @Test + void messageWithIncidentalParenthesesIsNotAFrame() { + // A message wrapped onto an "at ..." line can carry incidental parentheses that are not a frame + // location; its id must still be scrubbed. Real parenthesized locations below survive. + String stack = "java.lang.RuntimeException: bad\n" + + "at account 123456 failed (retry)\n" + + "\tat com.foo.Bar.baz(Bar.java:4242)\n" + + "\tat com.foo.Qux.run(Native Method)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account [num] failed (retry)") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("Bar.java:4242") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("(Native Method)") >= 0, scrubbed); + } + @Test void customScrubMessageOverrideReachesRawStack() { // An app that redacts an app-specific token by overriding scrubMessage must have it redacted From 76917ebbaf8296edb31b9d7269bad29935f72619 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:20:31 +0700 Subject: [PATCH 045/110] Keep the distinct watch entry class; require a release variant for Android rename hardening Mirrors two daemon fixes to the local builders: - The watch lifecycle entry class is resolved by its original FQN at runtime (CN1WatchBootstrap embeds it in cn1_watch_runtime_start), a reference the input-jar scanner can't find. A new extraKeepClasses hook on Executor (wired into writeHardeningConfig via harden.keep) keeps a distinct watchMain from renaming; IPhoneBuilder overrides it. A shared entry is the phone main class, already kept. - R8 renames only for a signed release variant (minifyEnabled lives in the release buildType), so the Android guard now also rejects rename hardening for a debug-only or certificate-less build via androidReleaseVariantBuilt, not just when android.enableProguard is off. Adds IPhoneBuilderWatchKeepTest and a release-variant case to AndroidGradleBuilderVersionTest. Co-Authored-By: Claude Opus 4.8 --- .../builders/AndroidGradleBuilder.java | 48 +++++++++-- .../java/com/codename1/builders/Executor.java | 26 ++++++ .../com/codename1/builders/IPhoneBuilder.java | 40 +++++++++ .../AndroidGradleBuilderVersionTest.java | 29 +++++++ .../builders/IPhoneBuilderWatchKeepTest.java | 85 +++++++++++++++++++ 5 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderWatchKeepTest.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 802ff2cf8a9..bccdca46268 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -352,6 +352,28 @@ static boolean r8RenameRequiredButDisabled(boolean renameRequested, String enabl return renameRequested && !"true".equals(enableProguardArg); } + /** + * True when this build will produce a signed release variant -- the only variant whose Gradle + * buildType carries {@code minifyEnabled}, and therefore the only one R8 actually renames. A + * debug-only build ({@code android.release=false} with a debug variant) or a build with no signing + * certificate runs only {@code assembleDebug}, so R8 never renames even with + * {@code android.enableProguard=true}. Mirrors the release/debug task selection. + */ + static boolean androidReleaseVariantBuilt(BuildRequest request) { + if (request.getCertificate() == null) { + return false; + } + boolean release = "true".equals(request.getArg("android.release", "true")); + boolean debug = release + ? "true".equals(request.getArg("android.debug", "false")) + : "true".equals(request.getArg("android.debug", "true")); + if (!release && !debug) { + // Neither explicitly selected: the builder falls back to building both, including release. + return true; + } + return release; + } + static boolean usesHuaweiPush(int detectedPushVersion, String messagingService, boolean hasHuaweiConfiguration) { return detectedPushVersion == 3 @@ -860,16 +882,26 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc && hardenLevel != null && !"off".equalsIgnoreCase(hardenLevel.trim()) && hardenLevel.trim().length() > 0 && hardenBoolArg(request, "harden.rename", true); - // R8 minification is emitted only when android.enableProguard is exactly "true" (see the - // minifyEnabled gate below), so any other value -- off, 0, False, no -- leaves R8 off. Gate on - // that same predicate, not just the literal "false", or a rename profile would be stamped - // rename:r8/hardened and ship without renaming. + // R8 actually renames only for a signed RELEASE variant built with minification: minifyEnabled + // lives in the release buildType and is emitted only when android.enableProguard is exactly + // "true", and a debug-only build (or one with no signing certificate) runs only assembleDebug. + // So a rename hardening profile must be rejected unless R8 will really run, not just when + // enableProguard is the literal "false" -- otherwise the APK ships stamped rename:r8/hardened + // without ever being renamed. String enableProguard = request.getArg("android.enableProguard", "true"); - if (r8RenameRequiredButDisabled(hardenRenames, enableProguard)) { + if (r8RenameRequiredButDisabled(hardenRenames, enableProguard) + || (hardenRenames && !androidReleaseVariantBuilt(request))) { + String reason = !"true".equals(enableProguard) + ? "android.enableProguard=" + enableProguard + " disables R8 (it renames only when " + + "android.enableProguard=true)" + : "this build produces no signed release variant (android.release=" + + request.getArg("android.release", "true") + ", android.debug=" + + request.getArg("android.debug", "false") + ", certificate " + + (request.getCertificate() == null ? "absent" : "present") + + "), and R8 minification applies only to the release build"; throw new BuildException("harden.level=" + hardenLevel + " requires Android's R8/ProGuard " - + "renaming, but android.enableProguard=" + enableProguard + " disables it (R8 runs " - + "only when android.enableProguard=true). Enable R8, set harden.rename=false, or " - + "set harden.level=off."); + + "renaming, but " + reason + ". Build a signed release variant with R8 enabled, set " + + "harden.rename=false, or set harden.level=off."); } if (useGradle8) { getGradleJavaHome(); // will throw build exception if JAVA17_HOME is not set diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index c675b164035..ef63f3b78a2 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2348,6 +2348,15 @@ protected String hardeningPlatform(BuildRequest request) { return "unknown"; } + /** + * Extra fully-qualified class names to keep from renaming, beyond the main class, for a slice whose + * runtime resolves a class by its ORIGINAL name in generated native code (which the input-jar + * scanner cannot discover). Empty by default; the iOS builder adds the watch lifecycle entry. + */ + protected java.util.List extraKeepClasses(BuildRequest request) { + return java.util.Collections.emptyList(); + } + /** * Java source that stamps the hardening runtime properties ({@code cn1.mappingId}, * {@code cn1.hardened}, {@code cn1.hardenLevel}) into {@code Display}, so every port's stub can @@ -2580,6 +2589,23 @@ private void writeHardeningConfig(File config, BuildRequest request) throws IOEx // (the stubs combine it with getPackageName()), so passing it bare would keep a default-package // class and let ProGuard rename the real application class out from under the generated stub. p.setProperty("cn1.mainClass", fullyQualifiedMainClass(request)); + // Keep any class a slice resolves by its original name in generated native code (e.g. the + // watch lifecycle entry embedded in cn1_watch_runtime_start), which the input-jar scanner + // cannot see. Appended as ProGuard -keep rules to harden.keep, the caller-keep channel. + java.util.List extraKeeps = extraKeepClasses(request); + if (extraKeeps != null && !extraKeeps.isEmpty()) { + StringBuilder kb = new StringBuilder(p.getProperty("harden.keep", "")); + for (String cls : extraKeeps) { + if (cls == null || cls.trim().length() == 0) { + continue; + } + if (kb.length() > 0) { + kb.append('\n'); + } + kb.append("-keep class ").append(cls.trim()).append(" { *; }"); + } + p.setProperty("harden.keep", kb.toString()); + } p.setProperty("cn1.renameSupported", Boolean.toString(hardeningRenameSupported())); // Local plugin builds are ungated: the engine is open source and a developer must be able // to reproduce a cloud failure locally. The cloud daemon sets this from the account tier. 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 f615381b388..c129f9f0f8d 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 @@ -488,6 +488,46 @@ protected String hardeningPlatform(BuildRequest request) { return "ios"; } + /** + * The watch lifecycle entry class is resolved by its ORIGINAL fully-qualified name at run time -- + * {@code CN1WatchBootstrap} embeds it in {@code cn1_watch_runtime_start("")} -- and that + * request-only string is not a reference the input-jar scanner can discover. When the watch ships a + * distinct entry class, keep it so the rename doesn't leave the watch runtime looking up a name that + * no longer exists. (A shared entry is the phone main class, already kept as {@code cn1.mainClass}; + * the tvOS target boots through the translated main-class symbol, not a by-name lookup.) + */ + @Override + protected java.util.List extraKeepClasses(BuildRequest request) { + return watchEntryKeepClasses(request); + } + + /** True when this build ships a watchOS slice (watchNative.enabled or a watchMain entry point). */ + static boolean watchTargetEnabled(BuildRequest request) { + return "true".equals(request.getArg("watchNative.enabled", "false")) + || request.getArg("watchMain", + request.getArg("watchNative.mainClass", "")).trim().length() > 0; + } + + /** The distinct watch entry class to keep (fully qualified), or empty when it shares the main class. */ + static java.util.List watchEntryKeepClasses(BuildRequest request) { + if (!watchTargetEnabled(request)) { + return java.util.Collections.emptyList(); + } + String watchMain = request.getArg("watchMain", + request.getArg("watchNative.mainClass", "")).trim(); + String main = request.getMainClass() == null ? "" : request.getMainClass().trim(); + if (watchMain.length() == 0 || watchMain.equals(main)) { + return java.util.Collections.emptyList(); + } + if (watchMain.indexOf('.') < 0) { + String pkg = request.getPackageName(); + if (pkg != null && pkg.trim().length() > 0) { + watchMain = pkg.trim() + "." + watchMain; + } + } + return java.util.Collections.singletonList(watchMain); + } + @Override public boolean build(File sourceZip, BuildRequest request) throws BuildException { // Builder instances are normally single-use, but keep scan-derived diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java index 8e53661bc78..8e75a9e9160 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java @@ -52,6 +52,35 @@ void renameHardeningRejectsEveryValueThatLeavesR8Off() { assertFalse(AndroidGradleBuilder.r8RenameRequiredButDisabled(false, "true")); } + @Test + void renameHardeningNeedsAReleaseVariantNotJustEnableProguard() { + // R8 minifyEnabled lives in the release buildType, so a debug-only build never renames even + // with the default android.enableProguard=true. + BuildRequest debugOnly = new BuildRequest(); + debugOnly.setCertificate(new byte[] {1, 2, 3}); + debugOnly.putArgument("android.release", "false"); + debugOnly.putArgument("android.debug", "true"); + assertFalse(AndroidGradleBuilder.androidReleaseVariantBuilt(debugOnly), + "android.release=false + debug builds only assembleDebug"); + + // A default (release) build with a certificate does produce a release variant. + BuildRequest release = new BuildRequest(); + release.setCertificate(new byte[] {1, 2, 3}); + assertTrue(AndroidGradleBuilder.androidReleaseVariantBuilt(release)); + + // Neither explicitly selected falls back to building both (release included). + BuildRequest both = new BuildRequest(); + both.setCertificate(new byte[] {1, 2, 3}); + both.putArgument("android.release", "false"); + both.putArgument("android.debug", "false"); + assertTrue(AndroidGradleBuilder.androidReleaseVariantBuilt(both)); + + // No signing certificate means only assembleDebug runs, so no release variant. + BuildRequest noCert = new BuildRequest(); + noCert.putArgument("android.release", "true"); + assertFalse(AndroidGradleBuilder.androidReleaseVariantBuilt(noCert)); + } + @Test void typedPushAutoDetectsBothAndroidProviderConfigurations() { assertTrue(AndroidGradleBuilder.usesFcmPush(3, "auto", true)); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderWatchKeepTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderWatchKeepTest.java new file mode 100644 index 00000000000..7aa335102b4 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderWatchKeepTest.java @@ -0,0 +1,85 @@ +/* + * 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.builders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * The watch lifecycle entry class is resolved by its original fully-qualified name at run time, so a + * distinct watchMain must be kept from renaming; a watch that shares the phone main class needs no + * extra keep (the main class is already kept). + */ +class IPhoneBuilderWatchKeepTest { + + private static BuildRequest request(String main, String pkg, String... kv) { + BuildRequest r = new BuildRequest(); + if (main != null) { + r.setMainClass(main); + } + if (pkg != null) { + r.setPackageName(pkg); + } + for (int i = 0; i < kv.length; i += 2) { + r.putArgument(kv[i], kv[i + 1]); + } + return r; + } + + @Test + void distinctFullyQualifiedWatchMainIsKept() { + BuildRequest r = request("MyApp", "com.example", + "watchNative.enabled", "true", "watchMain", "com.example.MyWatchApp"); + List keep = IPhoneBuilder.watchEntryKeepClasses(r); + assertEquals(1, keep.size()); + assertEquals("com.example.MyWatchApp", keep.get(0)); + } + + @Test + void simpleWatchMainIsQualifiedWithThePackage() { + BuildRequest r = request("MyApp", "com.example", + "watchMain", "MyWatchApp"); + List keep = IPhoneBuilder.watchEntryKeepClasses(r); + assertEquals(1, keep.size()); + assertEquals("com.example.MyWatchApp", keep.get(0)); + assertTrue(IPhoneBuilder.watchTargetEnabled(r), "a watchMain entry auto-enables the watch slice"); + } + + @Test + void watchSharingTheMainClassNeedsNoExtraKeep() { + // watchMain equals the phone main class (already kept as cn1.mainClass). + BuildRequest r = request("MyApp", "com.example", + "watchNative.enabled", "true", "watchMain", "MyApp"); + assertTrue(IPhoneBuilder.watchEntryKeepClasses(r).isEmpty()); + } + + @Test + void noWatchTargetMeansNoKeep() { + BuildRequest r = request("MyApp", "com.example"); + assertTrue(IPhoneBuilder.watchEntryKeepClasses(r).isEmpty()); + assertTrue(!IPhoneBuilder.watchTargetEnabled(r)); + } +} From 40a71a5f47469c4183311745c7c1450734388946 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:24:29 +0700 Subject: [PATCH 046/110] Report string literals left plaintext because they are too large to encrypt An aggressive/paranoid build silently left a valid ASCII literal longer than ~21,845 chars in plaintext -- its worst-case ciphertext (3 bytes/char) could overflow the 65535-byte constant pool -- while still reporting strings:all, so a large embedded credential/JSON/blob stayed readable with no notice. countOversizedLiterals now counts the distinct literals skipped for size across both channels (method LDCs selected by the mode, and static-final ConstantValues), and the engine turns a non-zero total into a build warning, matching the invokedynamic and legacy-interface exclusions. Covered by oversizedLiteralIsCountedAsExcluded. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/HardeningEngine.java | 10 +++ .../hardening/StringEncryptTransform.java | 68 +++++++++++++++++++ .../hardening/StringEncryptTransformTest.java | 26 +++++++ 3 files changed, 104 insertions(+) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 3d315aebd23..7d138689961 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -184,6 +184,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int encryptedStrings = 0; int concatLiterals = 0; int legacyInterfaceConstants = 0; + int oversizedLiterals = 0; boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); if (stringsApplied) { // In "constants" mode, first collect the values declared as static-final String @@ -206,6 +207,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi encryptedStrings += t.getEncryptedCount(); concatLiterals += t.getConcatLiteralCount(); legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); + oversizedLiterals += t.getOversizedLiteralCount(); } } @@ -306,6 +308,14 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "pre-Java-8 interface(s) were not encrypted (such interfaces cannot host the " + "decoder); recompile the interface at -target 8+ to encrypt its constant pool"); } + if (stringsApplied && oversizedLiterals > 0) { + // A literal longer than ~21,845 chars can widen to a 3-byte-per-char constant whose + // ciphertext overflows the 65535-byte constant pool, so it is left plaintext. Report it + // rather than let an strings:all build claim it encrypted everything. + result.getWarnings().add(oversizedLiterals + " string literal(s) were too large to encrypt " + + "(their ciphertext would overflow the 65535-byte constant pool) and remain in " + + "plaintext; move a large embedded secret/blob out of a string constant to hide it"); + } if (req.getReportFile() != null) { writeReport(req.getReportFile(), cfg, result); } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 66c3bbde980..822f90eeeaf 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -86,6 +86,7 @@ public final class StringEncryptTransform { private int encryptedCount; private int concatLiteralCount; private int legacyInterfaceConstantCount; + private int oversizedLiteralCount; public StringEncryptTransform(boolean encryptAllStrings, int seed) { this(encryptAllStrings, seed, null, null); @@ -160,6 +161,17 @@ public int getLegacyInterfaceConstantCount() { return legacyInterfaceConstantCount; } + /** + * The number of distinct string literals this transform would have encrypted but left in plaintext + * because their ciphertext could overflow the 65535-byte constant-pool limit (a valid ASCII literal + * longer than 21,845 characters can widen to a 3-byte-per-char modified-UTF-8 constant). A large + * embedded credential, JSON document or encoded blob therefore stays readable; counted and reported + * so an {@code strings:all} build is not believed to have encrypted everything. + */ + public int getOversizedLiteralCount() { + return oversizedLiteralCount; + } + /** Encrypts {@code classBytes}, returning the transformed bytes (or the input if nothing changed). */ public byte[] transform(byte[] classBytes) { ClassNode cn = new ClassNode(); @@ -200,6 +212,10 @@ && f.value instanceof String && shouldEncryptLiteral((String) f.value)) { // engine turns a non-zero total into a build warning so plaintext concat fragments are never // silently shipped. concatLiteralCount += countConcatLiterals(cn); + // Count the distinct literals that would be encrypted but are too large to (their ciphertext + // could overflow the constant pool), so the engine can report the exclusion rather than let an + // strings:all build claim it encrypted everything. + oversizedLiteralCount += countOversizedLiterals(cn); int base = keyBase(cn.name); boolean changed = false; @@ -334,6 +350,58 @@ private static boolean concatSiteHasLiteral(Object[] bsmArgs) { return false; } + /** + * Counts the distinct literals in {@code cn} that this transform would encrypt but skips because + * their ciphertext could overflow the constant pool (see {@link #getOversizedLiteralCount()}). + * Covers both channels: method-body {@code LDC}s selected by the current mode, and + * {@code static final String} {@code ConstantValue}s (encrypted regardless of mode). Distinct by + * value within the class, mirroring how encryption dedups. + */ + private int countOversizedLiterals(ClassNode cn) { + java.util.Set skipped = new java.util.HashSet(); + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String) { + String v = (String) ((LdcInsnNode) insn).cst; + if (isOversized(v) && modeSelectsLiteral(v)) { + skipped.add(v); + } + } + } + } + } + if (cn.fields != null) { + for (FieldNode fn : cn.fields) { + if ((fn.access & Opcodes.ACC_STATIC) != 0 && fn.value instanceof String) { + String v = (String) fn.value; + // encryptStaticFinalStrings uses shouldEncrypt (mode-independent), so any oversized + // static-final String would be skipped. + if (v.length() > 2 && isOversized(v)) { + skipped.add(v); + } + } + } + } + return skipped.size(); + } + + /** True when {@code s}'s worst-case ciphertext would overflow the 65535-byte constant-pool limit. */ + private static boolean isOversized(String s) { + return s != null && (long) s.length() * 3 > 65535; + } + + /** True when the current mode would select {@code s} for method-literal encryption (size aside). */ + private boolean modeSelectsLiteral(String s) { + if (s == null || s.length() <= 2) { + return false; + } + return encryptAllStrings || (constantValues != null && constantValues.contains(s)); + } + /** * Hoists each distinct encryptable method-body literal in {@code cn} to a synthetic static field * decoded once in {@code }, and rewrites its LDC sites to a GETSTATIC of that field. So a diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index a99b9b2c479..3d4f5c2801b 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -244,6 +244,32 @@ public void injectedInitializerCiphertextIsNotRewritten() throws Exception { assertEquals(b, c.getMethod("b").invoke(null)); } + @Test + public void oversizedLiteralIsCountedAsExcluded() throws Exception { + // A valid ASCII literal too large to encrypt (its ciphertext could overflow the constant pool) + // is left plaintext AND counted, so an strings:all build reports the exclusion. + StringBuilder big = new StringBuilder(); + for (int i = 0; i < 30000; i++) { + big.append('x'); + } + String huge = big.toString(); + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Oversized", null, "java/lang/Object", null); + addStringGetter(w, "huge", huge); + addStringGetter(w, "small", "an encryptable small secret value"); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 7); + byte[] out = t.transform(w.toByteArray()); + assertEquals("the oversized literal must be counted as excluded", 1, t.getOversizedLiteralCount()); + assertTrue("the small literal is still encrypted", t.getEncryptedCount() >= 1); + assertTrue("the oversized literal stays plaintext", + StringEncryptTransform.containsStringLiteral(out, huge)); + assertFalse("the small literal is encrypted away", + StringEncryptTransform.containsStringLiteral(out, "an encryptable small secret value")); + } + @Test public void preJava8InterfaceConstantIsCountedAsExcluded() throws Exception { // A Java 7 interface cannot host a /decoder, so its own static-final String constant From 00114d0d0d7728265d700ca48e7a32e25935d4a1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:51:50 +0700 Subject: [PATCH 047/110] Report constant-dynamic string exclusions; account for the existing when splitting - Java 11+ can carry plaintext through an LDC ConstantDynamic whose bootstrap arguments hold the string; it is resolved at link time so it can't be rewritten to a decode call. countCondyLiterals now counts constant-dynamic sites with a String bootstrap argument and the engine warns, matching the invokedynamic/legacy-interface/oversized exclusions. Covered by countsConstantDynamicStringArgumentsOnly. - The split threshold considered only the newly generated init, so a class already carrying a large could take the direct-insert path and push the combined method past the 65535-byte limit. prependToClinit now measures existing size + new init and splits on the combined total. Covered by existingLargeClinitIsSplitWhenCombinedWithNewInit. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/HardeningEngine.java | 9 +++ .../hardening/StringEncryptTransform.java | 70 +++++++++++++++++-- .../hardening/ConcatLiteralDetectionTest.java | 48 +++++++++++++ .../hardening/StringEncryptTransformTest.java | 48 +++++++++++++ 4 files changed, 170 insertions(+), 5 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 7d138689961..0436972305e 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -185,6 +185,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int concatLiterals = 0; int legacyInterfaceConstants = 0; int oversizedLiterals = 0; + int condyLiterals = 0; boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); if (stringsApplied) { // In "constants" mode, first collect the values declared as static-final String @@ -208,6 +209,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi concatLiterals += t.getConcatLiteralCount(); legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); oversizedLiterals += t.getOversizedLiteralCount(); + condyLiterals += t.getCondyLiteralCount(); } } @@ -308,6 +310,13 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "pre-Java-8 interface(s) were not encrypted (such interfaces cannot host the " + "decoder); recompile the interface at -target 8+ to encrypt its constant pool"); } + if (stringsApplied && condyLiterals > 0) { + // Java 11+ can carry plaintext through an LDC constant-dynamic whose bootstrap arguments + // hold the string; it is resolved at link time, so it cannot be rewritten to a decode call. + result.getWarnings().add(condyLiterals + " constant-dynamic (LDC ConstantDynamic) site(s) " + + "carrying string bootstrap arguments were not encrypted; such constants are " + + "resolved at link time and remain in plaintext"); + } if (stringsApplied && oversizedLiterals > 0) { // A literal longer than ~21,845 chars can widen to a 3-byte-per-char constant whose // ciphertext overflows the 65535-byte constant pool, so it is left plaintext. Report it diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 822f90eeeaf..f98ca60ec34 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -25,6 +25,7 @@ import java.util.List; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.ConstantDynamic; import org.objectweb.asm.Handle; import org.objectweb.asm.Opcodes; import org.objectweb.asm.Type; @@ -87,6 +88,7 @@ public final class StringEncryptTransform { private int concatLiteralCount; private int legacyInterfaceConstantCount; private int oversizedLiteralCount; + private int condyLiteralCount; public StringEncryptTransform(boolean encryptAllStrings, int seed) { this(encryptAllStrings, seed, null, null); @@ -172,6 +174,17 @@ public int getOversizedLiteralCount() { return oversizedLiteralCount; } + /** + * The number of {@code LDC ConstantDynamic} sites carrying a String among their bootstrap arguments + * that this transform did NOT encrypt. Java 11+ bytecode can materialize a constant through a + * {@code constant-dynamic} whose bootstrap arguments hold plaintext; those live neither in a direct + * {@code LDC "..."} nor in a field {@code ConstantValue}, and the condy is resolved at link time, so + * rewriting it to a decode call is unsafe. Counted and reported rather than shipped unremarked. + */ + public int getCondyLiteralCount() { + return condyLiteralCount; + } + /** Encrypts {@code classBytes}, returning the transformed bytes (or the input if nothing changed). */ public byte[] transform(byte[] classBytes) { ClassNode cn = new ClassNode(); @@ -212,6 +225,7 @@ && f.value instanceof String && shouldEncryptLiteral((String) f.value)) { // engine turns a non-zero total into a build warning so plaintext concat fragments are never // silently shipped. concatLiteralCount += countConcatLiterals(cn); + condyLiteralCount += countCondyLiterals(cn); // Count the distinct literals that would be encrypted but are too large to (their ciphertext // could overflow the constant pool), so the engine can report the exclusion rather than let an // strings:all build claim it encrypted everything. @@ -350,6 +364,42 @@ private static boolean concatSiteHasLiteral(Object[] bsmArgs) { return false; } + /** + * Counts the {@code LDC ConstantDynamic} sites in {@code cn} whose bootstrap arguments include a + * String -- Java 11+ can carry plaintext through a constant-dynamic (e.g. an enum switch map or an + * explicit-condy compiler), which no {@code LDC}/{@code ConstantValue} pass reaches. Counts the site + * once when it bears any String argument. + */ + private static int countCondyLiterals(ClassNode cn) { + if (cn.methods == null) { + return 0; + } + int count = 0; + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof ConstantDynamic) { + if (condyHasStringArgument((ConstantDynamic) ((LdcInsnNode) insn).cst)) { + count++; + } + } + } + } + return count; + } + + /** True when a constant-dynamic carries a String among its bootstrap arguments. */ + private static boolean condyHasStringArgument(ConstantDynamic condy) { + for (int i = 0, n = condy.getBootstrapMethodArgumentCount(); i < n; i++) { + if (condy.getBootstrapMethodArgument(i) instanceof String) { + return true; + } + } + return false; + } + /** * Counts the distinct literals in {@code cn} that this transform would encrypt but skips because * their ciphertext could overflow the constant pool (see {@link #getOversizedLiteralCount()}). @@ -549,7 +599,13 @@ private void prependToClinit(ClassNode cn, InsnList init, boolean isInterface) { // even though every input method was valid). When the initializer is large, split it across // synthetic helper methods and have call them in order. Each init unit ends with // PUTSTATIC (stack empty), so cutting after a PUTSTATIC keeps every chunk verifiable. - if (init.size() <= MAX_CLINIT_INSNS) { + // + // Measure the COMBINED size -- the class may already carry a large , so even a small + // new initializer inserted directly could push the existing method over the limit. When the + // total is under the bound, insert directly; otherwise split so only gains a few calls. + MethodNode existingClinit = findClinit(cn); + int existingSize = existingClinit == null ? 0 : existingClinit.instructions.size(); + if (existingSize + init.size() <= MAX_CLINIT_INSNS) { insertIntoClinit(cn, init); return; } @@ -592,16 +648,20 @@ private void prependToClinit(ClassNode cn, InsnList init, boolean isInterface) { insertIntoClinit(cn, calls); } - private void insertIntoClinit(ClassNode cn, InsnList init) { - MethodNode clinit = null; + /** The class's existing {@code }, or {@code null} if it has none. */ + private static MethodNode findClinit(ClassNode cn) { if (cn.methods != null) { for (MethodNode mn : cn.methods) { if ("".equals(mn.name) && "()V".equals(mn.desc)) { - clinit = mn; - break; + return mn; } } } + return null; + } + + private void insertIntoClinit(ClassNode cn, InsnList init) { + MethodNode clinit = findClinit(cn); if (clinit == null) { clinit = new MethodNode(Opcodes.ASM9, Opcodes.ACC_STATIC, "", "()V", null, null); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java index ab2ce0f5bbb..c8c45aef119 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java @@ -26,6 +26,7 @@ import org.junit.Test; import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.ConstantDynamic; import org.objectweb.asm.Handle; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Opcodes; @@ -107,4 +108,51 @@ public void countsLiteralBearingConcatSitesOnly() { // withInlineLiteral (recipe literal) + withConstantArg (String bootstrap arg) = 2; pureDynamic = 0. assertEquals(2, t.getConcatLiteralCount()); } + + private static final Handle CONDY_BSM = new Handle( + Opcodes.H_INVOKESTATIC, + "app/CondyBootstrap", + "make", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;" + + "Ljava/lang/Object;)Ljava/lang/Object;", + false); + + /** A class with one string-bearing constant-dynamic LDC and one with only a numeric argument. */ + private static byte[] condyFixture() { + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, "com/codename1/hardening/fixture/Condy", + null, "java/lang/Object", null); + + // LDC ConstantDynamic whose bootstrap argument carries plaintext. + ConstantDynamic withString = new ConstantDynamic("secretConst", "Ljava/lang/String;", + CONDY_BSM, "secret-plaintext-value"); + MethodVisitor m1 = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "withString", + "()Ljava/lang/String;", null, null); + m1.visitCode(); + m1.visitLdcInsn(withString); + m1.visitInsn(Opcodes.ARETURN); + m1.visitMaxs(1, 0); + m1.visitEnd(); + + // A constant-dynamic with only a numeric bootstrap argument: no plaintext, not counted. + ConstantDynamic numeric = new ConstantDynamic("numConst", "I", + CONDY_BSM, Integer.valueOf(7)); + MethodVisitor m2 = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "numeric", + "()I", null, null); + m2.visitCode(); + m2.visitLdcInsn(numeric); + m2.visitInsn(Opcodes.IRETURN); + m2.visitMaxs(1, 0); + m2.visitEnd(); + + cw.visitEnd(); + return cw.toByteArray(); + } + + @Test + public void countsConstantDynamicStringArgumentsOnly() { + StringEncryptTransform t = new StringEncryptTransform(true, 7); + t.transform(condyFixture()); + assertEquals(1, t.getCondyLiteralCount()); + } } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 3d4f5c2801b..6b84420c157 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -270,6 +270,54 @@ public void oversizedLiteralIsCountedAsExcluded() throws Exception { StringEncryptTransform.containsStringLiteral(out, "an encryptable small secret value")); } + @Test + public void existingLargeClinitIsSplitWhenCombinedWithNewInit() throws Exception { + // A class that already carries a large must not have a new initializer inserted + // directly (which could push the combined method over the 65535-byte limit): the split + // decision accounts for the existing initializer, so even one small hoisted literal triggers a + // helper split when the class's is already large. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/BigExistingClinit", null, "java/lang/Object", null); + // An existing with more instructions than the split threshold, but each a harmless + // stack-neutral ICONST_0/POP pair. + org.objectweb.asm.MethodVisitor clinit = w.visitMethod(org.objectweb.asm.Opcodes.ACC_STATIC, + "", "()V", null, null); + clinit.visitCode(); + for (int i = 0; i < 5000; i++) { + clinit.visitInsn(org.objectweb.asm.Opcodes.ICONST_0); + clinit.visitInsn(org.objectweb.asm.Opcodes.POP); + } + clinit.visitInsn(org.objectweb.asm.Opcodes.RETURN); + clinit.visitMaxs(1, 0); + clinit.visitEnd(); + // One encryptable literal, whose hoisted initializer would otherwise be inserted directly. + addStringGetter(w, "probe", "a small hoisted secret literal value"); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 13); + byte[] out = t.transform(w.toByteArray()); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + + final boolean[] sawHelper = {false}; + new org.objectweb.asm.ClassReader(out).accept(new org.objectweb.asm.ClassVisitor( + org.objectweb.asm.Opcodes.ASM9) { + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, String desc, + String sig, String[] exceptions) { + if (name.startsWith("zqCI$")) { + sawHelper[0] = true; + } + return null; + } + }, org.objectweb.asm.ClassReader.SKIP_CODE); + assertTrue("a large existing must push the new init into a helper", sawHelper[0]); + + Class c = new ByteLoader().define("app.BigExistingClinit", out); + assertEquals("a small hoisted secret literal value", c.getMethod("probe").invoke(null)); + } + @Test public void preJava8InterfaceConstantIsCountedAsExcluded() throws Exception { // A Java 7 interface cannot host a /decoder, so its own static-final String constant From 13ec40db36c925c76c446cf141bbad9c5c6be6c9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:09:46 +0700 Subject: [PATCH 048/110] Measure encoded bytecode before growing a method; skip and report when a transform will not fit Round 44 fixed the common overflow but used InsnList node COUNT, which cannot prove the encoded Code stays under the 65535-byte limit, and control-flow guards had the same exposure. Adds a conservative upper-bound byte estimator (MethodSize) used by both transforms: - ControlFlowTransform skips a method whose estimated size plus the guard(s) would exceed the safe bound (reported via getOversizedMethods) instead of aborting the build with MethodTooLargeException. - StringEncryptTransform now decides the split on encoded bytes, and pre-checks clinitCanAccept BEFORE mutating: a class whose is so near the limit that not even the helper calls fit is left untouched (its literals stay plaintext, reported via getClinitFullLiteralCount) rather than half-transformed. hoist/static-final both build the init first, then commit only if it fits. The engine turns both new counts into build warnings. Covered by oversizedMethodIsSkippedNotOverflowed, existingLargeClinitIsSplitWhenCombinedWithNewInit (rewritten for byte accounting), and clinitTooFullLeavesLiteralPlaintextAndReports. Co-Authored-By: Claude Opus 4.8 --- .../hardening/ControlFlowTransform.java | 20 +++ .../codename1/hardening/HardeningEngine.java | 14 ++ .../com/codename1/hardening/MethodSize.java | 105 +++++++++++++++ .../hardening/StringEncryptTransform.java | 124 +++++++++++++----- .../hardening/ControlFlowTransformTest.java | 41 ++++++ .../hardening/StringEncryptTransformTest.java | 70 ++++++---- 6 files changed, 318 insertions(+), 56 deletions(-) create mode 100644 maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java index 592f556140f..e926128cef7 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java @@ -57,9 +57,13 @@ public final class ControlFlowTransform { static final String GUARD_FIELD = "zq$cf"; static final String GUARD_DESC = "I"; + /** Widest encoding of one entry guard (GETSTATIC, IFGT, NEW, DUP, INVOKESPECIAL, ATHROW). */ + private static final int GUARD_BYTES = 16; + private final ClassLoader hierarchy; private final int intensity; private int guardedMethods; + private int oversizedMethods; public ControlFlowTransform() { this(null, 1); @@ -83,6 +87,15 @@ public int getGuardedMethods() { return guardedMethods; } + /** + * Methods left unguarded because they are already so close to the 65,535-byte method limit that + * prepending the guard(s) would overflow them. Skipped rather than aborting the build; reported so + * a paranoid build knows a large generated method kept its plain control flow. + */ + public int getOversizedMethods() { + return oversizedMethods; + } + public byte[] transform(byte[] classBytes) { ClassNode cn = new ClassNode(); new ClassReader(classBytes).accept(cn, ClassReader.SKIP_FRAMES); @@ -102,6 +115,13 @@ public byte[] transform(byte[] classBytes) { if (!isGuardable(mn)) { continue; } + // A generated method can already be near the 65,535-byte limit; prepending guards would + // overflow it and make ASM abort the whole build. Skip (and report) such a method rather + // than fail on a valid input class. + if (MethodSize.estimateBytes(mn) + GUARD_BYTES * intensity > MethodSize.SAFE_LIMIT) { + oversizedMethods++; + continue; + } for (int i = 0; i < intensity; i++) { prependGuard(cn, mn, guardField); } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 0436972305e..6c74b7b360e 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -186,6 +186,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int legacyInterfaceConstants = 0; int oversizedLiterals = 0; int condyLiterals = 0; + int clinitFullLiterals = 0; boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); if (stringsApplied) { // In "constants" mode, first collect the values declared as static-final String @@ -210,10 +211,12 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); oversizedLiterals += t.getOversizedLiteralCount(); condyLiterals += t.getCondyLiteralCount(); + clinitFullLiterals += t.getClinitFullLiteralCount(); } } int guardedMethods = 0; + int oversizedGuardMethods = 0; boolean controlFlowApplied = cfg.isControlFlow() && controlFlowSafeFor(cfg.getPlatform()); if (controlFlowApplied) { for (Map.Entry e : renamed.entrySet()) { @@ -222,6 +225,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi if (out != e.getValue()) { e.setValue(out); } + oversizedGuardMethods += t.getOversizedMethods(); guardedMethods += t.getGuardedMethods(); } } @@ -325,6 +329,16 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "(their ciphertext would overflow the 65535-byte constant pool) and remain in " + "plaintext; move a large embedded secret/blob out of a string constant to hide it"); } + if (stringsApplied && clinitFullLiterals > 0) { + result.getWarnings().add(clinitFullLiterals + " string literal(s) were left in plaintext " + + "because the class's static initializer is already near the 65535-byte method " + + "limit and could not hold the decode step"); + } + if (controlFlowApplied && oversizedGuardMethods > 0) { + result.getWarnings().add(oversizedGuardMethods + " method(s) were left with plain control " + + "flow because they are already near the 65535-byte method limit and adding the " + + "guard would overflow them"); + } if (req.getReportFile() != null) { writeReport(req.getReportFile(), cfg, result); } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java new file mode 100644 index 00000000000..6cc904fc71c --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java @@ -0,0 +1,105 @@ +/* + * 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.hardening; + +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.LookupSwitchInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.TableSwitchInsnNode; + +/** + * Conservative UPPER-BOUND estimate of a method's encoded {@code Code} size in bytes, so a transform + * can refuse to grow a method past the JVM's 65,535-byte limit (which makes ASM throw + * {@code MethodTooLargeException} at write time and abort an otherwise-valid build). Every instruction + * is charged its widest legal encoding, so the estimate never undercounts -- a transform that stops + * short of {@link #SAFE_LIMIT} is safe even though ASM might have squeezed a little more in. + */ +final class MethodSize { + + /** The hard JVM limit on a method's bytecode array. */ + static final int LIMIT = 65535; + /** + * The size a transform must stay under. Below {@link #LIMIT} by a margin that absorbs both the + * upper-bound estimate's slack and the fact that the real limit is on the emitted bytes, which + * COMPUTE_MAXS/FRAMES can shift slightly. + */ + static final int SAFE_LIMIT = 60000; + + private MethodSize() { + } + + /** Upper-bound encoded byte size of {@code m}'s instructions, or 0 when it has none. */ + static int estimateBytes(MethodNode m) { + return m == null || m.instructions == null ? 0 : estimateBytes(m.instructions); + } + + /** Upper-bound encoded byte size of an instruction list. */ + static int estimateBytes(InsnList insns) { + int total = 0; + for (AbstractInsnNode n = insns.getFirst(); n != null; n = n.getNext()) { + total += estimateBytes(n); + } + return total; + } + + /** Upper-bound encoded byte size of a single instruction node (0 for labels/line/frame metadata). */ + static int estimateBytes(AbstractInsnNode n) { + switch (n.getType()) { + case AbstractInsnNode.LABEL: + case AbstractInsnNode.LINE: + case AbstractInsnNode.FRAME: + return 0; + case AbstractInsnNode.INSN: + return 1; + case AbstractInsnNode.INT_INSN: + return 3; + case AbstractInsnNode.VAR_INSN: + return 4; + case AbstractInsnNode.TYPE_INSN: + return 3; + case AbstractInsnNode.FIELD_INSN: + return 3; + case AbstractInsnNode.METHOD_INSN: + return 5; + case AbstractInsnNode.INVOKE_DYNAMIC_INSN: + return 5; + case AbstractInsnNode.JUMP_INSN: + return 5; + case AbstractInsnNode.LDC_INSN: + return 3; + case AbstractInsnNode.IINC_INSN: + return 6; + case AbstractInsnNode.TABLESWITCH_INSN: + // opcode + up to 3 pad + default/low/high (12) + one 4-byte offset per case. + return 16 + ((TableSwitchInsnNode) n).labels.size() * 4; + case AbstractInsnNode.LOOKUPSWITCH_INSN: + // opcode + up to 3 pad + default/npairs (8) + one 8-byte (match,offset) per case. + return 12 + ((LookupSwitchInsnNode) n).keys.size() * 8; + case AbstractInsnNode.MULTIANEWARRAY_INSN: + return 4; + default: + return 4; + } + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index f98ca60ec34..5212d0c9740 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -73,12 +73,13 @@ public final class StringEncryptTransform { /** Synthesized initializer-chunk helpers, kept clear of any real member. */ private static final String INIT_HELPER_PREFIX = "zqCI$"; /** - * Cut a generated into helper methods once its initializer grows past this many - * instructions, so a class with thousands of hoisted/encrypted constants never exceeds the - * JVM's 65535-byte method limit. Each init unit is LDC -> INVOKESTATIC -> PUTSTATIC (~9 - * bytes), so this bound keeps every method well under the limit. + * Cut a generated into helper methods once a chunk grows past this many ENCODED bytes, so + * a class with thousands of hoisted/encrypted constants never exceeds the JVM's 65535-byte method + * limit. Kept well under MethodSize.SAFE_LIMIT so each helper fits. */ - private static final int MAX_CLINIT_INSNS = 4000; + private static final int MAX_CLINIT_CHUNK_BYTES = 48000; + /** Widest encoding of one INVOKESTATIC helper call prepended to . */ + private static final int CLINIT_CALL_BYTES = 5; private final boolean encryptAllStrings; private final int seed; @@ -89,6 +90,7 @@ public final class StringEncryptTransform { private int legacyInterfaceConstantCount; private int oversizedLiteralCount; private int condyLiteralCount; + private int clinitFullLiteralCount; public StringEncryptTransform(boolean encryptAllStrings, int seed) { this(encryptAllStrings, seed, null, null); @@ -185,6 +187,16 @@ public int getCondyLiteralCount() { return condyLiteralCount; } + /** + * The number of literals left plaintext because the class's {@code } is already so close to + * the 65,535-byte method limit that even the split path's helper calls would not fit. Extremely + * rare (a class whose static initializer is itself near the limit), but reported rather than + * aborting the build so a valid input class is never rejected. + */ + public int getClinitFullLiteralCount() { + return clinitFullLiteralCount; + } + /** Encrypts {@code classBytes}, returning the transformed bytes (or the input if nothing changed). */ public byte[] transform(byte[] classBytes) { ClassNode cn = new ClassNode(); @@ -494,11 +506,24 @@ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) if (valueToField.isEmpty()) { return false; } - // 2. Replace each LDC of a hoisted value with a GETSTATIC of its field, in the ORIGINAL method - // bodies. This happens BEFORE the initializer is inserted, so the initializer's own - // ciphertext LDCs are never rescanned -- otherwise a ciphertext that happens to equal - // another hoisted plaintext (the XOR encoding is involutive) would be rewritten into a read - // of a not-yet-assigned field and pass null to the decoder. + // 2. Build the initializer BEFORE mutating anything, so a class whose cannot + // accommodate it is left untouched (its literals stay plaintext, reported) rather than + // half-transformed with fields that are declared but never initialized. + InsnList init = new InsnList(); + for (java.util.Map.Entry e : valueToField.entrySet()) { + init.add(new LdcInsnNode(encode(e.getKey(), base))); + init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, false)); + init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, e.getValue(), "Ljava/lang/String;")); + } + if (!clinitCanAccept(cn, init)) { + clinitFullLiteralCount += valueToField.size(); + return false; + } + // 3. Commit. Replace each LDC of a hoisted value with a GETSTATIC of its field in the ORIGINAL + // method bodies; the standalone init is inserted into only afterwards, so its own + // ciphertext LDCs are never rescanned -- otherwise a ciphertext that happens to equal another + // hoisted plaintext (the XOR encoding is involutive) would be rewritten into a read of a + // not-yet-assigned field and pass null to the decoder. for (MethodNode mn : cn.methods) { if (mn.instructions == null || decoderName.equals(mn.name)) { continue; @@ -516,18 +541,14 @@ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) insn = next; } } - // 3. Add a field per value and decode it once in , prepended so it runs before the - // original body (a hoisted value used within reads the already-initialized field). + // Add a field per value; the init decodes each once in , prepended so it runs before + // the original body (a hoisted value used within reads the already-initialized field). if (cn.fields == null) { cn.fields = new java.util.ArrayList(); } - InsnList init = new InsnList(); - for (java.util.Map.Entry e : valueToField.entrySet()) { + for (String field : valueToField.values()) { cn.fields.add(new FieldNode(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC, - e.getValue(), "Ljava/lang/String;", null, null)); - init.add(new LdcInsnNode(encode(e.getKey(), base))); - init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, false)); - init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, e.getValue(), "Ljava/lang/String;")); + field, "Ljava/lang/String;", null, null)); encryptedCount++; } // hoistMethodLiterals runs only for a non-interface (interfaces decode per access), so the @@ -541,30 +562,40 @@ private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInte if (cn.fields == null) { return false; } + // Build the initializer and remember which fields to strip WITHOUT mutating yet, so a class + // whose cannot accommodate the init keeps its constants (plaintext, reported) rather + // than being left with fields whose ConstantValue was stripped but never re-initialized. InsnList init = new InsnList(); - boolean changed = false; + java.util.List toStrip = new java.util.ArrayList(); for (FieldNode fn : cn.fields) { boolean isStatic = (fn.access & Opcodes.ACC_STATIC) != 0; if (isStatic && fn.value instanceof String && shouldEncrypt((String) fn.value)) { String plain = (String) fn.value; // shouldEncrypt already rejected any value whose ciphertext could overflow the // constant pool (class-independent bound), so the encode result fits. - // Strip the ConstantValue so the plaintext leaves the class file entirely - // (this is the slot ParparVM would otherwise dump into the C constant pool). - fn.value = null; init.add(new LdcInsnNode(encode(plain, base))); // itf=true when the decoder lives in an interface, else the JVM emits a Methodref // instead of an InterfaceMethodref and throws IncompatibleClassChangeError. init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, isInterface)); init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, fn.name, fn.desc)); - encryptedCount++; - changed = true; + toStrip.add(fn); } } - if (changed) { - prependToClinit(cn, init, isInterface); + if (toStrip.isEmpty()) { + return false; } - return changed; + if (!clinitCanAccept(cn, init)) { + clinitFullLiteralCount += toStrip.size(); + return false; + } + // Commit: strip each ConstantValue so the plaintext leaves the class file entirely (the slot + // ParparVM would otherwise dump into the C constant pool), and decode it once in . + for (FieldNode fn : toStrip) { + fn.value = null; + encryptedCount++; + } + prependToClinit(cn, init, isInterface); + return true; } /** @@ -600,12 +631,13 @@ private void prependToClinit(ClassNode cn, InsnList init, boolean isInterface) { // synthetic helper methods and have call them in order. Each init unit ends with // PUTSTATIC (stack empty), so cutting after a PUTSTATIC keeps every chunk verifiable. // - // Measure the COMBINED size -- the class may already carry a large , so even a small - // new initializer inserted directly could push the existing method over the limit. When the - // total is under the bound, insert directly; otherwise split so only gains a few calls. - MethodNode existingClinit = findClinit(cn); - int existingSize = existingClinit == null ? 0 : existingClinit.instructions.size(); - if (existingSize + init.size() <= MAX_CLINIT_INSNS) { + // Measure the COMBINED ENCODED bytes -- the class may already carry a large , so even a + // small new initializer inserted directly could push the existing method over the limit, and a + // node count cannot prove the direct-insert path fits. When the total is under the bound, insert + // directly; otherwise split so only gains a few calls. Callers pre-check clinitCanAccept + // before mutating, so the split is only reached when the calls themselves fit. + int existingBytes = MethodSize.estimateBytes(findClinit(cn)); + if (existingBytes + MethodSize.estimateBytes(init) <= MethodSize.SAFE_LIMIT) { insertIntoClinit(cn, init); return; } @@ -619,13 +651,15 @@ private void prependToClinit(ClassNode cn, InsnList init, boolean isInterface) { | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC; InsnList calls = new InsnList(); InsnList chunk = new InsnList(); + int chunkBytes = 0; int helperCounter = 0; AbstractInsnNode insn = init.getFirst(); while (insn != null) { AbstractInsnNode next = insn.getNext(); init.remove(insn); + chunkBytes += MethodSize.estimateBytes(insn); chunk.add(insn); - boolean atBoundary = insn.getOpcode() == Opcodes.PUTSTATIC && chunk.size() >= MAX_CLINIT_INSNS; + boolean atBoundary = insn.getOpcode() == Opcodes.PUTSTATIC && chunkBytes >= MAX_CLINIT_CHUNK_BYTES; if (atBoundary || next == null) { String hname; do { @@ -642,12 +676,34 @@ private void prependToClinit(ClassNode cn, InsnList init, boolean isInterface) { // of an InterfaceMethodref and throws IncompatibleClassChangeError at run time. calls.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, hname, "()V", isInterface)); chunk = new InsnList(); + chunkBytes = 0; } insn = next; } insertIntoClinit(cn, calls); } + /** + * Whether {@code prependToClinit} can add {@code init} without pushing {@code } past the + * method limit. A direct insert must fit under {@link MethodSize#SAFE_LIMIT}; otherwise the split + * moves {@code init} into helper methods and {@code } only gains one call per chunk, so the + * check is whether the existing initializer plus those calls fits. Returns false only in the extreme + * case that even the calls do not fit -- then the caller leaves the literals plaintext rather than + * mutating a class it cannot finish. + */ + private boolean clinitCanAccept(ClassNode cn, InsnList init) { + int existingBytes = MethodSize.estimateBytes(findClinit(cn)); + int initBytes = MethodSize.estimateBytes(init); + if (existingBytes + initBytes <= MethodSize.SAFE_LIMIT) { + return true; + } + int chunks = (initBytes + MAX_CLINIT_CHUNK_BYTES - 1) / MAX_CLINIT_CHUNK_BYTES; + if (chunks < 1) { + chunks = 1; + } + return existingBytes + chunks * CLINIT_CALL_BYTES <= MethodSize.SAFE_LIMIT; + } + /** The class's existing {@code }, or {@code null} if it has none. */ private static MethodNode findClinit(ClassNode cn) { if (cn.methods != null) { diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java index a19a611e3f8..8dc3717a665 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java @@ -107,6 +107,47 @@ public void guardsAClassThatAlreadyDeclaresAGuardFieldName() throws Exception { assertEquals(5, c.getMethod("add", int.class, int.class).invoke(null, 2, 3)); } + @Test + public void oversizedMethodIsSkippedNotOverflowed() throws Exception { + // A method already near the 65535-byte limit cannot take a guard without overflowing. It must + // be skipped (and reported), while a normal sibling method is still guarded -- the build must + // not abort on a valid input class. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/BigMethod", null, "java/lang/Object", null); + // ~62 KB of harmless ICONST_0/POP filler: too large to accept a guard. + org.objectweb.asm.MethodVisitor big = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "big", "()V", null, null); + big.visitCode(); + for (int i = 0; i < 31000; i++) { + big.visitInsn(org.objectweb.asm.Opcodes.ICONST_0); + big.visitInsn(org.objectweb.asm.Opcodes.POP); + } + big.visitInsn(org.objectweb.asm.Opcodes.RETURN); + big.visitMaxs(1, 0); + big.visitEnd(); + // A normal method that can and must still be guarded. + org.objectweb.asm.MethodVisitor add = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "add", "(II)I", null, null); + add.visitCode(); + add.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 0); + add.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 1); + add.visitInsn(org.objectweb.asm.Opcodes.IADD); + add.visitInsn(org.objectweb.asm.Opcodes.IRETURN); + add.visitMaxs(2, 2); + add.visitEnd(); + w.visitEnd(); + + ControlFlowTransform t = new ControlFlowTransform(); + byte[] out = t.transform(w.toByteArray()); + assertEquals("the near-limit method must be skipped", 1, t.getOversizedMethods()); + assertTrue("the normal method must still be guarded", t.getGuardedMethods() >= 1); + CheckClassAdapter.verify(new ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.BigMethod", out); + assertEquals(5, c.getMethod("add", int.class, int.class).invoke(null, 2, 3)); + } + // Renames the class internal name so the intense variant can load beside the plain one. private static byte[] rename(byte[] bytes, String from, String to) { org.objectweb.asm.ClassReader cr = new org.objectweb.asm.ClassReader(bytes); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 6b84420c157..be9727da283 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -270,52 +270,78 @@ public void oversizedLiteralIsCountedAsExcluded() throws Exception { StringEncryptTransform.containsStringLiteral(out, "an encryptable small secret value")); } - @Test - public void existingLargeClinitIsSplitWhenCombinedWithNewInit() throws Exception { - // A class that already carries a large must not have a new initializer inserted - // directly (which could push the combined method over the 65535-byte limit): the split - // decision accounts for the existing initializer, so even one small hoisted literal triggers a - // helper split when the class's is already large. + /** Builds a class whose existing is padByteApprox bytes of harmless ICONST_0/POP filler. */ + private static org.objectweb.asm.ClassWriter classWithBigClinit(String owner, int padPairs) { org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, - "app/BigExistingClinit", null, "java/lang/Object", null); - // An existing with more instructions than the split threshold, but each a harmless - // stack-neutral ICONST_0/POP pair. + owner, null, "java/lang/Object", null); org.objectweb.asm.MethodVisitor clinit = w.visitMethod(org.objectweb.asm.Opcodes.ACC_STATIC, "", "()V", null, null); clinit.visitCode(); - for (int i = 0; i < 5000; i++) { + for (int i = 0; i < padPairs; i++) { clinit.visitInsn(org.objectweb.asm.Opcodes.ICONST_0); clinit.visitInsn(org.objectweb.asm.Opcodes.POP); } clinit.visitInsn(org.objectweb.asm.Opcodes.RETURN); clinit.visitMaxs(1, 0); clinit.visitEnd(); - // One encryptable literal, whose hoisted initializer would otherwise be inserted directly. - addStringGetter(w, "probe", "a small hoisted secret literal value"); - w.visitEnd(); - - StringEncryptTransform t = new StringEncryptTransform(true, 13); - byte[] out = t.transform(w.toByteArray()); - CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, - new java.io.PrintWriter(new java.io.StringWriter())); + return w; + } - final boolean[] sawHelper = {false}; + private static boolean hasHelper(byte[] out) { + final boolean[] saw = {false}; new org.objectweb.asm.ClassReader(out).accept(new org.objectweb.asm.ClassVisitor( org.objectweb.asm.Opcodes.ASM9) { @Override public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] exceptions) { if (name.startsWith("zqCI$")) { - sawHelper[0] = true; + saw[0] = true; } return null; } }, org.objectweb.asm.ClassReader.SKIP_CODE); - assertTrue("a large existing must push the new init into a helper", sawHelper[0]); + return saw[0]; + } + @Test + public void existingLargeClinitIsSplitWhenCombinedWithNewInit() throws Exception { + // A small new initializer that would fit on its own is still split into a helper when the class + // already carries a near the 65535-byte limit -- the split decision measures the + // COMBINED encoded size, not just the new init. ~59.6 KB of existing + a handful of + // literals exceeds the safe bound, so the new init must move into a helper. + org.objectweb.asm.ClassWriter w = classWithBigClinit("app/BigExistingClinit", 29800); + for (int i = 0; i < 40; i++) { + addStringGetter(w, "probe" + i, "a hoisted secret literal value number " + i); + } + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 13); + byte[] out = t.transform(w.toByteArray()); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + assertTrue("a large existing must push the new init into a helper", hasHelper(out)); + assertEquals(40, t.getEncryptedCount()); Class c = new ByteLoader().define("app.BigExistingClinit", out); - assertEquals("a small hoisted secret literal value", c.getMethod("probe").invoke(null)); + assertEquals("a hoisted secret literal value number 0", c.getMethod("probe0").invoke(null)); + } + + @Test + public void clinitTooFullLeavesLiteralPlaintextAndReports() throws Exception { + // The existing is so close to the limit that not even a helper CALL would fit, so the + // literal cannot be hoisted: it is left plaintext and counted, rather than aborting the build. + org.objectweb.asm.ClassWriter w = classWithBigClinit("app/FullClinit", 31500); // ~63 KB + addStringGetter(w, "probe", "a literal that cannot be hoisted here"); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 5); + byte[] out = t.transform(w.toByteArray()); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + assertEquals("the un-hoistable literal is reported", 1, t.getClinitFullLiteralCount()); + assertEquals("nothing was encrypted for this class", 0, t.getEncryptedCount()); + assertTrue("the literal stays plaintext rather than the build aborting", + StringEncryptTransform.containsStringLiteral(out, "a literal that cannot be hoisted here")); } @Test From 2461ea0376816f4c53b64690ea3efbb918131e37 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:32:22 +0700 Subject: [PATCH 049/110] Require a whitespace-free frame identity; size-check the guard-field clinit setup - isFrameLine still classified a wrapped message like 'at account 123456 failed:789' (or '...failed (token:789)') as a frame because it ended in a colon-number/parenthesized location. It now parses the full grammar: after 'at ', a single whitespace-free identity (., a JS ref, or a URL) followed by a real location. A message continuation has spaces in its identity, so its id is scrubbed; real JVM/ParparVM/Chrome/Firefox frames keep their coordinates. Covered by messageWithAtPrefixAndColonNumberIsNotAFrame. - ControlFlowTransform's round-45 method-size check did not cover the guard-field setup it prepends to (isGuardable skips ). A class whose is already near the limit could overflow it, and an uninitialized guard field is 0 -- making every guard throw. It now checks the size before guarding and skips the whole class (reporting the methods left plain) when the setup cannot fit. Covered by classWithNearFullClinitIsSkippedNotOverflowed. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 83 ++++++++++++++----- .../hardening/ControlFlowTransform.java | 34 ++++++++ .../hardening/ControlFlowTransformTest.java | 39 +++++++++ .../crash/PiiScrubberRawStackTest.java | 16 ++++ 4 files changed, 152 insertions(+), 20 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index 10c453b3a82..6131471c1bb 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -132,39 +132,82 @@ public String scrubRawStack(String rawStack) { /// form (which every V8/Chrome JavaScript frame also uses), and the /// Firefox/Safari `fn@url:line:column` form. /// - /// Both forms require an actual frame location, not just the leading token: a + /// A frame requires the full grammar, not just the leading token and some digits: a /// message can wrap onto a line that begins with `at ` (`printStackTrace` puts - /// `at account 123456 failed` on its own line) or that merely mentions a file, - /// and its id must still be scrubbed. So the `at ` form must carry a - /// parenthesized location (`(File.java:42)`, `(url:line:col)`, `(Native Method)`) - /// or a bare trailing `:` (ParparVM), and the `@` form must carry an `@` - /// and a terminal `::`. + /// `at account 123456 failed:789` on its own line) and its id must still be scrubbed. + /// The `at ` form must be a single whitespace-free identity (`.`, a JS + /// function ref, or a URL) followed by a real location -- a parenthesized + /// `(File.java:42)`/`(url:line:col)`/`(Native Method)`/`(Unknown Source)`, or a bare + /// trailing `:` (ParparVM). The `@` form must carry an `@` and a terminal + /// `::`. private static boolean isFrameLine(String line) { String t = line.trim(); if (t.startsWith("at ")) { - return hasParenLocation(t) || endsWithColonNumber(t); + return atFrame(t.substring(3).trim()); } return t.indexOf('@') >= 0 && endsWithLineColumn(t); } - /// True when `t` ends with a genuine parenthesized frame location, not just any parentheses: - /// `(File.java:42)` / `(url:line:col)` (content ending in `:`), or the JVM literals - /// `(Native Method)` / `(Unknown Source)`. A message wrapped onto an `at ...` line with an - /// incidental parenthetical (`at account 123456 failed (retry)`) does not match, so its digits - /// stay subject to scrubbing. - private static boolean hasParenLocation(String t) { - if (!t.endsWith(")")) { + /// The body of an `at ...` line: a whitespace-free identity plus a real location. A message + /// continuation such as `account 123456 failed:789` or `account 123456 failed (token:789)` has + /// spaces in its identity, so it is not a frame and its digits stay subject to scrubbing. + private static boolean atFrame(String rest) { + if (rest.length() == 0) { return false; } - int open = t.lastIndexOf('('); - if (open < 0) { + if (rest.endsWith(")")) { + int open = rest.lastIndexOf('('); + if (open < 0) { + return false; + } + String inside = rest.substring(open + 1, rest.length() - 1); + boolean location = "Native Method".equals(inside) || "Unknown Source".equals(inside) + || endsWithColonNumber(inside); + return location && isFrameIdentity(rest.substring(0, open).trim()); + } + int start = trailingLocationStart(rest); + return start > 0 && isFrameIdentity(rest.substring(0, start)); + } + + /// A frame's identity is a single token: non-empty and free of whitespace. A free-form message + /// continuation (`account 123456 failed`) has spaces, so it is rejected. + private static boolean isFrameIdentity(String id) { + if (id.length() == 0) { return false; } - String inside = t.substring(open + 1, t.length() - 1); - if ("Native Method".equals(inside) || "Unknown Source".equals(inside)) { - return true; + for (int i = 0; i < id.length(); i++) { + char c = id.charAt(i); + if (c == ' ' || c == '\t') { + return false; + } + } + return true; + } + + /// Index at which a trailing `:` (optionally `::`) location begins, or -1 + /// when the string does not end in one. A single trailing `)` is allowed. + private static int trailingLocationStart(String t) { + int i = t.length() - 1; + if (i >= 0 && t.charAt(i) == ')') { + i--; + } + int start = -1; + boolean matched = true; + while (matched) { + int j = i; + int digits = 0; + while (j >= 0 && t.charAt(j) >= '0' && t.charAt(j) <= '9') { + j--; + digits++; + } + if (digits > 0 && j >= 0 && t.charAt(j) == ':') { + start = j; + i = j - 1; + } else { + matched = false; + } } - return endsWithColonNumber(inside); + return start; } /// True when `t` ends with a `:` run (a trailing `)` allowed): the diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java index e926128cef7..1c5e40ec976 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java @@ -59,6 +59,8 @@ public final class ControlFlowTransform { /** Widest encoding of one entry guard (GETSTATIC, IFGT, NEW, DUP, INVOKESPECIAL, ATHROW). */ private static final int GUARD_BYTES = 16; + /** Widest encoding of the guard-field setup prepended to {@code } (2 calls + PUTSTATIC). */ + private static final int GUARD_INIT_BYTES = 16; private final ClassLoader hierarchy; private final int intensity; @@ -109,6 +111,15 @@ public byte[] transform(byte[] classBytes) { // assumption that the collision means it was already transformed. String guardField = resolveGuardField(cn); + // The guards read a field initialized in ; if is already near the method limit + // the setup cannot be added, and an uninitialized field is 0 -- which makes every guard take its + // dead (throwing) arm. So the whole class cannot be guarded then: skip it (reporting the methods + // that stay plain) rather than corrupt behaviour or abort the build with MethodTooLargeException. + if (MethodSize.estimateBytes(findClinit(cn)) + GUARD_INIT_BYTES > MethodSize.SAFE_LIMIT) { + oversizedMethods += countGuardable(cn); + return classBytes; + } + boolean changed = false; if (cn.methods != null) { for (MethodNode mn : cn.methods) { @@ -141,6 +152,29 @@ public byte[] transform(byte[] classBytes) { return cw.toByteArray(); } + private static MethodNode findClinit(ClassNode cn) { + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if ("".equals(mn.name) && "()V".equals(mn.desc)) { + return mn; + } + } + } + return null; + } + + private int countGuardable(ClassNode cn) { + int n = 0; + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if (isGuardable(mn)) { + n++; + } + } + } + return n; + } + private boolean isGuardable(MethodNode mn) { if (mn.instructions == null || mn.instructions.size() == 0) { return false; diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java index 8dc3717a665..454c5b38a54 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java @@ -148,6 +148,45 @@ public void oversizedMethodIsSkippedNotOverflowed() throws Exception { assertEquals(5, c.getMethod("add", int.class, int.class).invoke(null, 2, 3)); } + @Test + public void classWithNearFullClinitIsSkippedNotOverflowed() throws Exception { + // The guard reads a field initialized in . When is already near the limit the + // setup cannot be added, so guarding would leave the field 0 and every guard would throw. The + // whole class must be skipped (reported), not corrupted or aborted with MethodTooLargeException. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/FullClinitGuard", null, "java/lang/Object", null); + org.objectweb.asm.MethodVisitor clinit = w.visitMethod(org.objectweb.asm.Opcodes.ACC_STATIC, + "", "()V", null, null); + clinit.visitCode(); + for (int i = 0; i < 31500; i++) { // ~63 KB + clinit.visitInsn(org.objectweb.asm.Opcodes.ICONST_0); + clinit.visitInsn(org.objectweb.asm.Opcodes.POP); + } + clinit.visitInsn(org.objectweb.asm.Opcodes.RETURN); + clinit.visitMaxs(1, 0); + clinit.visitEnd(); + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "add", "(II)I", null, null); + m.visitCode(); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 0); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 1); + m.visitInsn(org.objectweb.asm.Opcodes.IADD); + m.visitInsn(org.objectweb.asm.Opcodes.IRETURN); + m.visitMaxs(2, 2); + m.visitEnd(); + w.visitEnd(); + + ControlFlowTransform t = new ControlFlowTransform(); + byte[] out = t.transform(w.toByteArray()); + assertEquals("no method can be guarded when cannot hold the setup", 0, t.getGuardedMethods()); + assertTrue("the skipped guardable method is reported", t.getOversizedMethods() >= 1); + CheckClassAdapter.verify(new ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.FullClinitGuard", out); + assertEquals(5, c.getMethod("add", int.class, int.class).invoke(null, 2, 3)); + } + // Renames the class internal name so the intense variant can load beside the plain one. private static byte[] rename(byte[] bytes, String from, String to) { org.objectweb.asm.ClassReader cr = new org.objectweb.asm.ClassReader(bytes); diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 3fc5c8feac1..68ac375ae6f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -104,6 +104,22 @@ void messageWithIncidentalParenthesesIsNotAFrame() { assertTrue(scrubbed.indexOf("(Native Method)") >= 0, scrubbed); } + @Test + void messageWithAtPrefixAndColonNumberIsNotAFrame() { + // A wrapped message can start with "at " AND end in a colon-number or carry a parenthetical + // location, yet its identity has spaces, so it is not a frame and its id must be scrubbed. + String stack = "java.lang.RuntimeException: bad\n" + + "at account 123456 failed:789\n" + + "at account 654321 failed (token:12)\n" + + "\tat com.foo.Bar.baz(Bar.java:4242)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account [num] failed:789") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("654321") < 0, scrubbed); + // The real frame keeps its coordinate. + assertTrue(scrubbed.indexOf("Bar.java:4242") >= 0, scrubbed); + } + @Test void customScrubMessageOverrideReachesRawStack() { // An app that redacts an app-specific token by overriding scrubMessage must have it redacted From 1353f18cd07f07f4ec9f355e1e7c65b9d447fdaa Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:48:03 +0700 Subject: [PATCH 050/110] Keep R8 source-file metadata; size-check interface method-body string rewrites - The retrace parser dropped R8's indented '# {"id":"sourceFile",...}' metadata (no " -> ", so parseMemberLine discarded it). A hardened Android build strips SourceFile, so the device reports no filename and the retrace synthesized .java, losing Screen.kt for Kotlin and package-private frames. The parser now captures the sourceFile per class and preferredSourceFile prefers it over a synthesized name. Covered by usesR8SourceFileMetadataWhenSourceFileStripped (and the no-metadata synthesize fallback). - The round-45/46 size guards covered growth and control-flow guards, but the interface per-access path (encryptMethodLiterals) inserted an INVOKESTATIC per literal without checking the method size, so a Java 8+ interface with a near-limit method could overflow. It now tracks the running method size and skips (leaving plaintext, reported via getMethodFullLiteralCount) before overflowing; the engine warns. Covered by interfaceMethodTooLargeSkipsAndReportsLiterals. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/HardeningEngine.java | 7 ++ .../hardening/StringEncryptTransform.java | 44 +++++++++---- .../hardening/StringEncryptTransformTest.java | 46 +++++++++++++ .../com/codename1/retrace/MappingFile.java | 64 ++++++++++++++++--- .../codename1/retrace/MappingFileTest.java | 30 +++++++++ 5 files changed, 172 insertions(+), 19 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 6c74b7b360e..0f87df69a34 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -187,6 +187,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int oversizedLiterals = 0; int condyLiterals = 0; int clinitFullLiterals = 0; + int methodFullLiterals = 0; boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); if (stringsApplied) { // In "constants" mode, first collect the values declared as static-final String @@ -212,6 +213,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi oversizedLiterals += t.getOversizedLiteralCount(); condyLiterals += t.getCondyLiteralCount(); clinitFullLiterals += t.getClinitFullLiteralCount(); + methodFullLiterals += t.getMethodFullLiteralCount(); } } @@ -334,6 +336,11 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "because the class's static initializer is already near the 65535-byte method " + "limit and could not hold the decode step"); } + if (stringsApplied && methodFullLiterals > 0) { + result.getWarnings().add(methodFullLiterals + " string literal(s) were left in plaintext " + + "because their enclosing method is already near the 65535-byte limit and the " + + "per-access decode call would overflow it"); + } if (controlFlowApplied && oversizedGuardMethods > 0) { result.getWarnings().add(oversizedGuardMethods + " method(s) were left with plain control " + "flow because they are already near the 65535-byte method limit and adding the " diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 5212d0c9740..a6f474b5726 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -80,6 +80,8 @@ public final class StringEncryptTransform { private static final int MAX_CLINIT_CHUNK_BYTES = 48000; /** Widest encoding of one INVOKESTATIC helper call prepended to . */ private static final int CLINIT_CALL_BYTES = 5; + /** Widest encoding of the per-access decoder INVOKESTATIC inserted after an LDC. */ + private static final int DECODER_CALL_BYTES = 5; private final boolean encryptAllStrings; private final int seed; @@ -91,6 +93,7 @@ public final class StringEncryptTransform { private int oversizedLiteralCount; private int condyLiteralCount; private int clinitFullLiteralCount; + private int methodFullLiteralCount; public StringEncryptTransform(boolean encryptAllStrings, int seed) { this(encryptAllStrings, seed, null, null); @@ -197,6 +200,16 @@ public int getClinitFullLiteralCount() { return clinitFullLiteralCount; } + /** + * The number of literals left plaintext because encrypting them per access would have pushed the + * enclosing method past the 65,535-byte limit. Only the interface path decodes per access (a class's + * literals are hoisted to {@code }, which does not grow the method body), so this is rare; + * reported rather than aborting the build on a valid input class. + */ + public int getMethodFullLiteralCount() { + return methodFullLiteralCount; + } + /** Encrypts {@code classBytes}, returning the transformed bytes (or the input if nothing changed). */ public byte[] transform(byte[] classBytes) { ClassNode cn = new ClassNode(); @@ -289,23 +302,32 @@ && f.value instanceof String && shouldEncryptLiteral((String) f.value)) { private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boolean isInterface, String decoderName) { boolean changed = false; + // Each rewrite inserts an INVOKESTATIC, growing the method. A method already near the limit + // cannot take unbounded rewrites, so track the running size and stop (leaving the remaining + // literals plaintext, reported) before the method would overflow, rather than aborting the build. + int currentBytes = MethodSize.estimateBytes(mn.instructions); AbstractInsnNode insn = mn.instructions.getFirst(); while (insn != null) { AbstractInsnNode next = insn.getNext(); if (insn instanceof LdcInsnNode) { LdcInsnNode ldc = (LdcInsnNode) insn; if (ldc.cst instanceof String && shouldEncryptLiteral((String) ldc.cst)) { - String plain = (String) ldc.cst; - // shouldEncrypt already rejected any value whose ciphertext could overflow the - // constant pool, using a class-independent bound, so the encode result fits. - ldc.cst = encode(plain, base); - // The itf flag must be true when the decoder lives in an interface, or the JVM - // writes a Methodref instead of an InterfaceMethodref and throws - // IncompatibleClassChangeError at run time. - mn.instructions.insert(ldc, new MethodInsnNode( - Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, isInterface)); - encryptedCount++; - changed = true; + if (currentBytes + DECODER_CALL_BYTES > MethodSize.SAFE_LIMIT) { + methodFullLiteralCount++; + } else { + String plain = (String) ldc.cst; + // shouldEncrypt already rejected any value whose ciphertext could overflow the + // constant pool, using a class-independent bound, so the encode result fits. + ldc.cst = encode(plain, base); + // The itf flag must be true when the decoder lives in an interface, or the JVM + // writes a Methodref instead of an InterfaceMethodref and throws + // IncompatibleClassChangeError at run time. + mn.instructions.insert(ldc, new MethodInsnNode( + Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, isInterface)); + currentBytes += DECODER_CALL_BYTES; + encryptedCount++; + changed = true; + } } } insn = next; diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index be9727da283..c17fd5ebc2a 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -367,6 +367,52 @@ public void preJava8InterfaceConstantIsCountedAsExcluded() throws Exception { StringEncryptTransform.containsStringLiteral(out, secret)); } + @Test + public void interfaceMethodTooLargeSkipsAndReportsLiterals() throws Exception { + // The interface path decodes per access (an INVOKESTATIC after each LDC). A method already near + // the limit cannot take those, so its literals are left plaintext and reported; a normal method + // in the same interface is still encrypted -- the build must not abort. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_INTERFACE | org.objectweb.asm.Opcodes.ACC_ABSTRACT, + "app/BigIface", null, "java/lang/Object", null); + // A static method already ~60 KB, then a few encryptable literals it cannot fit a decode call for. + org.objectweb.asm.MethodVisitor big = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "big", "()V", null, null); + big.visitCode(); + for (int i = 0; i < 30100; i++) { + big.visitInsn(org.objectweb.asm.Opcodes.ICONST_0); + big.visitInsn(org.objectweb.asm.Opcodes.POP); + } + for (int i = 0; i < 5; i++) { + big.visitLdcInsn("a big-interface secret literal number " + i); + big.visitInsn(org.objectweb.asm.Opcodes.POP); + } + big.visitInsn(org.objectweb.asm.Opcodes.RETURN); + big.visitMaxs(1, 0); + big.visitEnd(); + // A normal static method whose literal must still be encrypted. + org.objectweb.asm.MethodVisitor small = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "small", "()Ljava/lang/String;", null, null); + small.visitCode(); + small.visitLdcInsn("a small interface secret literal"); + small.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + small.visitMaxs(1, 0); + small.visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 3); + byte[] out = t.transform(w.toByteArray()); + assertEquals("the near-limit method's literals are reported", 5, t.getMethodFullLiteralCount()); + assertTrue("the normal method's literal is still encrypted", t.getEncryptedCount() >= 1); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + assertTrue("the un-encryptable literal stays plaintext", + StringEncryptTransform.containsStringLiteral(out, "a big-interface secret literal number 0")); + assertFalse("the small literal is encrypted away", + StringEncryptTransform.containsStringLiteral(out, "a small interface secret literal")); + } + @Test public void oversizedInitializerIsSplitAcrossHelpers() throws Exception { // A generated class with enough distinct literals that a single would exceed the diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java index c707fd0011a..0bb43ba7502 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -81,6 +81,10 @@ private static final class ClassMapping { final String originalName; // obfuscated member name -> candidate original methods (multiple when line ranges differ) final Map> methods = new HashMap>(); + // R8's recorded source file (e.g. Screen.kt) from a "# {"id":"sourceFile",...}" comment; null + // when the mapping carries no such metadata. Lets a hardened build -- which strips SourceFile + // from the binary, so the device reports no filename -- still name the real source file. + String sourceFile; ClassMapping(String originalName) { this.originalName = originalName; @@ -107,12 +111,46 @@ public static MappingFile parse(Reader reader) throws IOException { // Class line: "original -> obfuscated:" current = mf.parseClassLine(line); } else if (current != null) { - mf.parseMemberLine(current, line.trim()); + String trimmed = line.trim(); + // R8 records per-class metadata as an INDENTED comment, e.g. + // # {"id":"sourceFile","fileName":"Screen.kt"}. It is not a member line (no " -> "), + // so capture the source file here rather than dropping it in parseMemberLine. + if (trimmed.startsWith("#")) { + String sf = parseSourceFileMetadata(trimmed); + if (sf != null) { + current.sourceFile = sf; + } + } else { + mf.parseMemberLine(current, trimmed); + } } } return mf; } + /** + * Extracts the {@code fileName} from an R8 {@code sourceFile} metadata comment such as + * {@code # {"id":"sourceFile","fileName":"Screen.kt"}}, or {@code null} when the comment is not + * one. Deliberately a small indexOf scan rather than a JSON dependency (this module is zero-dep). + */ + private static String parseSourceFileMetadata(String comment) { + if (comment.indexOf("\"id\":\"sourceFile\"") < 0) { + return null; + } + String key = "\"fileName\":\""; + int at = comment.indexOf(key); + if (at < 0) { + return null; + } + int start = at + key.length(); + int end = comment.indexOf('"', start); + if (end < 0) { + return null; + } + String name = comment.substring(start, end).trim(); + return name.length() == 0 ? null : name; + } + private ClassMapping parseClassLine(String line) { int arrow = line.indexOf(" -> "); if (arrow < 0 || !line.endsWith(":")) { @@ -219,7 +257,7 @@ public List retraceAll(Frame obfuscated) { // obfuscated class, or a ParparVM synthesized .java), it carries no information, so // synthesize .java from the retraced class instead. String file = preferredSourceFile(obfuscated.getFileName(), obfuscated.getClassName(), - originalClass); + originalClass, cm.sourceFile); // ParparVM records a constructor / static initializer under the runtime sentinel names // __INIT__ / __CLINIT__ (BytecodeMethod), but a ProGuard mapping keys them as /. // Normalize before the lookup, or the frame misses its method record and keeps the sentinel @@ -272,13 +310,15 @@ private Frame frameFor(MethodMapping m, String enclosingClass, String enclosingF /** * The source file to report for the enclosing class. Keeps a real reported name (Screen.kt, - * Main.java) but synthesizes {@code .java} when the reported name is empty or is - * just the obfuscated class name with an extension (a renamed/synthesized placeholder that would - * otherwise leak an obfuscated name into the retraced stack). + * Main.java); otherwise -- when the reported name is empty or is just the obfuscated class name with + * an extension (a renamed/synthesized placeholder) -- prefers R8's recorded {@code sourceFile} + * metadata when the mapping has it (so a hardened build that stripped SourceFile still names + * Screen.kt), and only falls back to synthesizing {@code .java} when it does not. */ - private static String preferredSourceFile(String reported, String obfClassName, String originalClass) { + private static String preferredSourceFile(String reported, String obfClassName, String originalClass, + String mappedSourceFile) { if (reported == null || reported.length() == 0) { - return simpleSourceFile(originalClass); + return synthesizedSourceFile(originalClass, mappedSourceFile); } int dot = reported.lastIndexOf('.'); String reportedBase = dot > 0 ? reported.substring(0, dot) : reported; @@ -292,11 +332,19 @@ private static String preferredSourceFile(String reported, String obfClassName, obfSimple = obfSimple.substring(0, dollar); } if (reportedBase.equals(obfSimple)) { - return simpleSourceFile(originalClass); + return synthesizedSourceFile(originalClass, mappedSourceFile); } return reported; } + /** R8's recorded source file when the mapping has it, else a synthesized {@code .java}. */ + private static String synthesizedSourceFile(String originalClass, String mappedSourceFile) { + if (mappedSourceFile != null && mappedSourceFile.length() > 0) { + return mappedSourceFile; + } + return simpleSourceFile(originalClass); + } + private static String simpleSourceFile(String fqcn) { int d = fqcn.lastIndexOf('.'); String simple = d < 0 ? fqcn : fqcn.substring(d + 1); diff --git a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java index 03d4cc7b678..686c93991a7 100644 --- a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -57,6 +57,36 @@ public void retracesClassAndMethod() throws Exception { + " 50:55:void () -> \n" + " 70:72:void () -> \n"; + @Test + public void usesR8SourceFileMetadataWhenSourceFileStripped() throws Exception { + // A hardened Android build strips SourceFile, so the device reports no real filename; R8's + // sourceFile metadata comment carries the true name (Screen.kt) and must be used instead of a + // synthesized Screen.java. + String mapping = + "com.example.Screen -> a.b:\n" + + " # {\"id\":\"sourceFile\",\"fileName\":\"Screen.kt\"}\n" + + " 142:145:void onClick() -> a\n"; + MappingFile mf = MappingFile.parse(mapping); + // Reported file is the obfuscated class placeholder (b.java) -- a stripped-SourceFile symptom. + Frame placeholder = mf.retrace(new Frame("a.b", "a", "b.java", 143)); + assertEquals("com.example.Screen", placeholder.getClassName()); + assertEquals("onClick", placeholder.getMethodName()); + assertEquals("Screen.kt", placeholder.getFileName()); + // Empty reported file (the other stripped-SourceFile symptom) resolves the same way. + Frame empty = mf.retrace(new Frame("a.b", "a", "", 143)); + assertEquals("Screen.kt", empty.getFileName()); + } + + @Test + public void synthesizesSourceFileWhenMappingHasNoMetadata() throws Exception { + // Without sourceFile metadata, a stripped-SourceFile frame still synthesizes .java. + String mapping = + "com.example.Screen -> a.b:\n" + + " 142:145:void onClick() -> a\n"; + Frame out = MappingFile.parse(mapping).retrace(new Frame("a.b", "a", "b.java", 143)); + assertEquals("Screen.java", out.getFileName()); + } + @Test public void normalizesParparVmConstructorSentinel() throws Exception { // ParparVM records a constructor frame under the runtime sentinel __INIT__; the mapping keys it From 5446ef925f8d69cf2bdc3bbe176386c3bf24d9ff Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:09:20 +0700 Subject: [PATCH 051/110] Bound raw-stack capture; count annotation-value strings excluded from strings:all - CrashProtection.safeRawStack rendered printStackTrace into an unbounded ByteArrayOutputStream and only truncated to MAX_RAW_STACK_LEN afterwards, so a huge message or deep cause chain could allocate many times the 16 KiB cap during crash handling and trigger a second OutOfMemoryError, losing the report. It now renders into a fixed-capacity BoundedOutputStream that discards bytes past the cap. Covered by BoundedOutputStreamTest. - Strings in annotation element values/defaults live in the annotation metadata, not an LDC or a ConstantValue, so no channel encrypts them. countAnnotationStrings now counts the distinct ones the mode would select (walking class/field/method/parameter annotations, recursing into nested annotations and arrays, skipping enum refs and Types), and the engine warns -- so an strings:all build is not believed to have encrypted every string. Covered by annotationStringsAreCountedAsExcluded. (CN1 has no reflection to read an annotation value back, so this is a disclosure note.) Co-Authored-By: Claude Opus 4.8 --- .../com/codename1/crash/CrashProtection.java | 73 +++++++++++++-- .../codename1/hardening/HardeningEngine.java | 10 +++ .../hardening/StringEncryptTransform.java | 88 +++++++++++++++++++ .../hardening/StringEncryptTransformTest.java | 46 ++++++++++ .../crash/BoundedOutputStreamTest.java | 56 ++++++++++++ 5 files changed, 264 insertions(+), 9 deletions(-) create mode 100644 maven/core-unittests/src/test/java/com/codename1/crash/BoundedOutputStreamTest.java diff --git a/CodenameOne/src/com/codename1/crash/CrashProtection.java b/CodenameOne/src/com/codename1/crash/CrashProtection.java index 1220018fef6..6f1cf882f42 100644 --- a/CodenameOne/src/com/codename1/crash/CrashProtection.java +++ b/CodenameOne/src/com/codename1/crash/CrashProtection.java @@ -259,20 +259,75 @@ private static String safeRawStack(Throwable t) { // real trace on the ParparVM ports: the pre-rendered C shadow-call-stack text, or the // JavaScript engine's Error().stack on the JS port (where getStackTrace() has no // structured frames to offer). On the JVM ports it is the standard full trace. - java.io.ByteArrayOutputStream bout = new java.io.ByteArrayOutputStream(); - // Encode explicitly as UTF-8 on both ends rather than relying on the platform default - // (which SpotBugs flags and which would garble a non-ASCII exception message differently - // per device); the pair must agree, so the PrintStream and the readback share the charset. - java.io.PrintStream ps = new java.io.PrintStream(bout, true, "UTF-8"); - t.printStackTrace(ps); - ps.flush(); - String s = bout.toString("UTF-8"); - return s.length() == 0 ? null : s; + // + // Render into a BOUNDED buffer that discards bytes past the cap, rather than an unbounded + // ByteArrayOutputStream truncated afterwards: a throwable with a huge message or a deep + // cause chain could otherwise allocate many times the 16 KiB cap during crash handling and + // trigger a second OutOfMemoryError, losing the report. The cap sits a little above + // MAX_RAW_STACK_LEN so scrubbing (which only shrinks -- digit runs and emails collapse) and + // the final trim still yield a full-length raw stack. + BoundedOutputStream bout = new BoundedOutputStream( + CrashReportPayload.MAX_RAW_STACK_LEN + 8192); + try { + // Encode explicitly as UTF-8 on both ends rather than relying on the platform default + // (which SpotBugs flags and which would garble a non-ASCII exception message differently + // per device); the PrintStream and the readback share the charset. + java.io.PrintStream ps = new java.io.PrintStream(bout, true, "UTF-8"); + try { + t.printStackTrace(ps); + ps.flush(); + } finally { + ps.close(); + } + String s = bout.toUtf8(); + return s.length() == 0 ? null : s; + } finally { + bout.close(); + } } catch (Throwable ignored) { return null; } } + /// A fixed-capacity {@link java.io.OutputStream} that keeps the first {@code cap} bytes and + /// silently discards the rest, so rendering a pathologically large stack cannot grow the buffer + /// without bound (and cannot itself OOM during crash handling). Not thread-safe; used by a single + /// capturing thread. + static final class BoundedOutputStream extends OutputStream { + private final byte[] buf; + private int count; + + BoundedOutputStream(int cap) { + buf = new byte[cap]; + } + + @Override + public void write(int b) { + if (count < buf.length) { + buf[count++] = (byte) b; + } + } + + @Override + public void write(byte[] b, int off, int len) { + int room = buf.length - count; + if (room <= 0) { + return; + } + int n = len < room ? len : room; + System.arraycopy(b, off, buf, count, n); + count += n; + } + + String toUtf8() throws java.io.UnsupportedEncodingException { + // Explicit UTF-8 (never the platform default, which SpotBugs flags and which would garble a + // non-ASCII message per device). UTF-8 is always available, so this never actually throws; + // the checked exception is handled by the single caller's catch-all rather than swallowed + // here into a default-encoding String. + return new String(buf, 0, count, "UTF-8"); + } + } + /// Pulls the platform log snapshot, swallowing any exception the /// platform implementation throws -- crash protection must never /// itself crash the host. Returns `null` on platforms without a diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 0f87df69a34..5083519db4e 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -188,6 +188,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int condyLiterals = 0; int clinitFullLiterals = 0; int methodFullLiterals = 0; + int annotationLiterals = 0; boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); if (stringsApplied) { // In "constants" mode, first collect the values declared as static-final String @@ -214,6 +215,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi condyLiterals += t.getCondyLiteralCount(); clinitFullLiterals += t.getClinitFullLiteralCount(); methodFullLiterals += t.getMethodFullLiteralCount(); + annotationLiterals += t.getAnnotationLiteralCount(); } } @@ -341,6 +343,14 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "because their enclosing method is already near the 65535-byte limit and the " + "per-access decode call would overflow it"); } + if (stringsApplied && annotationLiterals > 0) { + // Annotation element values live in the annotation metadata, not an LDC or a ConstantValue, + // so no encryption channel reaches them. CN1 has no reflection to read them back, so this is + // a disclosure note; don't put a secret in an annotation and expect it hidden. + result.getWarnings().add(annotationLiterals + " string(s) in annotation values/defaults were " + + "not encrypted (they live in annotation metadata, not code); do not place a secret " + + "in an annotation"); + } if (controlFlowApplied && oversizedGuardMethods > 0) { result.getWarnings().add(oversizedGuardMethods + " method(s) were left with plain control " + "flow because they are already near the 65535-byte method limit and adding the " diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index a6f474b5726..85d5ff434ad 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -30,6 +30,7 @@ import org.objectweb.asm.Opcodes; import org.objectweb.asm.Type; import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.AnnotationNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.FieldInsnNode; import org.objectweb.asm.tree.FieldNode; @@ -94,6 +95,7 @@ public final class StringEncryptTransform { private int condyLiteralCount; private int clinitFullLiteralCount; private int methodFullLiteralCount; + private int annotationLiteralCount; public StringEncryptTransform(boolean encryptAllStrings, int seed) { this(encryptAllStrings, seed, null, null); @@ -210,6 +212,18 @@ public int getMethodFullLiteralCount() { return methodFullLiteralCount; } + /** + * The number of distinct string values the current mode would encrypt that are stored in annotation + * element values or annotation defaults. javac keeps those in the annotation metadata, not as an + * {@code LDC} or a field {@code ConstantValue}, so no encryption channel reaches them; they stay + * readable. Counted and reported so an {@code strings:all} build is not believed to have encrypted + * every string. (Codename One has no runtime reflection to read an annotation value back, so this is + * a disclosure note, not a correctness risk.) + */ + public int getAnnotationLiteralCount() { + return annotationLiteralCount; + } + /** Encrypts {@code classBytes}, returning the transformed bytes (or the input if nothing changed). */ public byte[] transform(byte[] classBytes) { ClassNode cn = new ClassNode(); @@ -251,6 +265,7 @@ && f.value instanceof String && shouldEncryptLiteral((String) f.value)) { // silently shipped. concatLiteralCount += countConcatLiterals(cn); condyLiteralCount += countCondyLiterals(cn); + annotationLiteralCount += countAnnotationStrings(cn); // Count the distinct literals that would be encrypted but are too large to (their ciphertext // could overflow the constant pool), so the engine can report the exclusion rather than let an // strings:all build claim it encrypted everything. @@ -434,6 +449,79 @@ private static boolean condyHasStringArgument(ConstantDynamic condy) { return false; } + /** + * Counts the distinct string values the current mode would encrypt that live in annotation element + * values or defaults (see {@link #getAnnotationLiteralCount()}). Walks class, field, method and + * parameter annotations plus method annotation defaults, recursing into nested annotations and array + * values; skips enum references ({@code String[]}) and {@code Type}, which are not string literals. + */ + private int countAnnotationStrings(ClassNode cn) { + java.util.Set found = new java.util.HashSet(); + collectAnnotations(cn.visibleAnnotations, found); + collectAnnotations(cn.invisibleAnnotations, found); + if (cn.fields != null) { + for (FieldNode f : cn.fields) { + collectAnnotations(f.visibleAnnotations, found); + collectAnnotations(f.invisibleAnnotations, found); + } + } + if (cn.methods != null) { + for (MethodNode m : cn.methods) { + collectAnnotations(m.visibleAnnotations, found); + collectAnnotations(m.invisibleAnnotations, found); + collectParameterAnnotations(m.visibleParameterAnnotations, found); + collectParameterAnnotations(m.invisibleParameterAnnotations, found); + collectAnnotationValue(m.annotationDefault, found); + } + } + return found.size(); + } + + private void collectAnnotations(java.util.List list, java.util.Set out) { + if (list == null) { + return; + } + for (AnnotationNode an : list) { + collectAnnotationNode(an, out); + } + } + + private void collectParameterAnnotations(java.util.List[] params, + java.util.Set out) { + if (params == null) { + return; + } + for (java.util.List list : params) { + collectAnnotations(list, out); + } + } + + private void collectAnnotationNode(AnnotationNode an, java.util.Set out) { + if (an == null || an.values == null) { + return; + } + // values is a flat [name, value, name, value, ...] list; only the values can hold strings. + for (int i = 1; i < an.values.size(); i += 2) { + collectAnnotationValue(an.values.get(i), out); + } + } + + private void collectAnnotationValue(Object value, java.util.Set out) { + if (value instanceof String) { + if (modeSelectsLiteral((String) value)) { + out.add((String) value); + } + } else if (value instanceof AnnotationNode) { + collectAnnotationNode((AnnotationNode) value, out); + } else if (value instanceof java.util.List) { + for (Object e : (java.util.List) value) { + collectAnnotationValue(e, out); + } + } + // A String[] is an enum reference {descriptor, constant} and a Type is a class literal -- neither + // is a user string literal, so both are left uncounted. + } + /** * Counts the distinct literals in {@code cn} that this transform would encrypt but skips because * their ciphertext could overflow the constant pool (see {@link #getOversizedLiteralCount()}). diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index c17fd5ebc2a..05c065c9866 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -367,6 +367,52 @@ public void preJava8InterfaceConstantIsCountedAsExcluded() throws Exception { StringEncryptTransform.containsStringLiteral(out, secret)); } + @Test + public void annotationStringsAreCountedAsExcluded() throws Exception { + // Strings in annotation element values/arrays live in the annotation metadata, not an LDC or a + // ConstantValue, so no channel encrypts them. They must be counted (and stay plaintext); an enum + // reference is not a string literal and must NOT be counted. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Annotated", null, "java/lang/Object", null); + org.objectweb.asm.AnnotationVisitor av = w.visitAnnotation("Lapp/Anno;", true); + av.visit("secret", "a class annotation secret value"); + org.objectweb.asm.AnnotationVisitor arr = av.visitArray("list"); + arr.visit(null, "an array annotation value"); + arr.visitEnd(); + av.visitEnum("kind", "Lapp/Kind;", "FOO"); // enum reference -- not a string literal + av.visitEnd(); + org.objectweb.asm.FieldVisitor fv = w.visitField(org.objectweb.asm.Opcodes.ACC_PRIVATE, + "f", "I", null, null); + org.objectweb.asm.AnnotationVisitor fav = fv.visitAnnotation("Lapp/Anno;", true); + fav.visit("fieldSecret", "a field annotation secret value"); + fav.visitEnd(); + fv.visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 9); + byte[] out = t.transform(w.toByteArray()); + assertEquals("the three distinct annotation strings are counted, the enum ref is not", + 3, t.getAnnotationLiteralCount()); + // The annotation string survives verbatim in the constant pool (no channel encrypts it). + assertTrue("annotation strings stay plaintext (no channel reaches them)", + containsRawBytes(out, "a class annotation secret value")); + } + + private static boolean containsRawBytes(byte[] haystack, String needle) throws Exception { + byte[] n = needle.getBytes("UTF-8"); + outer: + for (int i = 0; i + n.length <= haystack.length; i++) { + for (int j = 0; j < n.length; j++) { + if (haystack[i + j] != n[j]) { + continue outer; + } + } + return true; + } + return false; + } + @Test public void interfaceMethodTooLargeSkipsAndReportsLiterals() throws Exception { // The interface path decodes per access (an INVOKESTATIC after each LDC). A method already near diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/BoundedOutputStreamTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/BoundedOutputStreamTest.java new file mode 100644 index 00000000000..ee6fc22dfd7 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/crash/BoundedOutputStreamTest.java @@ -0,0 +1,56 @@ +/* + * 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.crash; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.PrintStream; +import org.junit.jupiter.api.Test; + +/** The bounded buffer that caps raw-stack capture so a huge trace cannot OOM during crash handling. */ +class BoundedOutputStreamTest { + + @Test + void keepsFirstBytesAndDiscardsTheRest() throws Exception { + CrashProtection.BoundedOutputStream b = new CrashProtection.BoundedOutputStream(10); + b.write("hello".getBytes("UTF-8"), 0, 5); + b.write("world!!!".getBytes("UTF-8"), 0, 8); // only 5 more fit + assertEquals("helloworld", b.toUtf8()); + b.write('x'); // past capacity: discarded + assertEquals("helloworld", b.toUtf8()); + } + + @Test + void capsAHugePrintStreamRenderingAtCapacity() throws Exception { + // Simulate a pathologically large rendering: far more than the cap is written, but the buffer + // never grows past its fixed capacity, so crash handling cannot allocate without bound. + int cap = 4096; + CrashProtection.BoundedOutputStream b = new CrashProtection.BoundedOutputStream(cap); + PrintStream ps = new PrintStream(b, true, "UTF-8"); + for (int i = 0; i < 100000; i++) { + ps.print("0123456789"); + } + ps.flush(); + assertEquals(cap, b.toUtf8().length()); + } +} From ab3d013d10fd8da2319f9b83a530678e0d3d3bdd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:23:56 +0700 Subject: [PATCH 052/110] Require a dotted/URL frame identity; recurse into nested constant-dynamic arguments - isFrameIdentity accepted any whitespace-free token, so a wrapped message like 'at account123456failed:789' was still treated as a frame and its id skipped scrubbing. The bare 'IDENT:' form now requires a dotted . or a URL identity (ParparVM/JS-anon), and the parenthesized form requires a real file/URL location (its head must have an extension dot or a '/'), so 'at retry (attempt:654321)' is scrubbed too. Real frames keep their coordinates. Covered by messageWithWhitespaceFreeIdentityIsNotAFrame. - condyHasStringArgument inspected only immediate LDC ConstantDynamic bootstrap arguments, so a string hidden inside a NESTED constant-dynamic argument shipped plaintext with no warning. It now recurses into ConstantDynamic arguments (depth-guarded against a pathological pool). Covered by countsStringNestedInsideAnotherConstantDynamicArgument. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 39 +++++++++++++++++-- .../hardening/StringEncryptTransform.java | 18 ++++++++- .../hardening/ConcatLiteralDetectionTest.java | 24 ++++++++++++ .../crash/PiiScrubberRawStackTest.java | 20 ++++++++++ 4 files changed, 95 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index 6131471c1bb..0eda777db17 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -161,12 +161,36 @@ private static boolean atFrame(String rest) { return false; } String inside = rest.substring(open + 1, rest.length() - 1); - boolean location = "Native Method".equals(inside) || "Unknown Source".equals(inside) - || endsWithColonNumber(inside); - return location && isFrameIdentity(rest.substring(0, open).trim()); + // The parenthesized location is the discriminator here (JVM `(File.java:42)`, Chrome + // `(url:line:col)`, or the `(Native Method)`/`(Unknown Source)` literals), so a plain + // whitespace-free identity before it is enough -- a Chrome frame's identity can be a bare + // function name with no dot. + return isParenLocation(inside) && isFrameIdentity(rest.substring(0, open).trim()); } int start = trailingLocationStart(rest); - return start > 0 && isFrameIdentity(rest.substring(0, start)); + if (start <= 0) { + return false; + } + // The bare `IDENT:` form is ParparVM (`com.foo.Bar.baz:42`) or a JS anonymous URL frame; + // its identity is always a dotted `.` or a URL. A message token such as + // `account123456failed` is neither, so its digits stay subject to scrubbing. + return isDottedOrUrlIdentity(rest.substring(0, start)); + } + + /// True when the content of an `at ...()` is a real location: the `(Native Method)` / + /// `(Unknown Source)` literals, or a `file.ext:line` / `scheme://host/path:line:col` whose part + /// before the trailing `:` names a file (has an extension dot) or a URL (has a `/`). A + /// bare word like `attempt:123456` is not a location, so its digits stay subject to scrubbing. + private static boolean isParenLocation(String inside) { + if ("Native Method".equals(inside) || "Unknown Source".equals(inside)) { + return true; + } + if (!endsWithColonNumber(inside)) { + return false; + } + int loc = trailingLocationStart(inside); + String head = loc > 0 ? inside.substring(0, loc) : ""; + return head.indexOf('.') >= 0 || head.indexOf('/') >= 0; } /// A frame's identity is a single token: non-empty and free of whitespace. A free-form message @@ -184,6 +208,13 @@ private static boolean isFrameIdentity(String id) { return true; } + /// A stricter identity for the bare `IDENT:` form: a whitespace-free token that is a dotted + /// `.` or a URL (has a `/`). A single word like `account123456failed` is rejected, + /// so a wrapped message that happens to start with `at ` and end in a colon-number is still scrubbed. + private static boolean isDottedOrUrlIdentity(String id) { + return isFrameIdentity(id) && (id.indexOf('.') >= 0 || id.indexOf('/') >= 0); + } + /// Index at which a trailing `:` (optionally `::`) location begins, or -1 /// when the string does not end in one. A single trailing `)` is allowed. private static int trailingLocationStart(String t) { diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 85d5ff434ad..eeafe424a23 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -439,10 +439,24 @@ private static int countCondyLiterals(ClassNode cn) { return count; } - /** True when a constant-dynamic carries a String among its bootstrap arguments. */ + /** True when a constant-dynamic carries a String among its bootstrap arguments, nested ones too. */ private static boolean condyHasStringArgument(ConstantDynamic condy) { + return condyHasStringArgument(condy, 0); + } + + private static boolean condyHasStringArgument(ConstantDynamic condy, int depth) { + // A constant-dynamic bootstrap argument can itself be a ConstantDynamic whose arguments hold the + // plaintext, so recurse. The depth guard is a backstop against a pathological/cyclic pool. + if (depth > 16) { + return false; + } for (int i = 0, n = condy.getBootstrapMethodArgumentCount(); i < n; i++) { - if (condy.getBootstrapMethodArgument(i) instanceof String) { + Object arg = condy.getBootstrapMethodArgument(i); + if (arg instanceof String) { + return true; + } + if (arg instanceof ConstantDynamic + && condyHasStringArgument((ConstantDynamic) arg, depth + 1)) { return true; } } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java index c8c45aef119..aef5867a005 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java @@ -155,4 +155,28 @@ public void countsConstantDynamicStringArgumentsOnly() { t.transform(condyFixture()); assertEquals(1, t.getCondyLiteralCount()); } + + @Test + public void countsStringNestedInsideAnotherConstantDynamicArgument() { + // The outer condy has no direct String argument; its plaintext hides inside a NESTED condy. + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, "com/codename1/hardening/fixture/NestedCondy", + null, "java/lang/Object", null); + ConstantDynamic inner = new ConstantDynamic("inner", "Ljava/lang/String;", + CONDY_BSM, "nested-plaintext-value"); + ConstantDynamic outer = new ConstantDynamic("outer", "Ljava/lang/String;", + CONDY_BSM, inner); + MethodVisitor m = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "m", + "()Ljava/lang/String;", null, null); + m.visitCode(); + m.visitLdcInsn(outer); + m.visitInsn(Opcodes.ARETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + cw.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 7); + t.transform(cw.toByteArray()); + assertEquals(1, t.getCondyLiteralCount()); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 68ac375ae6f..336b2eef6e4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -120,6 +120,26 @@ void messageWithAtPrefixAndColonNumberIsNotAFrame() { assertTrue(scrubbed.indexOf("Bar.java:4242") >= 0, scrubbed); } + @Test + void messageWithWhitespaceFreeIdentityIsNotAFrame() { + // A wrapped message with no spaces around its id ("at account123456failed:789") is not a frame: + // the bare colon-number form requires a dotted class.method or a URL, and a bare word/word+digits + // paren location ("(attempt:123456)") is not a real location either. + String stack = "java.lang.RuntimeException: bad\n" + + "at account123456failed:789\n" + + "at retry (attempt:654321)\n" + + "\tat com.foo.Bar.baz(Bar.java:4242)\n" + + " at com.foo.Bar.qux:998877\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account[num]failed:789") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("attempt:[num]") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("654321") < 0, scrubbed); + // Real frames (dotted identity) keep their coordinates. + assertTrue(scrubbed.indexOf("Bar.java:4242") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("Bar.qux:998877") >= 0, scrubbed); + } + @Test void customScrubMessageOverrideReachesRawStack() { // An app that redacts an app-specific token by overriding scrubMessage must have it redacted From 63f050a85536694c8157c4396012d534364099d1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:47:36 +0700 Subject: [PATCH 053/110] Scrub frame URL/query data; keep name-bound background callbacks; cap hoisting by pool budget - A JS frame line was sent only through scrubEmails, so user data in a URL query (app.js?account=123456:1:42) bypassed digit masking. scrubFrameLine now scrubs the identity/URL/query through scrubMessage while preserving the terminal :line:column coordinate for symbolication. Covered by frameUrlQueryDataIsScrubbedButCoordinateSurvives. - GeofenceManager persists its listener class name to Storage and resolves it with Class.forName + newInstance on a background restart; background location and background fetch do the same. The default per-build mapping could rename the listener after an app update, leaving the stored name unresolvable and silently stopping the callback. BuiltinKeepRules now keeps implementors of GeofenceListener, LocationListener and BackgroundFetch (shared with R8). Covered by keepsNameBoundBackgroundCallbacks. - Hoisting adds a field + reference constants per distinct literal, so ~13k+ literals could exceed the 65535-entry constant-pool limit (ClassTooLargeException) even with the method-size split. hoisting is now capped by a constant-pool budget (from the input's item count) and the excess is left plaintext and reported (getPoolFullLiteralCount). Covered by hoistingIsCappedByConstantPoolBudget. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 21 ++++++-- .../codename1/hardening/BuiltinKeepRules.java | 9 ++++ .../codename1/hardening/HardeningEngine.java | 7 +++ .../hardening/StringEncryptTransform.java | 49 ++++++++++++++++++- .../hardening/BuiltinKeepRulesTest.java | 17 +++++++ .../hardening/StringEncryptTransformTest.java | 40 ++++++++++++++- .../crash/PiiScrubberRawStackTest.java | 12 +++++ 7 files changed, 149 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index 0eda777db17..c1041be18a8 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -104,8 +104,10 @@ public String scrubFrame(String className, String methodName) { /// A free-form (non-frame) line is routed through {@link #scrubMessage(String)} /// -- the overridable method -- so an app that redacts app-specific tokens there /// redacts them in `rawStack` too, not only in the separately-scrubbed message. - /// A frame line instead gets only the built-in email pass: the virtual scrubber - /// masks long digit runs, which would destroy a frame's line/column coordinate. + /// A frame line is scrubbed too, but only up to its terminal `:line:column` + /// coordinate: the coordinate is preserved for symbolication while the function + /// identity and any URL/query before it (which can carry user data, e.g. + /// `app.js?account=123456`) still get message scrubbing. public String scrubRawStack(String rawStack) { if (rawStack == null) { return null; @@ -117,7 +119,7 @@ public String scrubRawStack(String rawStack) { int nl = rawStack.indexOf('\n', i); int lineEnd = nl < 0 ? len : nl; String line = rawStack.substring(i, lineEnd); - out.append(isFrameLine(line) ? scrubEmails(line) : scrubMessage(line)); + out.append(isFrameLine(line) ? scrubFrameLine(line) : scrubMessage(line)); if (nl < 0) { break; } @@ -127,6 +129,19 @@ public String scrubRawStack(String rawStack) { return out.toString(); } + /// Scrubs a recognized frame line while preserving its terminal `:line[:column]` coordinate. + /// Everything before the coordinate -- the function identity and any URL/query -- goes through + /// {@link #scrubMessage(String)}, so a URL query like `?account=123456` is masked; the coordinate + /// tail is appended verbatim so symbolication still works. A frame with no numeric coordinate + /// (`(Native Method)`) has nothing to protect and no PII to speak of, so it gets only the email pass. + private String scrubFrameLine(String line) { + int loc = trailingLocationStart(line); + if (loc <= 0) { + return scrubEmails(line); + } + return scrubMessage(line.substring(0, loc)) + line.substring(loc); + } + /// True for a stack-trace line whose numeric tokens are source coordinates, /// not PII: the JVM/ParparVM `at .(...)` / `at .:` /// form (which every V8/Chrome JavaScript frame also uses), and the diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java index 489f1a79bfb..0b714a37ee0 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java @@ -69,6 +69,15 @@ public static List rules(String mainClass) { // itself here; the specific Impl / Stub are found by scanning the input // (InputJarKeepScanner) and kept individually, rather than the over-broad **Impl / **Stub. r.add("-keep class * implements com.codename1.system.NativeInterface { *; }"); + // Background callbacks the OS restarts by the app's PERSISTED class name resolve the listener + // via Class.forName + newInstance after a process restart (GeofenceManager persists it to + // Storage; background location and background fetch register a class the platform reconstructs). + // These are genuine reflective, name-bound seams -- unlike CN1's string-keyed property + // persistence -- so the class name must stay stable across an app update, or the default + // per-build mapping renames it and the background callback silently stops. Keep the implementors. + r.add("-keep class * implements com.codename1.location.GeofenceListener { *; }"); + r.add("-keep class * implements com.codename1.location.LocationListener { *; }"); + r.add("-keep class * implements com.codename1.background.BackgroundFetch { *; }"); // JNI/native method names must not move. r.add("-keepclasseswithmembernames,includedescriptorclasses class * { native ; }"); // enum values()/valueOf(String) resolve constants by name, so they are kept -- this is diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 5083519db4e..287849cac70 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -189,6 +189,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int clinitFullLiterals = 0; int methodFullLiterals = 0; int annotationLiterals = 0; + int poolFullLiterals = 0; boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); if (stringsApplied) { // In "constants" mode, first collect the values declared as static-final String @@ -216,6 +217,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi clinitFullLiterals += t.getClinitFullLiteralCount(); methodFullLiterals += t.getMethodFullLiteralCount(); annotationLiterals += t.getAnnotationLiteralCount(); + poolFullLiterals += t.getPoolFullLiteralCount(); } } @@ -343,6 +345,11 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "because their enclosing method is already near the 65535-byte limit and the " + "per-access decode call would overflow it"); } + if (stringsApplied && poolFullLiterals > 0) { + result.getWarnings().add(poolFullLiterals + " string literal(s) were left in plaintext " + + "because hoisting them would push the class past the 65535-entry constant-pool " + + "limit; split the generated class or reduce its distinct literals"); + } if (stringsApplied && annotationLiterals > 0) { // Annotation element values live in the annotation metadata, not an LDC or a ConstantValue, // so no encryption channel reaches them. CN1 has no reflection to read them back, so this is diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index eeafe424a23..0f08be8d8ab 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -83,6 +83,14 @@ public final class StringEncryptTransform { private static final int CLINIT_CALL_BYTES = 5; /** Widest encoding of the per-access decoder INVOKESTATIC inserted after an LDC. */ private static final int DECODER_CALL_BYTES = 5; + /** Target ceiling for the constant-pool item count (the hard JVM limit is 65535), with margin. */ + private static final int SAFE_POOL_ITEMS = 60000; + /** + * Conservative constant-pool entries each hoisted literal adds -- a Utf8 for the field name, a + * NameAndType, a Fieldref, and the ciphertext Utf8 + String -- so the total can be bounded before + * hoisting rather than discovering the overflow only when ASM writes the class. + */ + private static final int POOL_ITEMS_PER_HOIST = 6; private final boolean encryptAllStrings; private final int seed; @@ -96,6 +104,9 @@ public final class StringEncryptTransform { private int clinitFullLiteralCount; private int methodFullLiteralCount; private int annotationLiteralCount; + private int poolFullLiteralCount; + /** The input class's constant-pool item count, so hoisting can stay under the 65535-entry limit. */ + private int poolBaseItems; public StringEncryptTransform(boolean encryptAllStrings, int seed) { this(encryptAllStrings, seed, null, null); @@ -224,10 +235,23 @@ public int getAnnotationLiteralCount() { return annotationLiteralCount; } + /** + * The number of literals left unhoisted (plaintext) because hoisting them all -- each adds a + * synthetic field and its field-reference constants -- would push the class past the JVM's + * 65,535-entry constant-pool limit (which makes ASM throw {@code ClassTooLargeException}). Rare (a + * generated class with ~13k+ distinct selected literals); reported rather than aborting the build. + */ + public int getPoolFullLiteralCount() { + return poolFullLiteralCount; + } + /** Encrypts {@code classBytes}, returning the transformed bytes (or the input if nothing changed). */ public byte[] transform(byte[] classBytes) { ClassNode cn = new ClassNode(); - new ClassReader(classBytes).accept(cn, ClassReader.SKIP_FRAMES); + ClassReader reader = new ClassReader(classBytes); + reader.accept(cn, ClassReader.SKIP_FRAMES); + // The input's current constant-pool item count; hoisting must not grow the pool past 65535. + poolBaseItems = reader.getItemCount(); boolean isInterface = (cn.access & Opcodes.ACC_INTERFACE) != 0; // The decoder is a concrete static method, and (for interface constants) it is invoked from @@ -627,6 +651,29 @@ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) } } } + // Bound hoisting by constant-pool growth: each hoisted literal adds several pool entries (its + // field name Utf8, a NameAndType, a Fieldref, the ciphertext Utf8 + String), so a class with + // tens of thousands of distinct literals could exceed the JVM's 65535-entry limit and make ASM + // throw ClassTooLargeException. Cap the number hoisted at what the pool budget allows and leave + // the rest plaintext, reported. First-seen order is kept (LinkedHashMap), so the cut is stable. + int budget = (SAFE_POOL_ITEMS - poolBaseItems) / POOL_ITEMS_PER_HOIST; + if (budget < 0) { + budget = 0; + } + if (valueToField.size() > budget) { + java.util.Iterator> it = + valueToField.entrySet().iterator(); + int kept = 0; + while (it.hasNext()) { + it.next(); + if (kept < budget) { + kept++; + } else { + it.remove(); + poolFullLiteralCount++; + } + } + } if (valueToField.isEmpty()) { return false; } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java index 41a559774f6..3d2a4b0ee2b 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java @@ -79,4 +79,21 @@ public void lineNumbersKeptButSourceFileStripped() { assertFalse("SourceFile stripped for " + p, sourceKept); } } + + @Test + public void keepsNameBoundBackgroundCallbacks() { + // Background callbacks the OS restarts by their persisted class name (Geofence, background + // location, background fetch) resolve the app's listener via Class.forName + newInstance, so + // renaming one silently stops the callback. Their implementors must be kept. + List rules = BuiltinKeepRules.rules("com.example.MyApp"); + assertTrue(rules.contains( + "-keep class * implements com.codename1.location.GeofenceListener { *; }")); + assertTrue(rules.contains( + "-keep class * implements com.codename1.location.LocationListener { *; }")); + assertTrue(rules.contains( + "-keep class * implements com.codename1.background.BackgroundFetch { *; }")); + // The same rules are exported to R8 on Android (where R8 does the renaming). + assertTrue(BuiltinKeepRules.forR8("com.example.MyApp").contains( + "-keep class * implements com.codename1.location.GeofenceListener { *; }")); + } } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 05c065c9866..b53e6f2849d 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -459,13 +459,49 @@ public void interfaceMethodTooLargeSkipsAndReportsLiterals() throws Exception { StringEncryptTransform.containsStringLiteral(out, "a small interface secret literal")); } + @Test + public void hoistingIsCappedByConstantPoolBudget() throws Exception { + // A class with far more distinct literals than the constant pool can hold once each is hoisted + // (a field + its reference constants) would overflow the 65535-entry pool. The transform must + // cap hoisting and leave the rest plaintext (reported) rather than throw ClassTooLargeException. + int count = 12000; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/PoolHeavy", null, "java/lang/Object", null); + int perMethod = 200; + for (int start = 0; start < count; start += perMethod) { + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "m" + start, "()V", null, null); + m.visitCode(); + for (int i = start; i < start + perMethod && i < count; i++) { + m.visitLdcInsn("pool_heavy_secret_literal_number_" + i); + m.visitInsn(org.objectweb.asm.Opcodes.POP); + } + m.visitInsn(org.objectweb.asm.Opcodes.RETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + } + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 71); + byte[] out = t.transform(w.toByteArray()); + assertTrue("hoisting must be capped, leaving some literals plaintext", + t.getPoolFullLiteralCount() > 0); + // Every distinct literal is either encrypted or reported as pool-excluded; nothing is lost. + assertEquals(count, t.getEncryptedCount() + t.getPoolFullLiteralCount()); + // The class assembles and verifies -- no ClassTooLargeException. + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + } + @Test public void oversizedInitializerIsSplitAcrossHelpers() throws Exception { // A generated class with enough distinct literals that a single would exceed the // 65535-byte method limit. Hoisting must split the initializer across helper methods so the // class still assembles, verifies and runs -- rather than throwing MethodTooLargeException. - // 8000 fields * ~9 bytes/init-unit ~= 72 KB, comfortably past the limit without the split. - int count = 8000; + // 6000 fields * ~11 bytes/init-unit ~= 66 KB, past the method limit but well under the + // constant-pool budget (so this exercises the method-size split, not the pool cap). + int count = 6000; String probeValue = "big_clinit_secret_literal_number_0"; org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 336b2eef6e4..26495873e89 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -140,6 +140,18 @@ void messageWithWhitespaceFreeIdentityIsNotAFrame() { assertTrue(scrubbed.indexOf("Bar.qux:998877") >= 0, scrubbed); } + @Test + void frameUrlQueryDataIsScrubbedButCoordinateSurvives() { + // A JS frame URL can carry user data in its query; the terminal line:column coordinate must + // survive for symbolication, but the identity/URL/query before it still gets scrubbed. + String stack = "TypeError: boom\n" + + " at f (https://host/app.js?account=123456:1:42)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account=[num]") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf(":1:42)") >= 0, scrubbed); + } + @Test void customScrubMessageOverrideReachesRawStack() { // An app that redacts an app-specific token by overriding scrubMessage must have it redacted From ebb8ef8d0a4a0a1a22a0e325970feb16a425440b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:04:57 +0700 Subject: [PATCH 054/110] Keep BackgroundWorker; fall back to per-access encryption instead of leaving literals plaintext - Add BackgroundWorker to the name-bound background-callback keeps: IOSImplementation.scheduleBackgroundWork persists request.getWorkerClass() and a later cold launch resolves it with Class.forName, so renaming the worker across an app update silently prevents an already-scheduled task from running. - The round-50 pool cap (and the -full case) left method-body literals PLAINTEXT in a pool-heavy class. Because the decoder interns, a value hoisted+interned in one class then compared with a plaintext copy here would be != on ParparVM's deduplicated pool (the transform's own == invariant). Fixed by falling back to PER-ACCESS encryption for the whole class when hoisting would overflow the constant pool or the is full: per-access adds no per-value field (no pool growth) and keeps every occurrence encrypted and interned, so the cross-class == holds. Removes the pool-full plaintext exclusion; the method-body path already reports the genuine floor (a single near-64KB method). The static-final -full case still stays plaintext -- that field's value is dead (all reads inlined+encrypted) -- and is still reported. Covered by poolHeavyClassEncryptsPerAccess..., clinitTooFullFallsBackToPerAccess..., and staticFinalConstantWithFullClinitStaysPlaintextAndReports. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/BuiltinKeepRules.java | 1 + .../codename1/hardening/HardeningEngine.java | 7 -- .../hardening/StringEncryptTransform.java | 74 +++++++++---------- .../hardening/BuiltinKeepRulesTest.java | 2 + .../hardening/StringEncryptTransformTest.java | 58 +++++++++++---- 5 files changed, 82 insertions(+), 60 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java index 0b714a37ee0..d759470c578 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java @@ -78,6 +78,7 @@ public static List rules(String mainClass) { r.add("-keep class * implements com.codename1.location.GeofenceListener { *; }"); r.add("-keep class * implements com.codename1.location.LocationListener { *; }"); r.add("-keep class * implements com.codename1.background.BackgroundFetch { *; }"); + r.add("-keep class * implements com.codename1.background.BackgroundWorker { *; }"); // JNI/native method names must not move. r.add("-keepclasseswithmembernames,includedescriptorclasses class * { native ; }"); // enum values()/valueOf(String) resolve constants by name, so they are kept -- this is diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 287849cac70..5083519db4e 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -189,7 +189,6 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int clinitFullLiterals = 0; int methodFullLiterals = 0; int annotationLiterals = 0; - int poolFullLiterals = 0; boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); if (stringsApplied) { // In "constants" mode, first collect the values declared as static-final String @@ -217,7 +216,6 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi clinitFullLiterals += t.getClinitFullLiteralCount(); methodFullLiterals += t.getMethodFullLiteralCount(); annotationLiterals += t.getAnnotationLiteralCount(); - poolFullLiterals += t.getPoolFullLiteralCount(); } } @@ -345,11 +343,6 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "because their enclosing method is already near the 65535-byte limit and the " + "per-access decode call would overflow it"); } - if (stringsApplied && poolFullLiterals > 0) { - result.getWarnings().add(poolFullLiterals + " string literal(s) were left in plaintext " - + "because hoisting them would push the class past the 65535-entry constant-pool " - + "limit; split the generated class or reduce its distinct literals"); - } if (stringsApplied && annotationLiterals > 0) { // Annotation element values live in the annotation metadata, not an LDC or a ConstantValue, // so no encryption channel reaches them. CN1 has no reflection to read them back, so this is diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 0f08be8d8ab..079f66f03f2 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -104,7 +104,6 @@ public final class StringEncryptTransform { private int clinitFullLiteralCount; private int methodFullLiteralCount; private int annotationLiteralCount; - private int poolFullLiteralCount; /** The input class's constant-pool item count, so hoisting can stay under the 65535-entry limit. */ private int poolBaseItems; @@ -235,15 +234,6 @@ public int getAnnotationLiteralCount() { return annotationLiteralCount; } - /** - * The number of literals left unhoisted (plaintext) because hoisting them all -- each adds a - * synthetic field and its field-reference constants -- would push the class past the JVM's - * 65,535-entry constant-pool limit (which makes ASM throw {@code ClassTooLargeException}). Rare (a - * generated class with ~13k+ distinct selected literals); reported rather than aborting the build. - */ - public int getPoolFullLiteralCount() { - return poolFullLiteralCount; - } /** Encrypts {@code classBytes}, returning the transformed bytes (or the input if nothing changed). */ public byte[] transform(byte[] classBytes) { @@ -310,12 +300,7 @@ && f.value instanceof String && shouldEncryptLiteral((String) f.value)) { // access; interface method bodies are rare and not hot loops. if (cn.methods != null) { if (isInterface) { - for (MethodNode mn : cn.methods) { - if (mn.instructions == null || decoderName.equals(mn.name)) { - continue; - } - changed |= encryptMethodLiterals(cn, mn, base, isInterface, decoderName); - } + changed |= encryptAllMethodsPerAccess(cn, base, decoderName, true); } else { changed |= hoistMethodLiterals(cn, base, decoderName); } @@ -338,6 +323,28 @@ && f.value instanceof String && shouldEncryptLiteral((String) f.value)) { return cw.toByteArray(); } + /** + * Encrypts every method-body literal in {@code cn} PER ACCESS (an {@code LDC} ciphertext + a decoder + * {@code INVOKESTATIC}), rather than hoisting distinct values to fields. Used for interfaces (whose + * fields are public) and as the fallback when hoisting a class would overflow the constant pool or + * its {@code } is already full: per-access adds no per-value field, so it does not grow the + * pool, and it keeps EVERY occurrence of a value encrypted and interned -- preserving a valid + * cross-class literal {@code ==} on ParparVM instead of leaving some copies plaintext. + */ + private boolean encryptAllMethodsPerAccess(ClassNode cn, int base, String decoderName, + boolean isInterface) { + boolean changed = false; + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if (mn.instructions == null || decoderName.equals(mn.name)) { + continue; + } + changed |= encryptMethodLiterals(cn, mn, base, isInterface, decoderName); + } + } + return changed; + } + private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boolean isInterface, String decoderName) { boolean changed = false; @@ -651,44 +658,35 @@ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) } } } + if (valueToField.isEmpty()) { + return false; + } // Bound hoisting by constant-pool growth: each hoisted literal adds several pool entries (its // field name Utf8, a NameAndType, a Fieldref, the ciphertext Utf8 + String), so a class with // tens of thousands of distinct literals could exceed the JVM's 65535-entry limit and make ASM - // throw ClassTooLargeException. Cap the number hoisted at what the pool budget allows and leave - // the rest plaintext, reported. First-seen order is kept (LinkedHashMap), so the cut is stable. + // throw ClassTooLargeException. When hoisting them all would overflow, fall back to per-access + // encryption for the whole class: it adds no per-value field, so it does not grow the pool, and + // -- crucially -- it keeps EVERY occurrence encrypted and interned rather than leaving some + // plaintext, so a value hoisted in one class still compares == to its per-access copy here. int budget = (SAFE_POOL_ITEMS - poolBaseItems) / POOL_ITEMS_PER_HOIST; if (budget < 0) { budget = 0; } if (valueToField.size() > budget) { - java.util.Iterator> it = - valueToField.entrySet().iterator(); - int kept = 0; - while (it.hasNext()) { - it.next(); - if (kept < budget) { - kept++; - } else { - it.remove(); - poolFullLiteralCount++; - } - } - } - if (valueToField.isEmpty()) { - return false; + return encryptAllMethodsPerAccess(cn, base, decoderName, false); } - // 2. Build the initializer BEFORE mutating anything, so a class whose cannot - // accommodate it is left untouched (its literals stay plaintext, reported) rather than - // half-transformed with fields that are declared but never initialized. + // 2. Build the initializer BEFORE mutating anything. InsnList init = new InsnList(); for (java.util.Map.Entry e : valueToField.entrySet()) { init.add(new LdcInsnNode(encode(e.getKey(), base))); init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, false)); init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, e.getValue(), "Ljava/lang/String;")); } + // If the class's is already so full it cannot hold the decode init, fall back to + // per-access here too (no growth), for the same reason: keep every occurrence encrypted + // and interned rather than leaving these method-body literals plaintext. if (!clinitCanAccept(cn, init)) { - clinitFullLiteralCount += valueToField.size(); - return false; + return encryptAllMethodsPerAccess(cn, base, decoderName, false); } // 3. Commit. Replace each LDC of a hoisted value with a GETSTATIC of its field in the ORIGINAL // method bodies; the standalone init is inserted into only afterwards, so its own diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java index 3d2a4b0ee2b..7ed2c4e2314 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java @@ -92,6 +92,8 @@ public void keepsNameBoundBackgroundCallbacks() { "-keep class * implements com.codename1.location.LocationListener { *; }")); assertTrue(rules.contains( "-keep class * implements com.codename1.background.BackgroundFetch { *; }")); + assertTrue(rules.contains( + "-keep class * implements com.codename1.background.BackgroundWorker { *; }")); // The same rules are exported to R8 on Android (where R8 does the renaming). assertTrue(BuiltinKeepRules.forR8("com.example.MyApp").contains( "-keep class * implements com.codename1.location.GeofenceListener { *; }")); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index b53e6f2849d..6639894e469 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -327,21 +327,43 @@ public void existingLargeClinitIsSplitWhenCombinedWithNewInit() throws Exception } @Test - public void clinitTooFullLeavesLiteralPlaintextAndReports() throws Exception { + public void staticFinalConstantWithFullClinitStaysPlaintextAndReports() throws Exception { + // A static-final ConstantValue can only be moved into ; when is already full it + // cannot, so the field keeps its plaintext. That is safe (javac inlined every read as an LDC, + // which IS encrypted, so the field value is dead), and reported via getClinitFullLiteralCount. + org.objectweb.asm.ClassWriter w = classWithBigClinit("app/FullClinitStatic", 31500); // ~63 KB + w.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "SECRET", "Ljava/lang/String;", null, + "a static-final constant with a full clinit").visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 5); + byte[] out = t.transform(w.toByteArray()); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + assertEquals("the un-encryptable static-final is reported", 1, t.getClinitFullLiteralCount()); + assertTrue("its constant stays plaintext rather than the build aborting", + StringEncryptTransform.containsStringLiteral(out, "a static-final constant with a full clinit")); + } + + @Test + public void clinitTooFullFallsBackToPerAccessEncryption() throws Exception { // The existing is so close to the limit that not even a helper CALL would fit, so the - // literal cannot be hoisted: it is left plaintext and counted, rather than aborting the build. + // literal cannot be HOISTED. Rather than leaving it plaintext (which would break a cross-class + // == against an encrypted copy elsewhere), it is encrypted PER ACCESS -- no growth. org.objectweb.asm.ClassWriter w = classWithBigClinit("app/FullClinit", 31500); // ~63 KB - addStringGetter(w, "probe", "a literal that cannot be hoisted here"); + addStringGetter(w, "probe", "a literal encrypted per access here"); w.visitEnd(); StringEncryptTransform t = new StringEncryptTransform(true, 5); byte[] out = t.transform(w.toByteArray()); CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, new java.io.PrintWriter(new java.io.StringWriter())); - assertEquals("the un-hoistable literal is reported", 1, t.getClinitFullLiteralCount()); - assertEquals("nothing was encrypted for this class", 0, t.getEncryptedCount()); - assertTrue("the literal stays plaintext rather than the build aborting", - StringEncryptTransform.containsStringLiteral(out, "a literal that cannot be hoisted here")); + assertTrue("the literal is encrypted per access, not left plaintext", t.getEncryptedCount() >= 1); + assertFalse("its plaintext must be gone", + StringEncryptTransform.containsStringLiteral(out, "a literal encrypted per access here")); + Class c = new ByteLoader().define("app.FullClinit", out); + assertEquals("a literal encrypted per access here", c.getMethod("probe").invoke(null)); } @Test @@ -460,10 +482,12 @@ public void interfaceMethodTooLargeSkipsAndReportsLiterals() throws Exception { } @Test - public void hoistingIsCappedByConstantPoolBudget() throws Exception { + public void poolHeavyClassEncryptsPerAccessInsteadOfOverflowingOrLeavingPlaintext() throws Exception { // A class with far more distinct literals than the constant pool can hold once each is hoisted - // (a field + its reference constants) would overflow the 65535-entry pool. The transform must - // cap hoisting and leave the rest plaintext (reported) rather than throw ClassTooLargeException. + // (a field + its reference constants) would overflow the 65535-entry pool. Rather than + // ClassTooLargeException -- or leaving some plaintext (which breaks cross-class ==) -- the class + // falls back to per-access encryption: no per-value field, so no pool growth, and every + // occurrence is encrypted and interned. A probe reuses one value so a decode is checked. int count = 12000; org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, @@ -481,17 +505,21 @@ public void hoistingIsCappedByConstantPoolBudget() throws Exception { m.visitMaxs(1, 0); m.visitEnd(); } + addStringGetter(w, "probe", "pool_heavy_secret_literal_number_0"); w.visitEnd(); StringEncryptTransform t = new StringEncryptTransform(true, 71); byte[] out = t.transform(w.toByteArray()); - assertTrue("hoisting must be capped, leaving some literals plaintext", - t.getPoolFullLiteralCount() > 0); - // Every distinct literal is either encrypted or reported as pool-excluded; nothing is lost. - assertEquals(count, t.getEncryptedCount() + t.getPoolFullLiteralCount()); - // The class assembles and verifies -- no ClassTooLargeException. + // Per-access encrypts every OCCURRENCE (count filler + the probe reuse), where hoisting would + // encrypt each distinct value once; count+1 therefore proves the per-access fallback ran. + assertEquals(count + 1, t.getEncryptedCount()); + assertFalse("no literal is left plaintext", + StringEncryptTransform.containsStringLiteral(out, "pool_heavy_secret_literal_number_5")); + // The class assembles and verifies -- no ClassTooLargeException -- and a value decodes. CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.PoolHeavy", out); + assertEquals("pool_heavy_secret_literal_number_0", c.getMethod("probe").invoke(null)); } @Test From 9de216b508ae72a3716a318f29dbd03bb5032bd7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:29:06 +0700 Subject: [PATCH 055/110] Scrub coordinate-free frame mimics; exclude un-encryptable literals jar-wide for == consistency - scrubFrameLine ran a coordinate-free frame ((Native Method)/(Unknown Source)) through email scrubbing only, so a message mimicking that shape (at account123456failed (Native Method)) kept its id. With no coordinate to protect, the whole line now goes through scrubMessage; a real frame's dotted identity has no long digit run and is unchanged. Covered by coordinateFreeFrameMimicIsScrubbed. - The pool cap and method-size exclusions were per-class/per-occurrence, so a value encrypted+interned in one class but left plaintext in another would fail a valid literal == on ParparVM's deduplicated pool. Fixed jar-wide: the engine now runs a first pass, collects every value any class could not encrypt (a method too full for the decode call via getNewlyExcluded, or a class whose pool cannot fit the decoder -- a new gate that reserves the decoder's fixed overhead and skips the class), and re-runs with those values excluded so a value is encrypted in every class or none. The second pass runs only when the exclusion set is non-empty. Covered by jarExcludedValueIsLeftPlaintextEvenInAllMode, poolTooFullForDecoderSkipsClassAndReportsJarWideExclusion, and the interface method-full test. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 6 +- .../codename1/hardening/HardeningEngine.java | 55 +++++++++--- .../hardening/StringEncryptTransform.java | 90 ++++++++++++++++++- .../hardening/StringEncryptTransformTest.java | 56 ++++++++++++ .../crash/PiiScrubberRawStackTest.java | 16 ++++ 5 files changed, 208 insertions(+), 15 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index c1041be18a8..5df2527738b 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -137,7 +137,11 @@ public String scrubRawStack(String rawStack) { private String scrubFrameLine(String line) { int loc = trailingLocationStart(line); if (loc <= 0) { - return scrubEmails(line); + // A coordinate-free frame ((Native Method)/(Unknown Source)): there is no numeric coordinate + // to protect, so run the whole line through message scrubbing. A real such frame's identity + // is a dotted class.method with no long digit run, so it is unchanged; a message that merely + // mimics the shape (at account123456failed (Native Method)) has its id masked. + return scrubMessage(line); } return scrubMessage(line.substring(0, loc)) + line.substring(loc); } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 5083519db4e..5258dd98254 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -187,8 +187,8 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int oversizedLiterals = 0; int condyLiterals = 0; int clinitFullLiterals = 0; - int methodFullLiterals = 0; int annotationLiterals = 0; + int jarExcludedLiterals = 0; boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); if (stringsApplied) { // In "constants" mode, first collect the values declared as static-final String @@ -201,22 +201,52 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi StringEncryptTransform.collectConstantValues(cls, constantValues); } } + // Pass 1 (from a snapshot of the input bytes): transform every class, tally the counts, and + // collect the values any class could NOT encrypt (a method too full for the decode call, or a + // class whose pool cannot fit the decoder). A value encrypted+interned in one class but left + // plaintext in another would fail a valid literal == on ParparVM's deduplicated pool, so + // those values must be excluded jar-wide -- encrypted everywhere or nowhere. + java.util.Map original = new java.util.HashMap(renamed); + java.util.Set jarExcluded = new java.util.HashSet(); for (Map.Entry e : renamed.entrySet()) { StringEncryptTransform t = new StringEncryptTransform( - cfg.isEncryptAllStrings(), seed, hierarchy, constantValues); - byte[] out = t.transform(e.getValue()); - if (out != e.getValue()) { - e.setValue(out); - } + cfg.isEncryptAllStrings(), seed, hierarchy, constantValues, null); + e.setValue(t.transform(e.getValue())); + jarExcluded.addAll(t.getNewlyExcluded()); encryptedStrings += t.getEncryptedCount(); concatLiterals += t.getConcatLiteralCount(); legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); oversizedLiterals += t.getOversizedLiteralCount(); condyLiterals += t.getCondyLiteralCount(); clinitFullLiterals += t.getClinitFullLiteralCount(); - methodFullLiterals += t.getMethodFullLiteralCount(); annotationLiterals += t.getAnnotationLiteralCount(); } + // Pass 2 only when pass 1 found values it could not consistently encrypt: re-transform every + // class from the original bytes with those values excluded (so a value is plaintext in all + // classes or none), replacing pass 1's outputs and re-tallying. In the common case the + // exclusion set is empty and pass 1's result stands -- one transform per class. + if (!jarExcluded.isEmpty()) { + encryptedStrings = 0; + concatLiterals = 0; + legacyInterfaceConstants = 0; + oversizedLiterals = 0; + condyLiterals = 0; + clinitFullLiterals = 0; + annotationLiterals = 0; + for (Map.Entry e : renamed.entrySet()) { + StringEncryptTransform t = new StringEncryptTransform( + cfg.isEncryptAllStrings(), seed, hierarchy, constantValues, jarExcluded); + e.setValue(t.transform(original.get(e.getKey()))); + encryptedStrings += t.getEncryptedCount(); + concatLiterals += t.getConcatLiteralCount(); + legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); + oversizedLiterals += t.getOversizedLiteralCount(); + condyLiterals += t.getCondyLiteralCount(); + clinitFullLiterals += t.getClinitFullLiteralCount(); + annotationLiterals += t.getAnnotationLiteralCount(); + } + } + jarExcludedLiterals = jarExcluded.size(); } int guardedMethods = 0; @@ -338,10 +368,13 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "because the class's static initializer is already near the 65535-byte method " + "limit and could not hold the decode step"); } - if (stringsApplied && methodFullLiterals > 0) { - result.getWarnings().add(methodFullLiterals + " string literal(s) were left in plaintext " - + "because their enclosing method is already near the 65535-byte limit and the " - + "per-access decode call would overflow it"); + if (stringsApplied && jarExcludedLiterals > 0) { + // Values that at least one class could not encrypt (a method already near the 65535-byte + // limit, or a class whose constant pool cannot fit the decoder) are left plaintext in EVERY + // class, so a decoded+interned copy never compares != to a plaintext copy on ParparVM. + result.getWarnings().add(jarExcludedLiterals + " distinct string value(s) were left in " + + "plaintext in every class because at least one class could not encrypt them (a " + + "method or constant pool near the JVM limit); those literals stay readable"); } if (stringsApplied && annotationLiterals > 0) { // Annotation element values live in the annotation metadata, not an LDC or a ConstantValue, diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 079f66f03f2..e8286341f4c 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -91,11 +91,28 @@ public final class StringEncryptTransform { * hoisting rather than discovering the overflow only when ASM writes the class. */ private static final int POOL_ITEMS_PER_HOIST = 6; + /** + * Conservative constant-pool entries the synthetic decoder itself adds (its name/descriptor, the + * {@code Methodref} the encrypt sites share, and the {@code String.toCharArray}/{@code intern} + * references). Reserved from the pool budget, and gated: a class whose pool cannot fit even this + * fixed overhead cannot be encrypted at all. + */ + private static final int DECODER_POOL_OVERHEAD = 32; private final boolean encryptAllStrings; private final int seed; private final ClassLoader hierarchy; private final java.util.Set constantValues; + /** + * Values that must be left plaintext in EVERY class (a jar-wide exclusion). When a value cannot be + * encrypted in some class (a method too full for the per-access call, or a class whose pool cannot + * fit the decoder), encrypting it in the OTHER classes would break a valid literal {@code ==} on + * ParparVM (the decoded copy is interned, the plaintext copy is not). The engine collects these in a + * first pass and re-runs with them excluded so a value is encrypted everywhere or nowhere. + */ + private final java.util.Set jarExcluded; + /** Values this transform left plaintext for a size/pool reason, for the engine to exclude jar-wide. */ + private final java.util.Set newlyExcluded = new java.util.HashSet(); private int encryptedCount; private int concatLiteralCount; private int legacyInterfaceConstantCount; @@ -108,11 +125,11 @@ public final class StringEncryptTransform { private int poolBaseItems; public StringEncryptTransform(boolean encryptAllStrings, int seed) { - this(encryptAllStrings, seed, null, null); + this(encryptAllStrings, seed, null, null, null); } public StringEncryptTransform(boolean encryptAllStrings, int seed, ClassLoader hierarchy) { - this(encryptAllStrings, seed, hierarchy, null); + this(encryptAllStrings, seed, hierarchy, null, null); } /** @@ -127,10 +144,31 @@ public StringEncryptTransform(boolean encryptAllStrings, int seed, ClassLoader h */ public StringEncryptTransform(boolean encryptAllStrings, int seed, ClassLoader hierarchy, java.util.Set constantValues) { + this(encryptAllStrings, seed, hierarchy, constantValues, null); + } + + /** + * @param jarExcluded values to force-leave plaintext in this class (a jar-wide exclusion collected + * by the engine so a value is encrypted everywhere or nowhere); may be + * {@code null}. See {@link #getNewlyExcluded()}. + */ + public StringEncryptTransform(boolean encryptAllStrings, int seed, ClassLoader hierarchy, + java.util.Set constantValues, java.util.Set jarExcluded) { this.encryptAllStrings = encryptAllStrings; this.seed = seed; this.hierarchy = hierarchy; this.constantValues = constantValues; + this.jarExcluded = jarExcluded; + } + + /** + * Values this transform left plaintext for a method-size or constant-pool reason. The engine unions + * these across the jar and re-runs the transform with them excluded, so a value that cannot be + * encrypted in one class is left plaintext in ALL classes -- keeping a valid literal {@code ==} on + * ParparVM (where a decoded literal is interned but a compile-time literal is not). + */ + public java.util.Set getNewlyExcluded() { + return newlyExcluded; } /** Collects the values of {@code static final String} fields in {@code classBytes} into {@code out}. */ @@ -288,6 +326,17 @@ && f.value instanceof String && shouldEncryptLiteral((String) f.value)) { int base = keyBase(cn.name); boolean changed = false; + // The decoder method and its references are added whenever anything is encrypted, at a fixed + // constant-pool cost. If the class's pool is already so full that even that overhead would not + // fit, nothing can be encrypted here without ClassTooLargeException. Skip the class and record + // the values it would have selected as jar-wide exclusions, so those values are left plaintext in + // every OTHER class too -- otherwise a value encrypted+interned elsewhere would compare != to the + // plaintext copy here on ParparVM. + if (poolBaseItems + DECODER_POOL_OVERHEAD > SAFE_POOL_ITEMS) { + collectSelectedValues(cn, newlyExcluded); + return classBytes; + } + // Channel 1: LDC string literals in method bodies. In "all" mode every literal is // encrypted; in "constants" mode only literals whose value was declared as a // static-final String constant somewhere in the jar -- which is exactly the set javac @@ -359,7 +408,11 @@ private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boo LdcInsnNode ldc = (LdcInsnNode) insn; if (ldc.cst instanceof String && shouldEncryptLiteral((String) ldc.cst)) { if (currentBytes + DECODER_CALL_BYTES > MethodSize.SAFE_LIMIT) { + // This method cannot grow to hold the decode call. Record the value as a jar-wide + // exclusion so the engine leaves it plaintext in every class -- encrypting it + // elsewhere would break a valid literal == against this plaintext copy. methodFullLiteralCount++; + newlyExcluded.add((String) ldc.cst); } else { String plain = (String) ldc.cst; // shouldEncrypt already rejected any value whose ciphertext could overflow the @@ -606,6 +659,31 @@ private int countOversizedLiterals(ClassNode cn) { return skipped.size(); } + /** Collects the distinct values the mode would encrypt (method LDCs + static-final constants). */ + private void collectSelectedValues(ClassNode cn, java.util.Set out) { + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String + && shouldEncryptLiteral((String) ((LdcInsnNode) insn).cst)) { + out.add((String) ((LdcInsnNode) insn).cst); + } + } + } + } + if (cn.fields != null) { + for (FieldNode fn : cn.fields) { + if ((fn.access & Opcodes.ACC_STATIC) != 0 && fn.value instanceof String + && shouldEncrypt((String) fn.value)) { + out.add((String) fn.value); + } + } + } + } + /** True when {@code s}'s worst-case ciphertext would overflow the 65535-byte constant-pool limit. */ private static boolean isOversized(String s) { return s != null && (long) s.length() * 3 > 65535; @@ -616,6 +694,9 @@ private boolean modeSelectsLiteral(String s) { if (s == null || s.length() <= 2) { return false; } + if (jarExcluded != null && jarExcluded.contains(s)) { + return false; + } return encryptAllStrings || (constantValues != null && constantValues.contains(s)); } @@ -668,7 +749,7 @@ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) // encryption for the whole class: it adds no per-value field, so it does not grow the pool, and // -- crucially -- it keeps EVERY occurrence encrypted and interned rather than leaving some // plaintext, so a value hoisted in one class still compares == to its per-access copy here. - int budget = (SAFE_POOL_ITEMS - poolBaseItems) / POOL_ITEMS_PER_HOIST; + int budget = (SAFE_POOL_ITEMS - poolBaseItems - DECODER_POOL_OVERHEAD) / POOL_ITEMS_PER_HOIST; if (budget < 0) { budget = 0; } @@ -1014,6 +1095,9 @@ private boolean shouldEncryptLiteral(String s) { if (!shouldEncrypt(s)) { return false; } + if (jarExcluded != null && jarExcluded.contains(s)) { + return false; + } if (encryptAllStrings) { return true; } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 6639894e469..bcba8503afb 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -435,6 +435,61 @@ private static boolean containsRawBytes(byte[] haystack, String needle) throws E return false; } + @Test + public void jarExcludedValueIsLeftPlaintextEvenInAllMode() throws Exception { + // A value the engine marked as a jar-wide exclusion must be left plaintext here, so it is not + // encrypted in this class while a copy stays plaintext in the class that could not encrypt it. + String excluded = "a jar-wide excluded secret value"; + String encrypted = "an ordinary encryptable secret value"; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Excl", null, "java/lang/Object", null); + addStringGetter(w, "excluded", excluded); + addStringGetter(w, "kept", encrypted); + w.visitEnd(); + + java.util.Set jarExcluded = new java.util.HashSet(); + jarExcluded.add(excluded); + StringEncryptTransform t = new StringEncryptTransform(true, 3, null, null, jarExcluded); + byte[] out = t.transform(w.toByteArray()); + assertTrue("the excluded value stays plaintext", + StringEncryptTransform.containsStringLiteral(out, excluded)); + assertFalse("the ordinary value is still encrypted", + StringEncryptTransform.containsStringLiteral(out, encrypted)); + } + + @Test + public void poolTooFullForDecoderSkipsClassAndReportsJarWideExclusion() throws Exception { + // A class whose constant pool is already within the decoder's overhead of the 65535 limit cannot + // encrypt anything (the decoder itself would not fit). It is skipped, and its selected values are + // reported as jar-wide exclusions so they stay plaintext in every other class too. + int count = 30000; // ~60000 pool entries (Utf8 + String each) -> past the decoder-reserve gate + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/PoolFull", null, "java/lang/Object", null); + int perMethod = 250; + for (int start = 0; start < count; start += perMethod) { + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "m" + start, "()V", null, null); + m.visitCode(); + for (int i = start; i < start + perMethod && i < count; i++) { + m.visitLdcInsn("pool_full_secret_literal_number_" + i); + m.visitInsn(org.objectweb.asm.Opcodes.POP); + } + m.visitInsn(org.objectweb.asm.Opcodes.RETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + } + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 3); + byte[] out = t.transform(w.toByteArray()); + assertEquals("nothing can be encrypted when even the decoder would not fit", 0, t.getEncryptedCount()); + assertTrue("its values are reported for jar-wide exclusion", t.getNewlyExcluded().size() > 0); + assertTrue("the class is returned unchanged (plaintext)", + StringEncryptTransform.containsStringLiteral(out, "pool_full_secret_literal_number_0")); + } + @Test public void interfaceMethodTooLargeSkipsAndReportsLiterals() throws Exception { // The interface path decodes per access (an INVOKESTATIC after each LDC). A method already near @@ -472,6 +527,7 @@ public void interfaceMethodTooLargeSkipsAndReportsLiterals() throws Exception { StringEncryptTransform t = new StringEncryptTransform(true, 3); byte[] out = t.transform(w.toByteArray()); assertEquals("the near-limit method's literals are reported", 5, t.getMethodFullLiteralCount()); + assertEquals("and recorded for jar-wide exclusion", 5, t.getNewlyExcluded().size()); assertTrue("the normal method's literal is still encrypted", t.getEncryptedCount() >= 1); CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, new java.io.PrintWriter(new java.io.StringWriter())); diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 26495873e89..119521b1909 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -152,6 +152,22 @@ void frameUrlQueryDataIsScrubbedButCoordinateSurvives() { assertTrue(scrubbed.indexOf(":1:42)") >= 0, scrubbed); } + @Test + void coordinateFreeFrameMimicIsScrubbed() { + // A message mimicking a coordinate-free frame ("(Native Method)"/"(Unknown Source)") has no + // coordinate to protect, so the whole line is scrubbed; a real such frame's dotted identity has + // no long digit run and is unchanged. + String stack = "java.lang.RuntimeException: bad\n" + + "at account123456failed (Native Method)\n" + + "at other654321thing (Unknown Source)\n" + + "\tat com.foo.Bar.baz(Native Method)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account[num]failed") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("654321") < 0, scrubbed); + assertTrue(scrubbed.indexOf("com.foo.Bar.baz(Native Method)") >= 0, scrubbed); + } + @Test void customScrubMessageOverrideReachesRawStack() { // An app that redacts an app-specific token by overriding scrubMessage must have it redacted From 773b977814250ff50484cd245a7a6b75fa168e2b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:39:33 +0700 Subject: [PATCH 056/110] Preserve only the terminal two coordinate groups in a frame line trailingLocationStart consumed an unbounded run of trailing : groups, so a frame URL carrying colon-delimited user data before the real coordinate (host/account:123456:1:42) had the whole tail -- including account 123456 -- treated as the coordinate and appended verbatim, bypassing scrubbing. A real location is :line or :line:column, so it now consumes at most two numeric groups; the earlier :123456 stays in the scrubbed head. Covered by onlyTheTerminalTwoCoordinateGroupsArePreserved. Co-Authored-By: Claude Opus 4.8 --- CodenameOne/src/com/codename1/crash/PiiScrubber.java | 10 ++++++---- .../com/codename1/crash/PiiScrubberRawStackTest.java | 12 ++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index 5df2527738b..9c48ba6ff37 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -235,15 +235,17 @@ private static boolean isDottedOrUrlIdentity(String id) { } /// Index at which a trailing `:` (optionally `::`) location begins, or -1 - /// when the string does not end in one. A single trailing `)` is allowed. + /// when the string does not end in one. A single trailing `)` is allowed. Consumes AT MOST two + /// numeric groups -- a real location is `:line` or `:line:column` -- so colon-delimited data before + /// the coordinate (a URL like `host/account:123456:1:42`) stays in the scrubbable head rather than + /// being preserved as if it were part of the coordinate. private static int trailingLocationStart(String t) { int i = t.length() - 1; if (i >= 0 && t.charAt(i) == ')') { i--; } int start = -1; - boolean matched = true; - while (matched) { + for (int groups = 0; groups < 2; groups++) { int j = i; int digits = 0; while (j >= 0 && t.charAt(j) >= '0' && t.charAt(j) <= '9') { @@ -254,7 +256,7 @@ private static int trailingLocationStart(String t) { start = j; i = j - 1; } else { - matched = false; + break; } } return start; diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 119521b1909..cb9af3af897 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -168,6 +168,18 @@ void coordinateFreeFrameMimicIsScrubbed() { assertTrue(scrubbed.indexOf("com.foo.Bar.baz(Native Method)") >= 0, scrubbed); } + @Test + void onlyTheTerminalTwoCoordinateGroupsArePreserved() { + // A frame URL can carry colon-delimited user data before the real :line:column, e.g. + // host/account:123456:1:42. Only the terminal two numeric groups (:1:42) are the coordinate; + // the earlier :123456 is data and must be scrubbed. + String stack = "TypeError: boom\n" + + " at f (https://host/account:123456:1:42)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account:[num]:1:42)") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + } + @Test void customScrubMessageOverrideReachesRawStack() { // An app that redacts an app-specific token by overriding scrubMessage must have it redacted From 75b94463f38e9f1342fba939bdef07b3865ee7bb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:53:07 +0700 Subject: [PATCH 057/110] Size-check hoist LDC->GETSTATIC replacements; count type-use and record-component annotation strings - Hoisting replaces an LDC with GETSTATIC (always 3 bytes), but an LDC whose constant-pool index is below 256 is only 2, so the replacement can grow a method already near the 65535-byte limit and make ASM throw MethodTooLargeException. MethodSize charges an LDC as 3 (= GETSTATIC), so estimateBytes(mn) already equals the post-hoist size; the hoist path now preflights each method and, when one would exceed the safe bound, excludes its selected values jar-wide -- their LDCs stay plaintext so the method is unchanged. Covered by nearLimitMethodExcludesHoistedLiteralsInsteadOfOverflowing. - The annotation walker only visited ordinary annotation lists. A selected string in a Java 8 type-use annotation (visible/invisibleTypeAnnotations on the class/field/method, or a local-variable annotation) or a record-component annotation was left plaintext with no warning while reporting strings:all. countAnnotationStrings now traverses every type-annotation and record-component collection ASM exposes. Covered by an extended annotationStringsAreCountedAsExcluded (a field type-use annotation). Co-Authored-By: Claude Opus 4.8 --- .../hardening/StringEncryptTransform.java | 51 +++++++++++++++++-- .../hardening/StringEncryptTransformTest.java | 51 ++++++++++++++++++- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index e8286341f4c..a2e69c96d6d 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -549,24 +549,42 @@ && condyHasStringArgument((ConstantDynamic) arg, depth + 1)) { /** * Counts the distinct string values the current mode would encrypt that live in annotation element - * values or defaults (see {@link #getAnnotationLiteralCount()}). Walks class, field, method and - * parameter annotations plus method annotation defaults, recursing into nested annotations and array - * values; skips enum references ({@code String[]}) and {@code Type}, which are not string literals. + * values or defaults (see {@link #getAnnotationLiteralCount()}). Walks EVERY annotation collection + * ASM exposes -- ordinary and type-use annotations on the class, its fields, methods, method + * parameters and local variables, plus record-component annotations and method annotation defaults + * -- recursing into nested annotations and array values; skips enum references ({@code String[]}) and + * {@code Type}, which are not string literals. */ private int countAnnotationStrings(ClassNode cn) { java.util.Set found = new java.util.HashSet(); collectAnnotations(cn.visibleAnnotations, found); collectAnnotations(cn.invisibleAnnotations, found); + collectAnnotations(cn.visibleTypeAnnotations, found); + collectAnnotations(cn.invisibleTypeAnnotations, found); + if (cn.recordComponents != null) { + for (org.objectweb.asm.tree.RecordComponentNode rc : cn.recordComponents) { + collectAnnotations(rc.visibleAnnotations, found); + collectAnnotations(rc.invisibleAnnotations, found); + collectAnnotations(rc.visibleTypeAnnotations, found); + collectAnnotations(rc.invisibleTypeAnnotations, found); + } + } if (cn.fields != null) { for (FieldNode f : cn.fields) { collectAnnotations(f.visibleAnnotations, found); collectAnnotations(f.invisibleAnnotations, found); + collectAnnotations(f.visibleTypeAnnotations, found); + collectAnnotations(f.invisibleTypeAnnotations, found); } } if (cn.methods != null) { for (MethodNode m : cn.methods) { collectAnnotations(m.visibleAnnotations, found); collectAnnotations(m.invisibleAnnotations, found); + collectAnnotations(m.visibleTypeAnnotations, found); + collectAnnotations(m.invisibleTypeAnnotations, found); + collectAnnotations(m.visibleLocalVariableAnnotations, found); + collectAnnotations(m.invisibleLocalVariableAnnotations, found); collectParameterAnnotations(m.visibleParameterAnnotations, found); collectParameterAnnotations(m.invisibleParameterAnnotations, found); collectAnnotationValue(m.annotationDefault, found); @@ -575,7 +593,8 @@ private int countAnnotationStrings(ClassNode cn) { return found.size(); } - private void collectAnnotations(java.util.List list, java.util.Set out) { + private void collectAnnotations(java.util.List list, + java.util.Set out) { if (list == null) { return; } @@ -756,6 +775,30 @@ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) if (valueToField.size() > budget) { return encryptAllMethodsPerAccess(cn, base, decoderName, false); } + // Preflight the LDC -> GETSTATIC growth: GETSTATIC is always 3 bytes, but an LDC whose + // constant-pool index is below 256 is only 2, so replacing hoisted LDCs can grow a method that + // is already near the 65535-byte limit. MethodSize charges an LDC as 3 (= GETSTATIC), so + // estimateBytes(mn) already equals the post-hoist size; if a method would exceed the safe bound, + // exclude its selected values jar-wide -- their LDCs then stay plaintext, so the method is + // unchanged -- rather than let ASM throw MethodTooLargeException. + for (MethodNode mn : cn.methods) { + if (mn.instructions == null || decoderName.equals(mn.name) + || MethodSize.estimateBytes(mn) <= MethodSize.SAFE_LIMIT) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String + && valueToField.containsKey((String) ((LdcInsnNode) insn).cst)) { + newlyExcluded.add((String) ((LdcInsnNode) insn).cst); + } + } + } + if (!newlyExcluded.isEmpty()) { + valueToField.keySet().removeAll(newlyExcluded); + if (valueToField.isEmpty()) { + return false; + } + } // 2. Build the initializer BEFORE mutating anything. InsnList init = new InsnList(); for (java.util.Map.Entry e : valueToField.entrySet()) { diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index bcba8503afb..865934d6a7c 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -410,12 +410,22 @@ public void annotationStringsAreCountedAsExcluded() throws Exception { fav.visit("fieldSecret", "a field annotation secret value"); fav.visitEnd(); fv.visitEnd(); + // A type-use annotation on a field type (visibleTypeAnnotations) also carries a string. + org.objectweb.asm.FieldVisitor tv = w.visitField(org.objectweb.asm.Opcodes.ACC_PRIVATE, + "typed", "Ljava/lang/String;", null, null); + org.objectweb.asm.AnnotationVisitor tav = tv.visitTypeAnnotation( + org.objectweb.asm.TypeReference.newTypeReference( + org.objectweb.asm.TypeReference.FIELD).getValue(), + null, "Lapp/TypeAnno;", true); + tav.visit("typeSecret", "a type-use annotation secret value"); + tav.visitEnd(); + tv.visitEnd(); w.visitEnd(); StringEncryptTransform t = new StringEncryptTransform(true, 9); byte[] out = t.transform(w.toByteArray()); - assertEquals("the three distinct annotation strings are counted, the enum ref is not", - 3, t.getAnnotationLiteralCount()); + assertEquals("the four distinct annotation strings are counted, the enum ref is not", + 4, t.getAnnotationLiteralCount()); // The annotation string survives verbatim in the constant pool (no channel encrypts it). assertTrue("annotation strings stay plaintext (no channel reaches them)", containsRawBytes(out, "a class annotation secret value")); @@ -435,6 +445,43 @@ private static boolean containsRawBytes(byte[] haystack, String needle) throws E return false; } + @Test + public void nearLimitMethodExcludesHoistedLiteralsInsteadOfOverflowing() throws Exception { + // Replacing an LDC (2 bytes for a small pool index) with GETSTATIC (3 bytes) grows a method, so + // hoisting a literal in a method already near the 65535-byte limit could overflow it. Those + // values are excluded jar-wide (their LDCs stay plaintext, method unchanged); a literal in a + // normal method is still encrypted. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/NearLimit", null, "java/lang/Object", null); + // A method already ~60 KB with three encryptable literals. + org.objectweb.asm.MethodVisitor big = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "big", "()V", null, null); + big.visitCode(); + for (int i = 0; i < 30100; i++) { + big.visitInsn(org.objectweb.asm.Opcodes.ICONST_0); + big.visitInsn(org.objectweb.asm.Opcodes.POP); + } + for (int i = 0; i < 3; i++) { + big.visitLdcInsn("a near-limit hoisted secret literal " + i); + big.visitInsn(org.objectweb.asm.Opcodes.POP); + } + big.visitInsn(org.objectweb.asm.Opcodes.RETURN); + big.visitMaxs(1, 0); + big.visitEnd(); + addStringGetter(w, "small", "a normal hoisted secret literal"); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 3); + byte[] out = t.transform(w.toByteArray()); + assertEquals("the near-limit method's three literals are excluded", 3, t.getNewlyExcluded().size()); + assertTrue("the normal method's literal is still encrypted", t.getEncryptedCount() >= 1); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + assertTrue("an excluded literal stays plaintext", + StringEncryptTransform.containsStringLiteral(out, "a near-limit hoisted secret literal 0")); + } + @Test public void jarExcludedValueIsLeftPlaintextEvenInAllMode() throws Exception { // A value the engine marked as a jar-wide exclusion must be left plaintext here, so it is not From 447885792e85125ac1b2b10ead23867d27d1863f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:08:10 +0700 Subject: [PATCH 058/110] Budget the constant pool for static-final fields and control-flow guards The round-50 pool budget covered only hoisted method literals. Two other transforms grow the pool without a check: - encryptStaticFinalStrings adds a PUTSTATIC Fieldref + NameAndType per constant, so a class with thousands of static-final String fields could exceed the 65535-entry limit even with the input pool under the ceiling. A running pool budget (shared with hoisting via poolItemsRemaining, reserving the decoder overhead) now caps it; constants past the budget keep their plaintext -- safe, since a static-final's value is dead once javac inlines every read. Covered by staticFinalEncryptionIsCappedByConstantPoolBudget. - ControlFlowTransform adds a guard field and Runtime/RuntimeException references without a pool check. It now skips (and reports) a class whose pool cannot fit that fixed overhead, like the existing -size guard. Covered by classWithNearFullPoolIsSkippedNotOverflowed. SAFE_POOL_ITEMS moves to MethodSize so both transforms share it. Co-Authored-By: Claude Opus 4.8 --- .../hardening/ControlFlowTransform.java | 18 ++++++++- .../codename1/hardening/HardeningEngine.java | 12 +++--- .../com/codename1/hardening/MethodSize.java | 6 +++ .../hardening/StringEncryptTransform.java | 39 ++++++++++++++++--- .../hardening/ControlFlowTransformTest.java | 32 +++++++++++++++ .../hardening/StringEncryptTransformTest.java | 26 +++++++++++++ 6 files changed, 122 insertions(+), 11 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java index 1c5e40ec976..9fcc9cfb4a5 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java @@ -61,6 +61,13 @@ public final class ControlFlowTransform { private static final int GUARD_BYTES = 16; /** Widest encoding of the guard-field setup prepended to {@code } (2 calls + PUTSTATIC). */ private static final int GUARD_INIT_BYTES = 16; + /** + * Conservative constant-pool entries the guard adds: the guard field (Utf8/Fieldref/NameAndType), + * the {@code Runtime}/{@code getRuntime}/{@code availableProcessors} references, and the + * {@code RuntimeException} constructor reference. Fixed per class, so a class whose pool is already + * near the 65535-entry limit cannot be guarded at all. + */ + private static final int GUARD_POOL_OVERHEAD = 32; private final ClassLoader hierarchy; private final int intensity; @@ -100,11 +107,20 @@ public int getOversizedMethods() { public byte[] transform(byte[] classBytes) { ClassNode cn = new ClassNode(); - new ClassReader(classBytes).accept(cn, ClassReader.SKIP_FRAMES); + ClassReader reader = new ClassReader(classBytes); + reader.accept(cn, ClassReader.SKIP_FRAMES); if ((cn.access & Opcodes.ACC_INTERFACE) != 0) { return classBytes; } + // The guard adds a field and references (Runtime, RuntimeException, the guard field) at a fixed + // constant-pool cost. If the class's pool is already so full it cannot fit that overhead, the + // class cannot be guarded without ClassTooLargeException. Skip it and report the methods that + // stay plain rather than aborting the entire hardened build. + if (reader.getItemCount() + GUARD_POOL_OVERHEAD > MethodSize.SAFE_POOL_ITEMS) { + oversizedMethods += countGuardable(cn); + return classBytes; + } // Pick a guard field name that collides with no existing member, so a class that happens to // declare a zq$cf field (reachable on Android, where the engine doesn't rename first, or in a // pre-obfuscated dependency) is still guarded instead of being returned unchanged on the false diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 5258dd98254..1a1b44975dc 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -364,9 +364,10 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "plaintext; move a large embedded secret/blob out of a string constant to hide it"); } if (stringsApplied && clinitFullLiterals > 0) { - result.getWarnings().add(clinitFullLiterals + " string literal(s) were left in plaintext " - + "because the class's static initializer is already near the 65535-byte method " - + "limit and could not hold the decode step"); + result.getWarnings().add(clinitFullLiterals + " static-final String constant(s) were left in " + + "plaintext because the class is near a JVM limit (its static initializer's size or " + + "the constant pool) and could not hold the decode step; those field values are " + + "dead once javac inlines their reads, so this is a disclosure note"); } if (stringsApplied && jarExcludedLiterals > 0) { // Values that at least one class could not encrypt (a method already near the 65535-byte @@ -386,8 +387,9 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi } if (controlFlowApplied && oversizedGuardMethods > 0) { result.getWarnings().add(oversizedGuardMethods + " method(s) were left with plain control " - + "flow because they are already near the 65535-byte method limit and adding the " - + "guard would overflow them"); + + "flow because their class is already near a JVM limit (a method near the " + + "65535-byte limit, or a constant pool near the 65535-entry limit) and adding the " + + "guard would overflow it"); } if (req.getReportFile() != null) { writeReport(req.getReportFile(), cfg, result); diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java index 6cc904fc71c..010492de824 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java @@ -39,6 +39,12 @@ final class MethodSize { /** The hard JVM limit on a method's bytecode array. */ static final int LIMIT = 65535; + /** + * The constant-pool item count a transform must stay under. The hard JVM limit is 65535 entries; + * this sits below it by a margin that absorbs the per-item estimates a transform makes before it + * knows the exact pool growth. Shared by the string and control-flow transforms. + */ + static final int SAFE_POOL_ITEMS = 60000; /** * The size a transform must stay under. Below {@link #LIMIT} by a margin that absorbs both the * upper-bound estimate's slack and the fact that the real limit is on the emitted bytes, which diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index a2e69c96d6d..c7fc10f9215 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -83,14 +83,18 @@ public final class StringEncryptTransform { private static final int CLINIT_CALL_BYTES = 5; /** Widest encoding of the per-access decoder INVOKESTATIC inserted after an LDC. */ private static final int DECODER_CALL_BYTES = 5; - /** Target ceiling for the constant-pool item count (the hard JVM limit is 65535), with margin. */ - private static final int SAFE_POOL_ITEMS = 60000; /** * Conservative constant-pool entries each hoisted literal adds -- a Utf8 for the field name, a * NameAndType, a Fieldref, and the ciphertext Utf8 + String -- so the total can be bounded before * hoisting rather than discovering the overflow only when ASM writes the class. */ private static final int POOL_ITEMS_PER_HOIST = 6; + /** + * Conservative constant-pool entries encrypting one {@code static final String} adds -- the field's + * {@code Fieldref} and {@code NameAndType} for the new {@code PUTSTATIC} (the ciphertext replaces the + * stripped plaintext, so it is roughly net-zero). Budgeted with the hoisting growth against the pool. + */ + private static final int POOL_ITEMS_PER_STATIC = 4; /** * Conservative constant-pool entries the synthetic decoder itself adds (its name/descriptor, the * {@code Methodref} the encrypt sites share, and the {@code String.toCharArray}/{@code intern} @@ -123,6 +127,12 @@ public final class StringEncryptTransform { private int annotationLiteralCount; /** The input class's constant-pool item count, so hoisting can stay under the 65535-entry limit. */ private int poolBaseItems; + /** + * Constant-pool items still available before the 65535-entry limit, after reserving the decoder's + * fixed overhead. Both channels (hoisted method literals and static-final constants) draw from it, + * so their combined growth is budgeted rather than each ignoring the other. + */ + private int poolItemsRemaining; public StringEncryptTransform(boolean encryptAllStrings, int seed) { this(encryptAllStrings, seed, null, null, null); @@ -278,8 +288,9 @@ public byte[] transform(byte[] classBytes) { ClassNode cn = new ClassNode(); ClassReader reader = new ClassReader(classBytes); reader.accept(cn, ClassReader.SKIP_FRAMES); - // The input's current constant-pool item count; hoisting must not grow the pool past 65535. + // The input's current constant-pool item count; encryption must not grow the pool past 65535. poolBaseItems = reader.getItemCount(); + poolItemsRemaining = MethodSize.SAFE_POOL_ITEMS - poolBaseItems - DECODER_POOL_OVERHEAD; boolean isInterface = (cn.access & Opcodes.ACC_INTERFACE) != 0; // The decoder is a concrete static method, and (for interface constants) it is invoked from @@ -332,7 +343,7 @@ && f.value instanceof String && shouldEncryptLiteral((String) f.value)) { // the values it would have selected as jar-wide exclusions, so those values are left plaintext in // every OTHER class too -- otherwise a value encrypted+interned elsewhere would compare != to the // plaintext copy here on ParparVM. - if (poolBaseItems + DECODER_POOL_OVERHEAD > SAFE_POOL_ITEMS) { + if (poolItemsRemaining < 0) { collectSelectedValues(cn, newlyExcluded); return classBytes; } @@ -768,7 +779,7 @@ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) // encryption for the whole class: it adds no per-value field, so it does not grow the pool, and // -- crucially -- it keeps EVERY occurrence encrypted and interned rather than leaving some // plaintext, so a value hoisted in one class still compares == to its per-access copy here. - int budget = (SAFE_POOL_ITEMS - poolBaseItems - DECODER_POOL_OVERHEAD) / POOL_ITEMS_PER_HOIST; + int budget = poolItemsRemaining / POOL_ITEMS_PER_HOIST; if (budget < 0) { budget = 0; } @@ -844,6 +855,9 @@ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) field, "Ljava/lang/String;", null, null)); encryptedCount++; } + // Charge the hoisted fields against the shared pool budget so the static-final channel that runs + // next sees the reduced headroom. + poolItemsRemaining -= valueToField.size() * POOL_ITEMS_PER_HOIST; // hoistMethodLiterals runs only for a non-interface (interfaces decode per access), so the // helper split, if it triggers, emits ordinary private static helpers. prependToClinit(cn, init, false); @@ -855,6 +869,15 @@ private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInte if (cn.fields == null) { return false; } + // Encrypting one static-final grows the pool (a PUTSTATIC Fieldref + NameAndType), so a class + // with thousands of them could exceed the 65535-entry limit. Cap it at the shared pool budget + // (already reduced by any hoisting above); constants past the budget keep their plaintext. That + // is safe -- a static-final's ConstantValue is dead once javac inlines every read (those inlined + // LDCs are encrypted by the method channel), so it is never compared by ==. + int budget = poolItemsRemaining / POOL_ITEMS_PER_STATIC; + if (budget < 0) { + budget = 0; + } // Build the initializer and remember which fields to strip WITHOUT mutating yet, so a class // whose cannot accommodate the init keeps its constants (plaintext, reported) rather // than being left with fields whose ConstantValue was stripped but never re-initialized. @@ -863,6 +886,11 @@ private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInte for (FieldNode fn : cn.fields) { boolean isStatic = (fn.access & Opcodes.ACC_STATIC) != 0; if (isStatic && fn.value instanceof String && shouldEncrypt((String) fn.value)) { + if (toStrip.size() >= budget) { + // Pool budget exhausted: leave this constant plaintext (dead value, reported). + clinitFullLiteralCount++; + continue; + } String plain = (String) fn.value; // shouldEncrypt already rejected any value whose ciphertext could overflow the // constant pool (class-independent bound), so the encode result fits. @@ -887,6 +915,7 @@ private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInte fn.value = null; encryptedCount++; } + poolItemsRemaining -= toStrip.size() * POOL_ITEMS_PER_STATIC; prependToClinit(cn, init, isInterface); return true; } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java index 454c5b38a54..aef3d4e4d08 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java @@ -187,6 +187,38 @@ public void classWithNearFullClinitIsSkippedNotOverflowed() throws Exception { assertEquals(5, c.getMethod("add", int.class, int.class).invoke(null, 2, 3)); } + @Test + public void classWithNearFullPoolIsSkippedNotOverflowed() throws Exception { + // A class whose constant pool is already near the 65535-entry limit cannot fit the guard's added + // field and references. It must be skipped (reported), not aborted with ClassTooLargeException. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/PoolFullGuard", null, "java/lang/Object", null); + // ~20000 distinct string constants (~3 pool entries each) push the pool past the guard gate. + for (int i = 0; i < 20000; i++) { + w.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "F" + i, "Ljava/lang/String;", null, + "constant pool filler string number " + i).visitEnd(); + } + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "add", "(II)I", null, null); + m.visitCode(); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 0); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 1); + m.visitInsn(org.objectweb.asm.Opcodes.IADD); + m.visitInsn(org.objectweb.asm.Opcodes.IRETURN); + m.visitMaxs(2, 2); + m.visitEnd(); + w.visitEnd(); + + ControlFlowTransform t = new ControlFlowTransform(); + byte[] out = t.transform(w.toByteArray()); + assertEquals("no method is guarded when the pool cannot fit the guard", 0, t.getGuardedMethods()); + assertTrue("the skipped guardable method is reported", t.getOversizedMethods() >= 1); + CheckClassAdapter.verify(new ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + } + // Renames the class internal name so the intense variant can load beside the plain one. private static byte[] rename(byte[] bytes, String from, String to) { org.objectweb.asm.ClassReader cr = new org.objectweb.asm.ClassReader(bytes); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 865934d6a7c..5d97cca4f75 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -326,6 +326,32 @@ public void existingLargeClinitIsSplitWhenCombinedWithNewInit() throws Exception assertEquals("a hoisted secret literal value number 0", c.getMethod("probe0").invoke(null)); } + @Test + public void staticFinalEncryptionIsCappedByConstantPoolBudget() throws Exception { + // A class with thousands of static-final String constants would overflow the 65535-entry pool if + // every one were encrypted (each adds a PUTSTATIC Fieldref + NameAndType). The transform caps it + // at the pool budget and leaves the rest plaintext (safe: the field value is dead once reads are + // inlined) rather than throwing ClassTooLargeException. + int count = 12000; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/ManyConstants", null, "java/lang/Object", null); + for (int i = 0; i < count; i++) { + w.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "F" + i, "Ljava/lang/String;", null, + "a static final constant value number " + i).visitEnd(); + } + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 3); + byte[] out = t.transform(w.toByteArray()); + assertTrue("some constants are encrypted within the pool budget", t.getEncryptedCount() > 0); + assertTrue("the rest are left plaintext and reported", t.getClinitFullLiteralCount() > 0); + // The class assembles and verifies -- no ClassTooLargeException. + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + } + @Test public void staticFinalConstantWithFullClinitStaysPlaintextAndReports() throws Exception { // A static-final ConstantValue can only be moved into ; when is already full it From 16cf9072f13ace326b97b8699bf019d598740106 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:22:13 +0700 Subject: [PATCH 059/110] Keep Login subclasses; run the hardening preflight against library-merged settings - A com.codename1.social.Login subclass persists its OAuth access/refresh tokens under Storage/Preferences keys derived from getClass().getName() (getAccessToken/setAccessToken/validateToken). Renaming an app's Login subclass changes the key after an app update, so the stored session becomes unreadable and the user is silently logged out. BuiltinKeepRules now keeps subclasses of Login (shared with R8). Covered in keepsNameBoundBackgroundCallbacks. - The Check-1 hardening preflight read only the project's codenameone_settings.properties, but a CN1Lib can supply codename1.arg.harden.level via codenameone_library_appended/required.properties, which createAntProject merges later. So a library that turned hardening on slipped a local/source build past the local-build refusal/force-off and produced a locally hardened artifact whose mapping is never uploaded. applyHardeningPreflight is now also run against the fully merged effective settings after the library-property merge. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/BuiltinKeepRules.java | 5 +++++ .../hardening/BuiltinKeepRulesTest.java | 3 +++ .../com/codename1/maven/CN1BuildMojo.java | 21 +++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java index d759470c578..01906273cb3 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java @@ -79,6 +79,11 @@ public static List rules(String mainClass) { r.add("-keep class * implements com.codename1.location.LocationListener { *; }"); r.add("-keep class * implements com.codename1.background.BackgroundFetch { *; }"); r.add("-keep class * implements com.codename1.background.BackgroundWorker { *; }"); + // A com.codename1.social.Login subclass persists its OAuth access/refresh tokens under keys + // derived from getClass().getName() (Login.getAccessToken/setAccessToken/validateToken). Renaming + // an app's Login subclass would change the key after an app update, so the stored session becomes + // unreadable and the user is silently logged out. Keep the subclasses so the name stays stable. + r.add("-keep class * extends com.codename1.social.Login { *; }"); // JNI/native method names must not move. r.add("-keepclasseswithmembernames,includedescriptorclasses class * { native ; }"); // enum values()/valueOf(String) resolve constants by name, so they are kept -- this is diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java index 7ed2c4e2314..4cc878879d1 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java @@ -94,6 +94,9 @@ public void keepsNameBoundBackgroundCallbacks() { "-keep class * implements com.codename1.background.BackgroundFetch { *; }")); assertTrue(rules.contains( "-keep class * implements com.codename1.background.BackgroundWorker { *; }")); + // A Login subclass persists OAuth tokens under getClass().getName(), so its name must stay stable. + assertTrue(rules.contains( + "-keep class * extends com.codename1.social.Login { *; }")); // The same rules are exported to R8 on Android (where R8 does the renaming). assertTrue(BuiltinKeepRules.forR8("com.example.MyApp").contains( "-keep class * implements com.codename1.location.GeofenceListener { *; }")); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index b6e6af8ecab..1a59388a651 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -192,6 +192,19 @@ private void applyHardeningPreflight() throws MojoFailureException { getLog().debug("Could not read codenameone_settings.properties for hardening pre-flight", ex); } } + applyHardeningPreflight(settings); + } + + /** + * Runs the hardening pre-flight against a given set of effective settings. Called first with the + * project's own {@code codenameone_settings.properties} (fail-fast before the merged jar is built), + * and again after {@code createAntProject} merges the CN1Lib-contributed + * {@code codenameone_library_appended/required.properties} -- a library can turn hardening on via + * {@code codename1.arg.harden.level}, which the early call cannot see, and without this second pass + * such a build would slip past the local-build refusal / force-off and produce a locally hardened + * artifact whose mapping is never uploaded. + */ + private void applyHardeningPreflight(Properties settings) throws MojoFailureException { String level = settings.getProperty("codename1.arg.harden.level", "off"); // A per-platform opt-out (harden..enabled=false) means hardening won't run for // this target, so the pre-flight must not reject it -- treat the level as off. The native-Mac @@ -1113,6 +1126,14 @@ private void createAntProject() throws IOException, LibraryPropertiesException, } + // Re-run the hardening pre-flight against the MERGED effective settings: a CN1Lib can supply + // codename1.arg.harden.level (or a per-platform opt-out) via the appended/required properties + // just merged above, which the early pre-flight -- run before this jar existed -- could not see. + // Without this, a library that turns hardening on would slip a local/source build past the + // local-build refusal / force-off and produce a locally hardened artifact whose mapping is never + // uploaded (so its crashes could never be retraced). + applyHardeningPreflight(cn1SettingsProps); + cn1SettingsProps.setProperty("codename1.arg.hyp.beamId", logPasskey); cn1SettingsProps.setProperty("codename1.arg.maven.codenameone-core.version", cn1MavenVersion); From 6549725866358cce18a873aaace1be9fcdd39729 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:03:55 +0700 Subject: [PATCH 060/110] Document why carrying .java/.kt sources through the demuxer is rename-safe A reviewer flagged that a CN1Lib-bundled .java/.kt native source carried unchanged while the engine renames classes could javac-fail with cannot-find-symbol against the renamed jar. It cannot: no builder compiles an unzip'd source against an engine-renamed classpath (Android does not rename in the engine -- R8 renames post-javac consistently; iOS routes these sources to the resource tree and only compiles the generated stub; win/linux compile only translator-generated .java; JS compiles the port's own sources; javase runs bytecode directly). Record the invariant at the carry-over site so the next review sees it in-code rather than only in the PR thread. Co-Authored-By: Claude Opus 4.8 --- .../com/codename1/hardening/JarDemuxer.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java index 559bed7b22e..03dc63124a2 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java @@ -99,6 +99,26 @@ public static NonClassEntries split(File input, File classesJarOut) throws IOExc // the .SF the JVM no longer verifies, which is correct for a rewritten jar. continue; } else { + // Every non-class entry -- resources, native bundles, AND any .java/.kt/.swift/ + // .m/.h/.cs source a CN1Lib bundles -- is carried byte-for-byte. Carrying a + // .java/.kt source unchanged while the engine renames classes is SAFE and cannot + // produce a "cannot find symbol" against the renamed jar, because no builder ever + // javac-compiles an unzip'd source against an engine-renamed classpath: + // - Android: the engine does NOT rename (AndroidGradleBuilder.hardeningRename- + // Supported()==false -> renameEnabled=false). R8 is the sole renamer and runs + // AFTER javac has compiled the app classes together with any bundled native + // sources, so R8 renames the whole set consistently -- imports still resolve. + // - iOS/mac/watch/tv: unzip routes these sources into the RESOURCE tree + // (IPhoneBuilder passes resDir as unzip's sourceDir); the only javac compiles + // the generated stub dir, never the carried sources. Native impls are Obj-C. + // - win/linux: javac compiles only the ParparVM translator-GENERATED .java, + // emitted from already-renamed bytecode, so it is internally consistent. + // - javascript: javac targets the JS port's own sources against staged classes; + // bundled app native sources are .js, and string encryption is off on JS. + // - javase/desktop: runs the bytecode directly and recompiles no sources. + // So there is no rename-vs-source mismatch to guard; a source-text keep scanner + // would be dead code. Revisit only if a builder starts compiling unzip's sourceDir + // against an engine-renamed classpath. nonClass.put(name, data); } } From 6b81363ab50a5ea73cb1e37505d2995992d6c1c8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:17:06 +0700 Subject: [PATCH 061/110] Validate @-frame source shape before preserving coordinates; skip OFF profile in the entitlement gate - PiiScrubber classified any line containing '@' and ending in two numeric groups as a Firefox/Safari frame, so a wrapped message like 'status@host:1:123456' had its six-digit tail preserved verbatim as a fake :line:column and bypassed scrubMessage, leaking the identifier into the uploaded raw stack. isFrameLine now requires the '@' form to carry a whitespace-free identity and a URL/file-shaped source (a '/' or '.') before the coordinate, mirroring the strictness of the 'at ' bare form. A bare source word like 'host' fails the shape test and the message stays scrubbed. - willApplyAnyTransform returned true for harden.level=off left with a stale individual override such as harden.rename=true, so the CLI's entitlement gate rejected a non-entitled build with EXIT_NOT_ENTITLED even though HardeningEngine.harden() returns SKIPPED for every OFF profile before considering transforms. The gate now short-circuits OFF to false, matching the engine, so turning hardening off never fails a build over a leftover override. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 28 ++++++++++++++++++- .../codename1/hardening/HardeningEngine.java | 7 +++++ .../hardening/HardeningEngineTest.java | 23 +++++++++++++++ .../crash/PiiScrubberRawStackTest.java | 14 ++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index 9c48ba6ff37..b43dd723622 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -164,7 +164,33 @@ private static boolean isFrameLine(String line) { if (t.startsWith("at ")) { return atFrame(t.substring(3).trim()); } - return t.indexOf('@') >= 0 && endsWithLineColumn(t); + return atSignFrame(t); + } + + /// The body of a Firefox/Safari `fn@source:line:column` frame: a whitespace-free function + /// identity (empty for an anonymous frame), an `@`, and a URL/file source before the trailing + /// `::`. The source must actually look like a URL or file -- contain a `/` + /// (`scheme://host/path`) or a `.` (`file.ext`). Without that check a wrapped message such as + /// `send status@host:1:123456` matches merely by containing an `@` and ending in two numeric + /// groups, and its six-digit tail would be preserved verbatim as a fake column instead of being + /// scrubbed. A bare source word like `host` fails the shape test, so the message stays scrubbed. + private static boolean atSignFrame(String t) { + int at = t.indexOf('@'); + if (at < 0 || !endsWithLineColumn(t)) { + return false; + } + // A wrapped message almost always has a space before the '@' (`send status@...`); a real frame's + // function ref is a single token. An empty identity is allowed (an anonymous `@url:1:2` frame). + String ident = t.substring(0, at); + if (ident.length() > 0 && !isFrameIdentity(ident)) { + return false; + } + int loc = trailingLocationStart(t); + if (loc <= at + 1) { + return false; + } + String source = t.substring(at + 1, loc); + return source.indexOf('/') >= 0 || source.indexOf('.') >= 0; } /// The body of an `at ...` line: a whitespace-free identity plus a real location. A message diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 1a1b44975dc..97478988784 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -405,6 +405,13 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi */ /** True when at least one transform will actually run for this config and platform. */ static boolean willApplyAnyTransform(HardeningConfig cfg) { + // An off profile skips everything: harden() returns SKIPPED for OFF (before any transform), + // even when a stale individual override such as harden.rename=true is still present. Match + // that here so a non-entitled build that explicitly set harden.level=off is skipped rather + // than rejected as not-entitled just because an override was left behind. + if (cfg.getProfile() == HardeningProfile.OFF) { + return false; + } // A per-platform opt-out means nothing runs for this target -- so a non-entitled build with // harden..enabled=false is skipped, not rejected as not-entitled. if (!cfg.isPlatformEnabled()) { diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index 6a62a8c051f..14f2f532a99 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -218,6 +218,29 @@ public void platformOptOutIsSkippedNotHardened() throws Exception { HardeningEngine.willApplyAnyTransform(HardeningConfig.from(hints, "ios", true))); } + @Test + public void offLevelWithStaleOverrideAppliesNoTransform() throws Exception { + // harden.level=off with a leftover harden.rename=true must not count as an applied transform, + // so a non-entitled build that turned hardening off is skipped (harden() returns SKIPPED for + // OFF) rather than rejected as not-entitled by the CLI's entitlement gate. + Map hints = new HashMap(); + hints.put("harden.level", "off"); + hints.put("harden.rename", "true"); + HardeningConfig cfg = HardeningConfig.from(hints, "ios", true); + assertFalse("off must apply no transform even with a stale override", + HardeningEngine.willApplyAnyTransform(cfg)); + File in = buildInputJar(); + File out = tmp.newFile("off-override-hardened.jar"); + HardeningRequest req = new HardeningRequest() + .inputJar(in).outputJar(out).mappingFile(tmp.newFile("off-override-map.txt")) + .workDir(tmp.newFolder("off-override-work")) + .config(cfg) + .mainClass("com.codename1.hardening.fixture.Secrets"); + HardeningResult r = HardeningEngine.harden(req); + assertFalse(r.isHardened()); + assertEquals(HardeningResult.Outcome.SKIPPED_NOT_REQUESTED, r.getOutcome()); + } + @Test public void androidRenameOnlyIsHardenedViaR8() throws Exception { // Android (renameSupported=false), standard with strings off: the engine renames nothing, diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index cb9af3af897..b8ab9590d6f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -213,4 +213,18 @@ void messageDigitsAndEmailsStillScrubbed() { assertTrue(scrubbed.indexOf("tes***@example.com") >= 0, scrubbed); assertTrue(scrubbed.indexOf("Bar.java:42") >= 0, scrubbed); } + + @Test + void atSignMessageWithoutUrlSourceIsScrubbed() { + // A wrapped message that merely contains an '@' and ends in two numeric groups + // (status@host:1:123456) is NOT a Firefox/Safari frame: its source `host` is neither a URL nor + // a file, so the six-digit tail must be scrubbed rather than preserved as a fake column. A real + // Firefox frame (fn@http://host/app.js:10:5) whose source IS a URL keeps its coordinate. + String stack = "java.lang.RuntimeException: verifying\n" + + "status@host:1:123456\n" + + "renderApp@http://host/app.js:10:5\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("app.js:10:5") >= 0, scrubbed); + } } From 52ed531e626d70f245ed121e9dc1c38bf96dba52 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:38:09 +0700 Subject: [PATCH 062/110] Budget per-access ciphertext growth; use inlinee's own sourceFile in retrace - The per-access string channel is not pool-neutral: when a value's plaintext is retained elsewhere (an unstripped static-final ConstantValue, an annotation value, another class in the jar), replacing a method LDC with its ciphertext ADDS a Utf8/String pair while the original stays referenced. A class dense with such retained values could push the constant pool past 65535 and make ClassWriter.toByteArray() throw ClassTooLargeException, because encryptMethodLiterals bounded only the method byte size, not pool growth. It now charges POOL_ITEMS_PER_ACCESS per distinct value against the shared poolItemsRemaining budget (once, since ASM folds equal ciphertext) and, when the budget is exhausted, leaves the value plaintext recorded for a jar-wide exclusion -- the same consistency path the byte-full branch uses. New test perAccessEncryptionIsCappedByConstantPoolBudget reproduces the overflow (18000 annotation-retained literals) and asserts the class still verifies. - MappingFile.frameFor fabricated .java for every inlined method, ignoring that class's own R8 sourceFile metadata, so a Kotlin inlinee (Helper.kt) or a package-private class in a differently named file got a nonexistent source name and source linking failed. The mapping is now also indexed by original FQCN so an inlinee recovers its declaring class's recorded sourceFile, falling back to the synthesized name only when it has none. Covered by inlinedMethodFromKotlinClassUsesItsOwnSourceFile and inlinedMethodWithoutMetadataStillSynthesizesJava. Co-Authored-By: Claude Opus 4.8 --- .../hardening/StringEncryptTransform.java | 43 +++++++++++++--- .../hardening/StringEncryptTransformTest.java | 50 +++++++++++++++++++ .../com/codename1/retrace/MappingFile.java | 18 ++++++- .../codename1/retrace/MappingFileTest.java | 33 ++++++++++++ 4 files changed, 136 insertions(+), 8 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index c7fc10f9215..80b7addc5c6 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -95,6 +95,15 @@ public final class StringEncryptTransform { * stripped plaintext, so it is roughly net-zero). Budgeted with the hoisting growth against the pool. */ private static final int POOL_ITEMS_PER_STATIC = 4; + /** + * Conservative constant-pool entries one per-access ciphertext adds -- a Utf8 for the ciphertext + * plus its {@code String_info}. The per-access channel is NOT pool-neutral: when the plaintext value + * is retained elsewhere (an unstripped static-final {@code ConstantValue}, an annotation value, or + * another class in the jar), the original pair stays referenced, so the ciphertext pair is a net + * addition. Budgeted per distinct value against the pool so a class dense with retained plaintext + * cannot silently overflow the 65535-entry limit in {@code ClassWriter.toByteArray()}. + */ + private static final int POOL_ITEMS_PER_ACCESS = 2; /** * Conservative constant-pool entries the synthetic decoder itself adds (its name/descriptor, the * {@code Methodref} the encrypt sites share, and the {@code String.toCharArray}/{@code intern} @@ -395,18 +404,22 @@ private boolean encryptAllMethodsPerAccess(ClassNode cn, int base, String decode boolean isInterface) { boolean changed = false; if (cn.methods != null) { + // Distinct values already charged against the pool budget in this pass. ASM folds equal + // ciphertext strings to one constant, so a value re-encountered in another method adds no + // further pool entry and must not be charged twice. + java.util.Set pooledThisPass = new java.util.HashSet(); for (MethodNode mn : cn.methods) { if (mn.instructions == null || decoderName.equals(mn.name)) { continue; } - changed |= encryptMethodLiterals(cn, mn, base, isInterface, decoderName); + changed |= encryptMethodLiterals(cn, mn, base, isInterface, decoderName, pooledThisPass); } } return changed; } private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boolean isInterface, - String decoderName) { + String decoderName, java.util.Set pooledThisPass) { boolean changed = false; // Each rewrite inserts an INVOKESTATIC, growing the method. A method already near the limit // cannot take unbounded rewrites, so track the running size and stop (leaving the remaining @@ -418,14 +431,28 @@ private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boo if (insn instanceof LdcInsnNode) { LdcInsnNode ldc = (LdcInsnNode) insn; if (ldc.cst instanceof String && shouldEncryptLiteral((String) ldc.cst)) { + String plain = (String) ldc.cst; if (currentBytes + DECODER_CALL_BYTES > MethodSize.SAFE_LIMIT) { // This method cannot grow to hold the decode call. Record the value as a jar-wide // exclusion so the engine leaves it plaintext in every class -- encrypting it // elsewhere would break a valid literal == against this plaintext copy. methodFullLiteralCount++; - newlyExcluded.add((String) ldc.cst); + newlyExcluded.add(plain); + } else if (!pooledThisPass.contains(plain) + && poolItemsRemaining < POOL_ITEMS_PER_ACCESS) { + // The ciphertext for a not-yet-charged value would push the class constant pool + // past the 65535-entry limit (per-access is not pool-neutral when the plaintext is + // retained elsewhere). Leaving some occurrences encrypted and others plaintext + // would break a valid literal ==, so exclude the value jar-wide (plaintext in + // every class) and let the engine's second pass re-run with it excluded, rather + // than let ClassWriter.toByteArray() throw ClassTooLargeException. + newlyExcluded.add(plain); } else { - String plain = (String) ldc.cst; + // Charge the pool budget the first time a distinct value is encrypted this pass; + // repeats of the same value reuse the folded ciphertext constant for free. + if (pooledThisPass.add(plain)) { + poolItemsRemaining -= POOL_ITEMS_PER_ACCESS; + } // shouldEncrypt already rejected any value whose ciphertext could overflow the // constant pool, using a class-independent bound, so the encode result fits. ldc.cst = encode(plain, base); @@ -776,9 +803,11 @@ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) // field name Utf8, a NameAndType, a Fieldref, the ciphertext Utf8 + String), so a class with // tens of thousands of distinct literals could exceed the JVM's 65535-entry limit and make ASM // throw ClassTooLargeException. When hoisting them all would overflow, fall back to per-access - // encryption for the whole class: it adds no per-value field, so it does not grow the pool, and - // -- crucially -- it keeps EVERY occurrence encrypted and interned rather than leaving some - // plaintext, so a value hoisted in one class still compares == to its per-access copy here. + // encryption for the whole class: it adds no per-value field, so it grows the pool far less (just + // the ciphertext Utf8 + String per distinct value, itself budgeted against poolItemsRemaining in + // encryptMethodLiterals), and -- crucially -- it keeps every affordable occurrence encrypted and + // interned rather than leaving some plaintext, so a value hoisted in one class still compares == + // to its per-access copy here. int budget = poolItemsRemaining / POOL_ITEMS_PER_HOIST; if (budget < 0) { budget = 0; diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 5d97cca4f75..9e2f94a453f 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -352,6 +352,56 @@ public void staticFinalEncryptionIsCappedByConstantPoolBudget() throws Exception new java.io.PrintWriter(new java.io.StringWriter())); } + @Test + public void perAccessEncryptionIsCappedByConstantPoolBudget() throws Exception { + // The per-access channel is NOT pool-neutral when a value's plaintext is retained elsewhere: the + // ciphertext Utf8+String is a NET addition. Here every literal is ALSO kept plaintext in a class + // annotation (annotation values are never encrypted), so encrypting all of an interface's method + // copies would push the pool past 65535 and make ClassWriter.toByteArray() throw + // ClassTooLargeException. The transform must budget the ciphertext growth per distinct value and + // leave the overflow values plaintext (recorded for a jar-wide exclusion) instead. + int perMethod = 300; + int methods = 60; + int total = perMethod * methods; // 18000 distinct literals -- past what the 60000-item budget allows + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + // An interface, so every method literal goes through per access (never hoisted to ). + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_ABSTRACT | org.objectweb.asm.Opcodes.ACC_INTERFACE, + "app/ManyLiterals", null, "java/lang/Object", null); + // Retain every value as plaintext in a class-level annotation array; no encryption channel reaches + // annotation values, so each stays referenced and the method-copy ciphertext is a net pool add. + org.objectweb.asm.AnnotationVisitor av = w.visitAnnotation("Lapp/Keep;", false); + org.objectweb.asm.AnnotationVisitor arr = av.visitArray("value"); + for (int i = 0; i < total; i++) { + arr.visit(null, "interface per-access literal value number " + i); + } + arr.visitEnd(); + av.visitEnd(); + int n = 0; + for (int mth = 0; mth < methods; mth++) { + org.objectweb.asm.MethodVisitor mv = w.visitMethod( + org.objectweb.asm.Opcodes.ACC_PUBLIC, "m" + mth, "()V", null, null); + mv.visitCode(); + for (int k = 0; k < perMethod; k++, n++) { + mv.visitLdcInsn("interface per-access literal value number " + n); + mv.visitInsn(org.objectweb.asm.Opcodes.POP); + } + mv.visitInsn(org.objectweb.asm.Opcodes.RETURN); + mv.visitMaxs(1, 0); + mv.visitEnd(); + } + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 7); + byte[] out = t.transform(w.toByteArray()); + // The class assembles and verifies -- crucially, no ClassTooLargeException from a pool overflow. + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + assertTrue("some literals are encrypted within the pool budget", t.getEncryptedCount() > 0); + assertFalse("the pool-budget guard must leave the overflow values for a jar-wide exclusion", + t.getNewlyExcluded().isEmpty()); + } + @Test public void staticFinalConstantWithFullClinitStaysPlaintextAndReports() throws Exception { // A static-final ConstantValue can only be moved into ; when is already full it diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java index 0bb43ba7502..50a15529ee1 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -93,6 +93,9 @@ private static final class ClassMapping { // obfuscated class binary name -> mapping private final Map byObfuscated = new HashMap(); + // Same ClassMapping objects keyed by their ORIGINAL (deobfuscated) FQCN, so an inlined method's + // declaring class -- named by its original name on the member line -- can recover its sourceFile. + private final Map byOriginal = new HashMap(); public static MappingFile parse(String text) throws IOException { return parse(new StringReader(text)); @@ -160,6 +163,9 @@ private ClassMapping parseClassLine(String line) { String obf = line.substring(arrow + 4, line.length() - 1).trim(); ClassMapping cm = new ClassMapping(original); byObfuscated.put(obf, cm); + // Also index by original FQCN so an inlined method's declaring class (recorded by its ORIGINAL + // name on the member line) can recover its own sourceFile metadata for the inline frame. + byOriginal.put(original, cm); return cm; } @@ -304,7 +310,17 @@ private static String normalizeInitializer(String methodName) { /** Builds a frame for one method record, honoring an inlinee's own declaring class/source file. */ private Frame frameFor(MethodMapping m, String enclosingClass, String enclosingFile, int observed) { String cls = m.declaringClass != null ? m.declaringClass : enclosingClass; - String file = m.declaringClass != null ? simpleSourceFile(m.declaringClass) : enclosingFile; + String file; + if (m.declaringClass != null) { + // An inlined method comes from another class (Helper.kt, a package-private class in a + // differently named file). Prefer that class's own recorded sourceFile metadata when the + // mapping has it, so the inline frame points at Helper.kt rather than a fabricated + // Helper.java; fall back to the synthesized .java only when it has none. + ClassMapping dc = byOriginal.get(m.declaringClass); + file = synthesizedSourceFile(m.declaringClass, dc != null ? dc.sourceFile : null); + } else { + file = enclosingFile; + } return new Frame(cls, m.originalName, file, m.mapLine(observed)); } diff --git a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java index 686c93991a7..276546895ce 100644 --- a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -170,6 +170,39 @@ public void inlinedMethodFromAnotherClassKeepsItsOwnClass() throws Exception { assertEquals("outerMethod", frames.get(1).getMethodName()); } + @Test + public void inlinedMethodFromKotlinClassUsesItsOwnSourceFile() throws Exception { + // The inlinee is Helper.doIt from a Kotlin class whose declaration records sourceFile Helper.kt. + // The inline frame must report Helper.kt, not a fabricated Helper.java. + MappingFile mf = MappingFile.parse( + "com.example.Outer -> x:\n" + + " 1:1:void outerMethod():30:30 -> a\n" + + "com.example.Helper -> y:\n" + + " # {\"id\":\"sourceFile\",\"fileName\":\"Helper.kt\"}\n" + + " void doIt() -> b\n" + // Back in Outer: an inlined call to Helper.doIt at obf line 1 of method a. + + "com.example.Outer -> x:\n" + + " 1:1:void com.example.Helper.doIt():12:12 -> a\n" + + " 1:1:void outerMethod():30:30 -> a\n"); + java.util.List frames = mf.retraceAll(new Frame("x", "a", "x.java", 1)); + assertEquals(2, frames.size()); + assertEquals("com.example.Helper", frames.get(0).getClassName()); + assertEquals("doIt", frames.get(0).getMethodName()); + assertEquals("Helper.kt", frames.get(0).getFileName()); + assertEquals("com.example.Outer", frames.get(1).getClassName()); + } + + @Test + public void inlinedMethodWithoutMetadataStillSynthesizesJava() throws Exception { + // A declaring class with no recorded sourceFile metadata falls back to the synthesized name. + MappingFile mf = MappingFile.parse( + "com.example.Outer -> x:\n" + + " 1:1:void com.example.Callee.run():12:12 -> a\n" + + " 1:1:void outerMethod():30:30 -> a\n"); + assertEquals("Callee.java", + mf.retraceAll(new Frame("x", "a", "x.java", 1)).get(0).getFileName()); + } + @Test public void keepsRealReportedSourceFileButNotObfuscatedPlaceholder() throws Exception { MappingFile mf = MappingFile.parse( From a4ce422ec8975eae047afcd49766d76441f2d174 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:54:25 +0700 Subject: [PATCH 063/110] Preserve literal identity across the library boundary; charge widened conditional jumps - On the ParparVM-C targets a compile-time literal is a constant-pool object that is never interned (cn1_globals.m builds constantPoolObjects but the intern-seeding is disabled; String.intern uses a private pool that starts empty), while the string decoder returns new String(...).intern(). So a value encrypted in the app but left plaintext in an UNHARDENED library class (core/java-runtime/dependencies, which live in -libraryjars and are never in 'renamed') would compare != against the library's copy even though the two equal literals were reference-equal before hardening. The engine now scans the library jars for their literals on ParparVM-C targets and excludes any app value that also appears there, so a literal == across the boundary still holds; the exclusions are counted and warned. Real-JVM and Android targets intern every compile-time literal into the same pool intern() uses, so the scan is skipped there and coverage is unchanged. Covered by librarySharedLiteralsStayPlaintextOnParparVM (ios: shared stays plaintext, app-only encrypted) and librarySharedLiteralsAreEncryptedOnRealJvm (javase: shared encrypted). - MethodSize charged every JUMP_INSN 5 bytes, but a conditional IF* has no wide form: ASM widens an out-of-range one to an inverted 3-byte conditional over a 5-byte GOTO_W == 8 bytes. The purported upper-bound estimator therefore undercounted a method dense with long-range conditionals, which could let a preflight accept growth that then throws MethodTooLargeException. Conditional jumps are now charged 8 and only GOTO/JSR (which have GOTO_W/JSR_W) charge 5. New MethodSizeTest pins both. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/HardeningEngine.java | 74 +++++++++++++++++ .../com/codename1/hardening/MethodSize.java | 7 +- .../hardening/StringEncryptTransform.java | 61 ++++++++++++++ .../hardening/HardeningEngineTest.java | 83 +++++++++++++++++++ .../codename1/hardening/MethodSizeTest.java | 63 ++++++++++++++ 5 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 maven/cn1-hardening/src/test/java/com/codename1/hardening/MethodSizeTest.java diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 97478988784..091615ae4ad 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -189,6 +189,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int clinitFullLiterals = 0; int annotationLiterals = 0; int jarExcludedLiterals = 0; + int libraryExcludedLiterals = 0; boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); if (stringsApplied) { // In "constants" mode, first collect the values declared as static-final String @@ -201,6 +202,23 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi StringEncryptTransform.collectConstantValues(cls, constantValues); } } + // On a ParparVM-C target a compile-time literal is a never-interned constant-pool object, + // while an encrypted app copy is intern()ed, so encrypting an app value that ALSO appears as + // a literal in an UNHARDENED library class (core, java-runtime, dependencies -- never in + // 'renamed') would break a valid literal == against the library copy that held before + // hardening. Collect those library literals and exclude them from encryption. On a real-JVM + // or Android target every compile-time literal is interned to the same pool intern() uses, so + // the constraint does not apply and the scan is skipped (full coverage). + java.util.Set libraryLiterals = null; + if (translatesThroughParparVMC(cfg.getPlatform()) && req.getLibraryJars() != null) { + libraryLiterals = new java.util.HashSet(); + for (File lib : req.getLibraryJars()) { + collectJarLiterals(lib, libraryLiterals); + } + } + final java.util.Set libLiterals = + libraryLiterals != null && !libraryLiterals.isEmpty() ? libraryLiterals : null; + java.util.Set libraryExcluded = new java.util.HashSet(); // Pass 1 (from a snapshot of the input bytes): transform every class, tally the counts, and // collect the values any class could NOT encrypt (a method too full for the decode call, or a // class whose pool cannot fit the decoder). A value encrypted+interned in one class but left @@ -211,8 +229,10 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi for (Map.Entry e : renamed.entrySet()) { StringEncryptTransform t = new StringEncryptTransform( cfg.isEncryptAllStrings(), seed, hierarchy, constantValues, null); + t.setLibraryLiterals(libLiterals); e.setValue(t.transform(e.getValue())); jarExcluded.addAll(t.getNewlyExcluded()); + libraryExcluded.addAll(t.getLibraryExcludedValues()); encryptedStrings += t.getEncryptedCount(); concatLiterals += t.getConcatLiteralCount(); legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); @@ -233,10 +253,13 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi condyLiterals = 0; clinitFullLiterals = 0; annotationLiterals = 0; + libraryExcluded.clear(); for (Map.Entry e : renamed.entrySet()) { StringEncryptTransform t = new StringEncryptTransform( cfg.isEncryptAllStrings(), seed, hierarchy, constantValues, jarExcluded); + t.setLibraryLiterals(libLiterals); e.setValue(t.transform(original.get(e.getKey()))); + libraryExcluded.addAll(t.getLibraryExcludedValues()); encryptedStrings += t.getEncryptedCount(); concatLiterals += t.getConcatLiteralCount(); legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); @@ -247,6 +270,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi } } jarExcludedLiterals = jarExcluded.size(); + libraryExcludedLiterals = libraryExcluded.size(); } int guardedMethods = 0; @@ -377,6 +401,14 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "plaintext in every class because at least one class could not encrypt them (a " + "method or constant pool near the JVM limit); those literals stay readable"); } + if (stringsApplied && libraryExcludedLiterals > 0) { + // Values that also appear as a literal in an unhardened library class are left plaintext so a + // literal == against the library's (never interned) constant-pool copy still holds on ParparVM. + result.getWarnings().add(libraryExcludedLiterals + " distinct string value(s) were left in " + + "plaintext because an unhardened framework/dependency class also holds them as a " + + "literal (encrypting only the app copy would break a valid reference-equality check " + + "against the library copy on the ParparVM native targets); those literals stay readable"); + } if (stringsApplied && annotationLiterals > 0) { // Annotation element values live in the annotation metadata, not an LDC or a ConstantValue, // so no encryption channel reaches them. CN1 has no reflection to read them back, so this is @@ -454,6 +486,48 @@ static boolean translatesThroughParparVMC(String platform) { || "tv".equals(platform) || "win".equals(platform) || "linux".equals(platform); } + /** + * Collects every string literal ({@code LDC} operand or {@code static final String} + * {@code ConstantValue}) in every class of {@code jar} into {@code out}. Used to gather the + * literals of the unhardened library jars so an app value shared with a framework/dependency class + * is not encrypted (which would break a literal {@code ==} against the un-interned library copy on + * ParparVM). A jar that cannot be read is skipped -- a library the engine cannot scan simply + * contributes no exclusions rather than aborting the build. + */ + private static void collectJarLiterals(File jar, java.util.Set out) { + if (jar == null || !jar.isFile()) { + return; + } + try { + java.io.FileInputStream fi = new java.io.FileInputStream(jar); + try { + java.util.zip.ZipInputStream zis = new java.util.zip.ZipInputStream(fi); + java.util.zip.ZipEntry entry; + byte[] buf = new byte[8192]; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory() || !entry.getName().endsWith(".class")) { + continue; + } + java.io.ByteArrayOutputStream bout = new java.io.ByteArrayOutputStream(); + int r; + while ((r = zis.read(buf)) >= 0) { + bout.write(buf, 0, r); + } + try { + StringEncryptTransform.collectAllLiterals(bout.toByteArray(), out); + } catch (RuntimeException ignored) { + // A class ASM cannot parse (a newer format than this ASM, a malformed entry) + // contributes no exclusions; it must not fail the scan of the rest of the jar. + } + } + } finally { + fi.close(); + } + } catch (IOException ex) { + // An unreadable library jar simply contributes no exclusions. + } + } + /** * A classloader over the (renamed) application classes plus the library jars, for stack-map * frame computation. JDK library classes resolve through the parent (bootstrap) loader, so the diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java index 010492de824..1df4ba72011 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java @@ -91,7 +91,12 @@ static int estimateBytes(AbstractInsnNode n) { case AbstractInsnNode.INVOKE_DYNAMIC_INSN: return 5; case AbstractInsnNode.JUMP_INSN: - return 5; + // GOTO/JSR have a 5-byte wide form (GOTO_W/JSR_W). A conditional branch (IF*) has NO wide + // form, so when its target is out of the +-32KB range ASM widens it to an inverted 3-byte + // conditional over a 5-byte GOTO_W == 8 bytes. Charge the widest form of each so the + // upper-bound estimate never undercounts a method dense with long-range conditionals. + return n.getOpcode() == org.objectweb.asm.Opcodes.GOTO + || n.getOpcode() == org.objectweb.asm.Opcodes.JSR ? 5 : 8; case AbstractInsnNode.LDC_INSN: return 3; case AbstractInsnNode.IINC_INSN: diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 80b7addc5c6..d3eacdb0dc5 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -205,6 +205,61 @@ public org.objectweb.asm.FieldVisitor visitField(int access, String name, String }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); } + /** + * Collects every string a class contributes to ParparVM's constant pool as an interned-free literal + * -- {@code LDC} operands and {@code static final String} {@code ConstantValue}s. Used to gather the + * literals of the UNHARDENED library jars: on a ParparVM-C target a compile-time literal is a + * constant-pool object that is never interned, while an encrypted app copy is {@code intern()}ed, so + * a value encrypted in the app but left plaintext in a library class would compare {@code !=} against + * the library copy even though the two equal literals were reference-equal before hardening. The + * engine excludes these from encryption so that identity is preserved across the library boundary. + */ + public static void collectAllLiterals(byte[] classBytes, final java.util.Set out) { + new ClassReader(classBytes).accept(new org.objectweb.asm.ClassVisitor(Opcodes.ASM9) { + @Override + public org.objectweb.asm.FieldVisitor visitField(int access, String name, String desc, + String sig, Object value) { + if (value instanceof String) { + out.add((String) value); + } + return null; + } + + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, String desc, + String sig, String[] exceptions) { + return new org.objectweb.asm.MethodVisitor(Opcodes.ASM9) { + @Override + public void visitLdcInsn(Object cst) { + if (cst instanceof String) { + out.add((String) cst); + } + } + }; + } + }, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } + + /** + * Values that appear as plaintext literals in the UNHARDENED library jars. On a ParparVM-C target + * encrypting an app copy of one of these would break a valid literal {@code ==} against the library's + * (never interned) constant-pool copy, so they are excluded from encryption. Null when there is no + * such constraint (a real-JVM/Android target, where every compile-time literal is interned anyway). + */ + private java.util.Set libraryLiterals; + /** Distinct values this transform left plaintext because a library class also holds them as a literal. */ + private final java.util.Set libraryExcludedValues = new java.util.HashSet(); + + /** Sets the unhardened-library literal set whose values must stay plaintext to preserve identity. */ + void setLibraryLiterals(java.util.Set values) { + this.libraryLiterals = values; + } + + /** The distinct values left plaintext because an unhardened library class also holds them as a literal. */ + java.util.Set getLibraryExcludedValues() { + return libraryExcludedValues; + } + public int getEncryptedCount() { return encryptedCount; } @@ -1199,6 +1254,12 @@ private boolean shouldEncryptLiteral(String s) { if (jarExcluded != null && jarExcluded.contains(s)) { return false; } + if (libraryLiterals != null && libraryLiterals.contains(s)) { + // Left plaintext so the app copy stays the same (never-interned) constant-pool object the + // unhardened library class uses, preserving a valid literal == across the boundary on ParparVM. + libraryExcludedValues.add(s); + return false; + } if (encryptAllStrings) { return true; } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index 14f2f532a99..582d0ec39c3 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -100,6 +100,89 @@ private static byte[] nativeInterface(String internalName) { return cw.toByteArray(); } + /** A class whose {@code run()} method loads each given string literal (and discards it). */ + private static byte[] classWithLiterals(String internal, String... literals) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internal, null, "java/lang/Object", null); + org.objectweb.asm.MethodVisitor mv = cw.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "run", "()V", null, null); + mv.visitCode(); + for (String s : literals) { + mv.visitLdcInsn(s); + mv.visitInsn(org.objectweb.asm.Opcodes.POP); + } + mv.visitInsn(org.objectweb.asm.Opcodes.RETURN); + mv.visitMaxs(1, 0); + mv.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + + private File writeJar(String name, String internal, byte[] classBytes) throws Exception { + File jar = tmp.newFile(name); + FileOutputStream fo = new FileOutputStream(jar); + ZipOutputStream zos = new ZipOutputStream(fo); + zos.putNextEntry(new ZipEntry(internal + ".class")); + zos.write(classBytes); + zos.closeEntry(); + zos.finish(); + fo.close(); + return jar; + } + + private HardeningResult hardenAppWithLibrary(String platform, String appInternal, String[] appLiterals, + String libInternal, String[] libLiterals, String suffix) throws Exception { + File appJar = writeJar("app-" + suffix + ".jar", appInternal, classWithLiterals(appInternal, appLiterals)); + File libJar = writeJar("lib-" + suffix + ".jar", libInternal, classWithLiterals(libInternal, libLiterals)); + Map hints = new HashMap(); + hints.put("harden.level", "aggressive"); + hints.put("harden.strings", "all"); + hints.put("harden.rename", "false"); // isolate string encryption -- no ProGuard dependency + hints.put("harden.controlFlow", "false"); + HardeningRequest req = new HardeningRequest() + .inputJar(appJar).outputJar(tmp.newFile("out-" + suffix + ".jar")) + .mappingFile(tmp.newFile("map-" + suffix + ".txt")) + .reportFile(tmp.newFile("report-" + suffix + ".json")) + .workDir(tmp.newFolder("work-" + suffix)) + .config(HardeningConfig.from(hints, platform, true)) + .mainClass(appInternal.replace('/', '.')); + req.addLibraryJar(libJar); + return HardeningEngine.harden(req); + } + + @Test + public void librarySharedLiteralsStayPlaintextOnParparVM() throws Exception { + // On a ParparVM-C target a compile-time literal is a never-interned constant-pool object while an + // encrypted app copy is intern()ed, so a value that ALSO appears as a literal in an unhardened + // library class must be left plaintext or a valid literal == against the library copy (which held + // before hardening) would break. A value unique to the app is still encrypted. + String shared = "value shared between the app and an unhardened library class"; + String appOnly = "a secret value that appears only in the application"; + HardeningResult r = hardenAppWithLibrary("ios", "app/App", new String[]{shared, appOnly}, + "lib/Lib", new String[]{shared}, "ios"); + assertTrue(r.isHardened()); + byte[] app = readAll(r.getHardenedJar()).get("app/App.class"); + assertTrue("a library-shared literal stays plaintext to preserve == on ParparVM", + StringEncryptTransform.containsStringLiteral(app, shared)); + assertFalse("an app-only literal is still encrypted", + StringEncryptTransform.containsStringLiteral(app, appOnly)); + } + + @Test + public void librarySharedLiteralsAreEncryptedOnRealJvm() throws Exception { + // On a real-JVM target every compile-time literal is interned to the same pool intern() uses, so + // there is no cross-boundary identity hazard: the library scan is skipped and the shared value IS + // encrypted (full coverage, no needless plaintext). + String shared = "value shared between the app and an unhardened library class"; + HardeningResult r = hardenAppWithLibrary("javase", "app/App", new String[]{shared}, + "lib/Lib", new String[]{shared}, "jvm"); + assertTrue(r.isHardened()); + byte[] app = readAll(r.getHardenedJar()).get("app/App.class"); + assertFalse("on a real JVM the shared literal is encrypted (no cross-boundary hazard)", + StringEncryptTransform.containsStringLiteral(app, shared)); + } + private HardeningResult harden(HardeningProfile profile, String platform, boolean renameSupported) throws Exception { File in = buildInputJar(); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/MethodSizeTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/MethodSizeTest.java new file mode 100644 index 00000000000..187306838b3 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/MethodSizeTest.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.hardening; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.JumpInsnNode; +import org.objectweb.asm.tree.LabelNode; + +/** The upper-bound byte estimator must never undercount an instruction's widest legal encoding. */ +public class MethodSizeTest { + + @Test + public void conditionalJumpChargesItsWidenedEightBytes() { + // A conditional IF* has no wide form, so ASM widens an out-of-range one to an inverted 3-byte + // conditional over a 5-byte GOTO_W == 8 bytes. Charging fewer would let a method dense with + // long-range conditionals pass the preflight and then throw MethodTooLargeException at write time. + LabelNode l = new LabelNode(); + InsnList insns = new InsnList(); + insns.add(new JumpInsnNode(Opcodes.IFEQ, l)); + insns.add(l); + assertEquals(8, MethodSize.estimateBytes(insns)); + } + + @Test + public void gotoAndJsrChargeTheirFiveByteWideForm() { + // GOTO/JSR DO have a 5-byte wide form (GOTO_W/JSR_W), so 5 is their widest encoding. + LabelNode g = new LabelNode(); + InsnList gotoList = new InsnList(); + gotoList.add(new JumpInsnNode(Opcodes.GOTO, g)); + gotoList.add(g); + assertEquals(5, MethodSize.estimateBytes(gotoList)); + + LabelNode j = new LabelNode(); + InsnList jsrList = new InsnList(); + jsrList.add(new JumpInsnNode(Opcodes.JSR, j)); + jsrList.add(j); + assertEquals(5, MethodSize.estimateBytes(jsrList)); + } +} From 9cf83ca954be74d302d95b8b16cbc7673148d672 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:06:46 +0700 Subject: [PATCH 064/110] Apply the engine's platform-safety rules in the hardening preflight hardeningRequestsAnyTransform decided a level reduces to off without regard to the target platform, but the engine SKIPS control-flow on the ParparVM native ports and skips string encryption on JavaScript. So a local/source iOS build with harden.level=aggressive, harden.rename=false, harden.strings=off (only control-flow left) -- or a JavaScript build with only string encryption left -- was rejected by HardeningPreflight even though the engine would apply nothing and return SKIPPED. The preflight now takes the resolved hardening platform and gates string encryption (skipped on javascript) and control-flow (runs only on android/javase/desktop) by the same rules as HardeningEngine, so a level whose only remaining transform is unsafe on the target reduces to off and is not rejected. Rename still counts on every platform (engine or R8 delivers it), and an unknown platform stays conservative (still preflighted). Covered by controlFlowOnlyReducesToOffOnParparVMTargets and stringOnlyReducesToOffOnJavaScript. Co-Authored-By: Claude Opus 4.8 --- .../com/codename1/maven/CN1BuildMojo.java | 48 +++++++++++++++++-- .../maven/HardeningPreflightTest.java | 32 +++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 1a59388a651..f8c1728acba 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -224,7 +224,7 @@ private void applyHardeningPreflight(Properties settings) throws MojoFailureExce // overrides to the effective transform set so such a build is not rejected on a local/source // or on-device-debug target for a "hardening" it isn't actually asking for. An unknown level // is NOT reduced here -- it must reach the preflight so the invalid-level check rejects it. - if (hardeningReducesToOff(settings, level)) { + if (hardeningReducesToOff(settings, level, hardenPlatform)) { level = "off"; } boolean allowLocal = "true".equalsIgnoreCase( @@ -313,10 +313,25 @@ private static boolean isHardenFalse(String value) { * cloud build be submitted for the forked engine to reject later. */ static boolean hardeningReducesToOff(Properties settings, String level) { - return hardenLevelRank(level) >= 1 && !hardeningRequestsAnyTransform(settings, level); + return hardeningReducesToOff(settings, level, null); + } + + /** + * As {@link #hardeningReducesToOff(Properties, String)}, but taking the resolved hardening + * {@code platform} so a transform the engine SKIPS as unsafe on that target does not keep the level + * from reducing to off. Without this, a local iOS build with only control-flow left on (which the + * engine skips on the ParparVM native ports), or a JavaScript build with only string encryption left + * on (skipped on JS), would be rejected for a hardening it would never actually apply. + */ + static boolean hardeningReducesToOff(Properties settings, String level, String platform) { + return hardenLevelRank(level) >= 1 && !hardeningRequestsAnyTransform(settings, level, platform); } static boolean hardeningRequestsAnyTransform(Properties settings, String level) { + return hardeningRequestsAnyTransform(settings, level, null); + } + + static boolean hardeningRequestsAnyTransform(Properties settings, String level, String platform) { int rank = hardenLevelRank(level); if (rank <= 0) { return false; @@ -325,15 +340,40 @@ static boolean hardeningRequestsAnyTransform(Properties settings, String level) // and constant-string encryption -- are on unless explicitly overridden off. Control-flow is a // default only from aggressive up. boolean atLeastAggressive = rank >= 2; + // Rename is delivered on every platform (by the engine, or by R8 on Android), so it always counts. boolean rename = hardenBoolTri( settings.getProperty("codename1.arg.harden.rename"), true); + // String encryption and control-flow are subject to the engine's platform-safety rules: a + // transform the engine would skip on this target must not, on its own, keep the level from + // reducing to off. When the platform is unknown the safety checks pass (conservative -- the + // build is still preflighted rather than silently allowed). boolean stringsOn = hardenStringsRequested( - settings.getProperty("codename1.arg.harden.strings"), true); + settings.getProperty("codename1.arg.harden.strings"), true) + && stringEncryptionAppliesOn(platform); boolean controlFlow = hardenBoolTri( - settings.getProperty("codename1.arg.harden.controlFlow"), atLeastAggressive); + settings.getProperty("codename1.arg.harden.controlFlow"), atLeastAggressive) + && controlFlowAppliesOn(platform); return rename || stringsOn || controlFlow; } + /** Engine rule: string encryption is skipped only on JavaScript (it would break the JS bridge). */ + private static boolean stringEncryptionAppliesOn(String platform) { + return !"javascript".equals(platform); + } + + /** + * Engine rule: control-flow obfuscation runs only on the JVM-bytecode ports (Android, JavaSE/ + * desktop); it is skipped on the ParparVM native ports and JavaScript. An unknown platform is + * treated as applicable so an ambiguous build is preflighted rather than silently allowed. + */ + private static boolean controlFlowAppliesOn(String platform) { + if (platform == null) { + return true; + } + return "and".equals(platform) || "android".equals(platform) + || "javase".equals(platform) || "desktop".equals(platform); + } + /** off/empty/unknown = 0, standard = 1, aggressive = 2, paranoid = 3. */ private static int hardenLevelRank(String level) { if (level == null) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java index d3260a76fb4..6d2b19afcde 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java @@ -144,4 +144,36 @@ public void validLevelWithEveryTransformOffReducesToOff() { assertFalse(CN1BuildMojo.hardeningReducesToOff(new java.util.Properties(), "standard")); assertFalse(CN1BuildMojo.hardeningReducesToOff(allOff, "off")); } + + @Test + public void controlFlowOnlyReducesToOffOnParparVMTargets() { + // aggressive with rename+strings off leaves only control flow, which the engine SKIPS on the + // ParparVM native ports. So on iOS this reduces to off (must not be rejected), while on a + // JVM-bytecode target (JavaSE/Android) control flow really runs and it does NOT reduce to off. + java.util.Properties cfOnly = new java.util.Properties(); + cfOnly.setProperty("codename1.arg.harden.rename", "false"); + cfOnly.setProperty("codename1.arg.harden.strings", "off"); + // controlFlow unset -> on by default at aggressive. + assertTrue(CN1BuildMojo.hardeningReducesToOff(cfOnly, "aggressive", "ios"), + "control flow is skipped on iOS, so nothing runs"); + assertTrue(CN1BuildMojo.hardeningReducesToOff(cfOnly, "aggressive", "win")); + assertFalse(CN1BuildMojo.hardeningReducesToOff(cfOnly, "aggressive", "javase"), + "control flow really runs on JavaSE"); + assertFalse(CN1BuildMojo.hardeningReducesToOff(cfOnly, "aggressive", "and"), + "control flow really runs on Android"); + } + + @Test + public void stringOnlyReducesToOffOnJavaScript() { + // standard with rename+controlFlow off leaves only string encryption, which the engine SKIPS on + // JavaScript (the native bridge). So on JS this reduces to off, but on iOS strings really run. + java.util.Properties strOnly = new java.util.Properties(); + strOnly.setProperty("codename1.arg.harden.rename", "false"); + strOnly.setProperty("codename1.arg.harden.controlFlow", "false"); + // strings unset -> constant-string encryption on by default at standard. + assertTrue(CN1BuildMojo.hardeningReducesToOff(strOnly, "standard", "javascript"), + "string encryption is skipped on JavaScript, so nothing runs"); + assertFalse(CN1BuildMojo.hardeningReducesToOff(strOnly, "standard", "ios"), + "string encryption really runs on iOS"); + } } From 9edd05be4cdea0739584c8dc3a9f669c97d381e4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:20:45 +0700 Subject: [PATCH 065/110] Disclose non-concat invokedynamic string args and short literals in the coverage report Two silent gaps in the strings:all coverage claim, both now counted and warned like the existing concat/condy/annotation/oversized disclosures: - countConcatLiterals only tallied StringConcatFactory.makeConcatWithConstants sites, so a custom invokedynamic emitted by a bytecode generator whose bootstrap arguments carry a plaintext String (or a nested constant-dynamic that does) left that literal readable while the report still advertised strings:all. New countIndyLiterals tallies every non-concat invokedynamic with a String bootstrap argument (reusing the condy recursion for nested constant-dynamics) and skips the concat sites so they are not double-counted; getIndyLiteralCount + an engine warning disclose it. - shouldEncrypt drops one- and two-character literals unconditionally, so a short sensitive literal stayed plaintext in strings:all with no counter or warning. New countShortLiterals tallies the distinct short literals the current mode would otherwise encrypt; getShortLiteralCount + an engine warning disclose them (a two-char value is trivially recovered even encrypted, so they are left plaintext by design -- this makes the coverage claim honest rather than silently omitting them). Tests: countsCustomInvokeDynamicStringBootstrapArguments, countsStringNestedInCustomInvokeDynamicCondy- Argument, customInvokeDynamicWithoutStringArgsIsNotCounted, concatSitesAreNotDoubleCountedAsGenericIndy, and shortLiteralsAreLeftPlaintextAndDisclosed. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/HardeningEngine.java | 22 ++++ .../hardening/StringEncryptTransform.java | 111 ++++++++++++++++++ .../hardening/ConcatLiteralDetectionTest.java | 56 +++++++++ .../hardening/StringEncryptTransformTest.java | 31 +++++ 4 files changed, 220 insertions(+) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 091615ae4ad..79e6535f59a 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -186,6 +186,8 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int legacyInterfaceConstants = 0; int oversizedLiterals = 0; int condyLiterals = 0; + int indyLiterals = 0; + int shortLiterals = 0; int clinitFullLiterals = 0; int annotationLiterals = 0; int jarExcludedLiterals = 0; @@ -238,6 +240,8 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); oversizedLiterals += t.getOversizedLiteralCount(); condyLiterals += t.getCondyLiteralCount(); + indyLiterals += t.getIndyLiteralCount(); + shortLiterals += t.getShortLiteralCount(); clinitFullLiterals += t.getClinitFullLiteralCount(); annotationLiterals += t.getAnnotationLiteralCount(); } @@ -251,6 +255,8 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi legacyInterfaceConstants = 0; oversizedLiterals = 0; condyLiterals = 0; + indyLiterals = 0; + shortLiterals = 0; clinitFullLiterals = 0; annotationLiterals = 0; libraryExcluded.clear(); @@ -265,6 +271,8 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); oversizedLiterals += t.getOversizedLiteralCount(); condyLiterals += t.getCondyLiteralCount(); + indyLiterals += t.getIndyLiteralCount(); + shortLiterals += t.getShortLiteralCount(); clinitFullLiterals += t.getClinitFullLiteralCount(); annotationLiterals += t.getAnnotationLiteralCount(); } @@ -379,6 +387,20 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "carrying string bootstrap arguments were not encrypted; such constants are " + "resolved at link time and remain in plaintext"); } + if (stringsApplied && indyLiterals > 0) { + // A custom invokedynamic (not a StringConcatFactory concat) can carry a plaintext string in + // its bootstrap arguments, resolved at link time, which no LDC/ConstantValue pass reaches. + result.getWarnings().add(indyLiterals + " invokedynamic site(s) carrying string bootstrap " + + "arguments (a non-concat bootstrap emitted by a bytecode generator) were not " + + "encrypted; such constants are resolved at link time and remain in plaintext"); + } + if (stringsApplied && cfg.isEncryptAllStrings() && shortLiterals > 0) { + // One- and two-character literals are left plaintext (the decoder overhead dwarfs them, and a + // two-char value is trivially brute-forced even encrypted). Disclose so strings:all is honest. + result.getWarnings().add(shortLiterals + " distinct one- or two-character string literal(s) " + + "were left in plaintext (too short to be worth encrypting); a short value is " + + "trivially recovered even when encrypted, so this is a disclosure note"); + } if (stringsApplied && oversizedLiterals > 0) { // A literal longer than ~21,845 chars can widen to a 3-byte-per-char constant whose // ciphertext overflows the 65535-byte constant pool, so it is left plaintext. Report it diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index d3eacdb0dc5..a831323500e 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -134,6 +134,8 @@ public final class StringEncryptTransform { private int clinitFullLiteralCount; private int methodFullLiteralCount; private int annotationLiteralCount; + private int indyLiteralCount; + private int shortLiteralCount; /** The input class's constant-pool item count, so hoisting can stay under the 65535-entry limit. */ private int poolBaseItems; /** @@ -310,6 +312,28 @@ public int getOversizedLiteralCount() { * {@code LDC "..."} nor in a field {@code ConstantValue}, and the condy is resolved at link time, so * rewriting it to a decode call is unsafe. Counted and reported rather than shipped unremarked. */ + /** + * The number of {@code invokedynamic} sites -- other than the {@code StringConcatFactory} concat + * recipes counted by {@link #getConcatLiteralCount()} -- whose bootstrap arguments carry a plaintext + * String (directly or through a nested constant-dynamic). A custom {@code invokedynamic} emitted by a + * bytecode generator can hold a literal in its bootstrap arguments that no {@code LDC}/ + * {@code ConstantValue} pass reaches, so it stays readable; reported so an {@code strings:all} build + * is not believed to have encrypted every string. + */ + public int getIndyLiteralCount() { + return indyLiteralCount; + } + + /** + * The number of distinct one- and two-character string literals the current mode would have + * encrypted but left plaintext because they are too short to be worth the decoder overhead. A + * two-character value is trivially brute-forced even when encrypted, so this is a disclosure note + * (the {@code strings:all} claim does not silently omit them), not a correctness risk. + */ + public int getShortLiteralCount() { + return shortLiteralCount; + } + public int getCondyLiteralCount() { return condyLiteralCount; } @@ -392,7 +416,9 @@ && f.value instanceof String && shouldEncryptLiteral((String) f.value)) { // silently shipped. concatLiteralCount += countConcatLiterals(cn); condyLiteralCount += countCondyLiterals(cn); + indyLiteralCount += countIndyLiterals(cn); annotationLiteralCount += countAnnotationStrings(cn); + shortLiteralCount += countShortLiterals(cn); // Count the distinct literals that would be encrypted but are too large to (their ciphertext // could overflow the constant pool), so the engine can report the exclusion rather than let an // strings:all build claim it encrypted everything. @@ -616,6 +642,91 @@ private static int countCondyLiterals(ClassNode cn) { return count; } + /** + * Counts the {@code invokedynamic} sites in {@code cn} that carry a plaintext String in their + * bootstrap arguments and are NOT the {@code StringConcatFactory.makeConcatWithConstants} concat + * recipes already tallied by {@link #countConcatLiterals(ClassNode)} (skipped here to avoid + * double-counting). A custom {@code invokedynamic} from a bytecode generator can hold a literal -- + * directly or in a nested constant-dynamic argument -- that no {@code LDC}/{@code ConstantValue} pass + * reaches. Counts the site once when any bootstrap argument bears a String. + */ + private static int countIndyLiterals(ClassNode cn) { + if (cn.methods == null) { + return 0; + } + int count = 0; + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (!(insn instanceof InvokeDynamicInsnNode)) { + continue; + } + InvokeDynamicInsnNode indy = (InvokeDynamicInsnNode) insn; + Handle bsm = indy.bsm; + if (bsm != null + && "java/lang/invoke/StringConcatFactory".equals(bsm.getOwner()) + && "makeConcatWithConstants".equals(bsm.getName())) { + continue; + } + if (indyBsmArgsHaveString(indy.bsmArgs)) { + count++; + } + } + } + return count; + } + + /** True when any bootstrap argument is a String, or a constant-dynamic that (nested) carries one. */ + private static boolean indyBsmArgsHaveString(Object[] bsmArgs) { + if (bsmArgs == null) { + return false; + } + for (Object arg : bsmArgs) { + if (arg instanceof String) { + return true; + } + if (arg instanceof ConstantDynamic && condyHasStringArgument((ConstantDynamic) arg)) { + return true; + } + } + return false; + } + + /** + * Counts the distinct one- and two-character string literals in {@code cn} that the current mode + * would encrypt (every {@code LDC} in "all" mode; a declared constant in "constants" mode) but + * {@link #shouldEncrypt(String)} leaves plaintext because they are too short to be worth the decoder + * overhead. Empty strings carry no information and are not counted; reported so the coverage claim + * discloses the short-literal exclusion rather than silently omitting it. + */ + private int countShortLiterals(ClassNode cn) { + if (cn.methods == null) { + return 0; + } + java.util.Set found = new java.util.HashSet(); + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String) { + String v = (String) ((LdcInsnNode) insn).cst; + if (v.length() >= 1 && v.length() <= 2 && wouldSelectButForLength(v)) { + found.add(v); + } + } + } + } + return found.size(); + } + + /** True when a value would be an encryption candidate in the current mode if it were long enough. */ + private boolean wouldSelectButForLength(String v) { + return encryptAllStrings || (constantValues != null && constantValues.contains(v)); + } + /** True when a constant-dynamic carries a String among its bootstrap arguments, nested ones too. */ private static boolean condyHasStringArgument(ConstantDynamic condy) { return condyHasStringArgument(condy, 0); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java index aef5867a005..7257cde7ecb 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java @@ -179,4 +179,60 @@ public void countsStringNestedInsideAnotherConstantDynamicArgument() { t.transform(cw.toByteArray()); assertEquals(1, t.getCondyLiteralCount()); } + + private static final Handle CUSTOM_BSM = new Handle( + Opcodes.H_INVOKESTATIC, + "app/CustomBootstrap", + "bootstrap", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;" + + "Ljava/lang/invoke/MethodType;Ljava/lang/String;)Ljava/lang/invoke/CallSite;", + false); + + private static byte[] customIndyFixture(Object[] bsmArgs) { + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, "app/CustomIndy", null, "java/lang/Object", null); + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "run", + "()Ljava/lang/Object;", null, null); + mv.visitCode(); + mv.visitInvokeDynamicInsn("compute", "()Ljava/lang/Object;", CUSTOM_BSM, bsmArgs); + mv.visitInsn(Opcodes.ARETURN); + mv.visitMaxs(1, 0); + mv.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + + @Test + public void countsCustomInvokeDynamicStringBootstrapArguments() { + // A non-concat invokedynamic whose bootstrap arguments carry a plaintext String is reported. + StringEncryptTransform t = new StringEncryptTransform(true, 7); + t.transform(customIndyFixture(new Object[] {"a plaintext secret in a custom indy bootstrap"})); + assertEquals(1, t.getIndyLiteralCount()); + } + + @Test + public void countsStringNestedInCustomInvokeDynamicCondyArgument() { + // The custom indy has no direct String argument; its plaintext hides in a NESTED constant-dynamic. + ConstantDynamic inner = new ConstantDynamic("inner", "Ljava/lang/String;", + CONDY_BSM, "nested-indy-plaintext"); + StringEncryptTransform t = new StringEncryptTransform(true, 7); + t.transform(customIndyFixture(new Object[] {inner})); + assertEquals(1, t.getIndyLiteralCount()); + } + + @Test + public void customInvokeDynamicWithoutStringArgsIsNotCounted() { + StringEncryptTransform t = new StringEncryptTransform(true, 7); + t.transform(customIndyFixture(new Object[] {Integer.valueOf(42)})); + assertEquals(0, t.getIndyLiteralCount()); + } + + @Test + public void concatSitesAreNotDoubleCountedAsGenericIndy() { + // The StringConcatFactory recipe sites are counted as concat exclusions, never again as indy. + StringEncryptTransform t = new StringEncryptTransform(true, 42); + t.transform(fixture()); + assertEquals(2, t.getConcatLiteralCount()); + assertEquals(0, t.getIndyLiteralCount()); + } } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 9e2f94a453f..3e0e985040f 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -352,6 +352,37 @@ public void staticFinalEncryptionIsCappedByConstantPoolBudget() throws Exception new java.io.PrintWriter(new java.io.StringWriter())); } + @Test + public void shortLiteralsAreLeftPlaintextAndDisclosed() throws Exception { + // One- and two-character literals are not worth the decoder overhead; in strings:all they stay + // plaintext but must be counted (distinctly) so the coverage claim does not silently omit them. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Shorts", null, "java/lang/Object", null); + org.objectweb.asm.MethodVisitor mv = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "run", "()V", null, null); + mv.visitCode(); + mv.visitLdcInsn("x"); // 1 char -> short + mv.visitInsn(org.objectweb.asm.Opcodes.POP); + mv.visitLdcInsn("ab"); // 2 char -> short + mv.visitInsn(org.objectweb.asm.Opcodes.POP); + mv.visitLdcInsn("x"); // duplicate short -> distinct count is still 2 + mv.visitInsn(org.objectweb.asm.Opcodes.POP); + mv.visitLdcInsn("a long enough literal to actually encrypt"); + mv.visitInsn(org.objectweb.asm.Opcodes.POP); + mv.visitInsn(org.objectweb.asm.Opcodes.RETURN); + mv.visitMaxs(1, 0); + mv.visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 9); + byte[] out = t.transform(w.toByteArray()); + assertEquals("two distinct short literals disclosed", 2, t.getShortLiteralCount()); + assertTrue("the long literal is still encrypted", t.getEncryptedCount() >= 1); + assertTrue("a short literal stays plaintext", + StringEncryptTransform.containsStringLiteral(out, "ab")); + } + @Test public void perAccessEncryptionIsCappedByConstantPoolBudget() throws Exception { // The per-access channel is NOT pool-neutral when a value's plaintext is retained elsewhere: the From 16a5c8f9e8f68f6be5d67286307c50a89db9787d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:37:47 +0700 Subject: [PATCH 066/110] Preserve static-final ConstantValues a bundled Android source references as a constant Verified the mechanism in AndroidGradleBuilder: unzip routes the .java/.kt sources bundled in the app jar into src/main/java (line 1399) while the transformed classes are zipped into libs/userClasses.jar from dummyClassesDir (line 3570), and Gradle compiles the sources against that jar. So when the string transform strips a public static final String's ConstantValue (to decode it in ), a bundled source that uses that constant in a constant-expression context -- a case label, an annotation value, another constant's initializer -- fails javac/kotlinc with a constant-expression error, because the field is no longer a compile-time constant. (Reachable within one CN1Lib that ships both a Java constant class and Android native source using it; a real JVM/iOS build never hits it -- iOS routes carried source to the resource tree and does not compile it.) The engine now collects the identifiers referenced by bundled .java/.kt sources on a target that compiles them (compilesCarriedSource == Android) and preserves the ConstantValue of any static-final String whose field name is among them; the inlined reads elsewhere in the app are still encrypted, and the preserved plaintext is counted and disclosed. Preservation is selective -- a constant not named by any bundled source is still stripped/encrypted. Covered by staticFinalConstantReferencedByBundledSourceKeepsItsConstantValue- OnAndroid (MODE preserved, unreferenced OTHER still encrypted) and staticFinalConstantIsStrippedWhenNoSourceCompilesAgainstIt (iOS strips as usual). Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/HardeningEngine.java | 65 +++++++++++++ .../hardening/StringEncryptTransform.java | 31 +++++++ .../hardening/HardeningEngineTest.java | 93 +++++++++++++++++++ 3 files changed, 189 insertions(+) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 79e6535f59a..99b0705159e 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -192,6 +192,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int annotationLiterals = 0; int jarExcludedLiterals = 0; int libraryExcludedLiterals = 0; + int sourcePreservedConstants = 0; boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); if (stringsApplied) { // In "constants" mode, first collect the values declared as static-final String @@ -221,6 +222,26 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi final java.util.Set libLiterals = libraryLiterals != null && !libraryLiterals.isEmpty() ? libraryLiterals : null; java.util.Set libraryExcluded = new java.util.HashSet(); + // On a target that compiles the .java/.kt source bundled in the app jar against the + // transformed classes (Android places those sources in src/main/java and the hardened classes + // in libs/userClasses.jar), stripping a static-final String ConstantValue would break a + // case-label/annotation/const-initializer reference to it in that source. Collect the + // identifiers such sources reference so those constants keep their ConstantValue. + java.util.Set srcNames = null; + if (compilesCarriedSource(cfg.getPlatform())) { + java.util.Set ids = new java.util.HashSet(); + for (Map.Entry e : nonClass.asMap().entrySet()) { + String name = e.getKey().toLowerCase(); + if (name.endsWith(".java") || name.endsWith(".kt")) { + collectSourceIdentifiers(new String(e.getValue(), + java.nio.charset.Charset.forName("UTF-8")), ids); + } + } + if (!ids.isEmpty()) { + srcNames = ids; + } + } + final java.util.Set sourceReferencedNames = srcNames; // Pass 1 (from a snapshot of the input bytes): transform every class, tally the counts, and // collect the values any class could NOT encrypt (a method too full for the decode call, or a // class whose pool cannot fit the decoder). A value encrypted+interned in one class but left @@ -232,9 +253,11 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi StringEncryptTransform t = new StringEncryptTransform( cfg.isEncryptAllStrings(), seed, hierarchy, constantValues, null); t.setLibraryLiterals(libLiterals); + t.setSourceReferencedNames(sourceReferencedNames); e.setValue(t.transform(e.getValue())); jarExcluded.addAll(t.getNewlyExcluded()); libraryExcluded.addAll(t.getLibraryExcludedValues()); + sourcePreservedConstants += t.getSourcePreservedConstantCount(); encryptedStrings += t.getEncryptedCount(); concatLiterals += t.getConcatLiteralCount(); legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); @@ -259,13 +282,16 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi shortLiterals = 0; clinitFullLiterals = 0; annotationLiterals = 0; + sourcePreservedConstants = 0; libraryExcluded.clear(); for (Map.Entry e : renamed.entrySet()) { StringEncryptTransform t = new StringEncryptTransform( cfg.isEncryptAllStrings(), seed, hierarchy, constantValues, jarExcluded); t.setLibraryLiterals(libLiterals); + t.setSourceReferencedNames(sourceReferencedNames); e.setValue(t.transform(original.get(e.getKey()))); libraryExcluded.addAll(t.getLibraryExcludedValues()); + sourcePreservedConstants += t.getSourcePreservedConstantCount(); encryptedStrings += t.getEncryptedCount(); concatLiterals += t.getConcatLiteralCount(); legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); @@ -423,6 +449,14 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "plaintext in every class because at least one class could not encrypt them (a " + "method or constant pool near the JVM limit); those literals stay readable"); } + if (stringsApplied && sourcePreservedConstants > 0) { + // A static-final String referenced by a bundled .java/.kt source in a constant-expression + // context keeps its ConstantValue so that source still compiles; disclose the plaintext. + result.getWarnings().add(sourcePreservedConstants + " static-final String constant(s) kept " + + "their plaintext ConstantValue because a bundled .java/.kt source may reference them " + + "as a compile-time constant (a case label or annotation value), which requires the " + + "attribute to compile; their inlined reads are still encrypted"); + } if (stringsApplied && libraryExcludedLiterals > 0) { // Values that also appear as a literal in an unhardened library class are left plaintext so a // literal == against the library's (never interned) constant-pool copy still holds on ParparVM. @@ -508,6 +542,37 @@ static boolean translatesThroughParparVMC(String platform) { || "tv".equals(platform) || "win".equals(platform) || "linux".equals(platform); } + /** + * True for a target that javac/kotlinc-compiles the {@code .java}/{@code .kt} sources bundled in the + * app jar against the transformed classes. Android does (the sources land in {@code src/main/java} + * and the hardened classes in {@code libs/userClasses.jar}); iOS routes carried source into the + * resource tree and never compiles it, win/linux compile only the ParparVM translator's generated + * {@code .java}, and JavaSE runs the bytecode directly. On such a target a stripped + * {@code ConstantValue} would break a constant-expression reference from the carried source. + */ + static boolean compilesCarriedSource(String platform) { + return "and".equals(platform) || "android".equals(platform); + } + + /** Adds every Java/Kotlin identifier token in {@code source} to {@code out} (a safe over-set). */ + private static void collectSourceIdentifiers(String source, java.util.Set out) { + int n = source.length(); + int i = 0; + while (i < n) { + char c = source.charAt(i); + if (Character.isJavaIdentifierStart(c)) { + int start = i; + i++; + while (i < n && Character.isJavaIdentifierPart(source.charAt(i))) { + i++; + } + out.add(source.substring(start, i)); + } else { + i++; + } + } + } + /** * Collects every string literal ({@code LDC} operand or {@code static final String} * {@code ConstantValue}) in every class of {@code jar} into {@code out}. Used to gather the diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index a831323500e..86056bb65a6 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -262,6 +262,28 @@ java.util.Set getLibraryExcludedValues() { return libraryExcludedValues; } + /** + * Simple field names a carried {@code .java}/{@code .kt} source (bundled in the app jar and compiled + * downstream against the transformed classes, e.g. an Android CN1Lib native source) may reference as a + * compile-time constant. A {@code static final String} whose name is in this set keeps its + * {@code ConstantValue} attribute: stripping it would make the field a non-constant and break a + * {@code case}/annotation/const-initializer reference in that source at javac/kotlinc time. Null on a + * target that does not compile carried source, where the attribute is stripped as usual. + */ + private java.util.Set sourceReferencedNames; + /** Count of static-final constants whose ConstantValue was preserved for a carried source reference. */ + private int sourcePreservedConstantCount; + + /** Sets the field names carried source may reference as constants, whose ConstantValue is preserved. */ + void setSourceReferencedNames(java.util.Set names) { + this.sourceReferencedNames = names; + } + + /** Count of static-final String constants left plaintext to keep a carried source's compilation valid. */ + int getSourcePreservedConstantCount() { + return sourcePreservedConstantCount; + } + public int getEncryptedCount() { return encryptedCount; } @@ -1081,6 +1103,15 @@ private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInte for (FieldNode fn : cn.fields) { boolean isStatic = (fn.access & Opcodes.ACC_STATIC) != 0; if (isStatic && fn.value instanceof String && shouldEncrypt((String) fn.value)) { + if (sourceReferencedNames != null && sourceReferencedNames.contains(fn.name)) { + // A carried .java/.kt source may use this constant in a constant-expression context + // (a case label, an annotation value, another constant's initializer). Stripping its + // ConstantValue would make the field a non-constant and break that source's javac/ + // kotlinc compilation against the transformed jar, so preserve it (plaintext, + // disclosed). The inlined reads elsewhere in the app are still encrypted. + sourcePreservedConstantCount++; + continue; + } if (toStrip.size() >= budget) { // Pool budget exhausted: leave this constant plaintext (dead value, reported). clinitFullLiteralCount++; diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index 582d0ec39c3..a2f1f18b0b8 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -151,6 +151,99 @@ private HardeningResult hardenAppWithLibrary(String platform, String appInternal return HardeningEngine.harden(req); } + /** + * A class with {@code public static final String FIELD = value} plus a second, unreferenced + * {@code OTHER} constant so an Android build (where the referenced FIELD is preserved) still encrypts + * something and is marked hardened. + */ + private static byte[] classWithStaticFinalString(String internal, String field, String value) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internal, null, "java/lang/Object", null); + cw.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, field, "Ljava/lang/String;", null, value).visitEnd(); + cw.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "OTHER", "Ljava/lang/String;", null, + "an unrelated secret constant not named by any bundled source").visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + + /** The ConstantValue of the named static-final String field in a class, or null if stripped/absent. */ + private static String constantValueOf(byte[] classBytes, final String field) { + final String[] holder = new String[1]; + new org.objectweb.asm.ClassReader(classBytes).accept( + new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) { + @Override + public org.objectweb.asm.FieldVisitor visitField(int access, String name, String desc, + String sig, Object value) { + if (field.equals(name)) { + holder[0] = value instanceof String ? (String) value : null; + } + return null; + } + }, org.objectweb.asm.ClassReader.SKIP_CODE); + return holder[0]; + } + + private HardeningResult hardenClassWithBundledSource(String platform, String constantValue, + String bundledSource, String suffix) throws Exception { + File jar = tmp.newFile("cv-" + suffix + ".jar"); + FileOutputStream fo = new FileOutputStream(jar); + ZipOutputStream zos = new ZipOutputStream(fo); + zos.putNextEntry(new ZipEntry("app/Constants.class")); + zos.write(classWithStaticFinalString("app/Constants", "MODE", constantValue)); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("com/lib/Native.java")); + zos.write(bundledSource.getBytes("UTF-8")); + zos.closeEntry(); + zos.finish(); + fo.close(); + Map hints = new HashMap(); + hints.put("harden.level", "aggressive"); + hints.put("harden.strings", "all"); + hints.put("harden.rename", "false"); + HardeningRequest req = new HardeningRequest() + .inputJar(jar).outputJar(tmp.newFile("cv-out-" + suffix + ".jar")) + .mappingFile(tmp.newFile("cv-map-" + suffix + ".txt")) + .reportFile(tmp.newFile("cv-report-" + suffix + ".json")) + .workDir(tmp.newFolder("cv-work-" + suffix)) + .config(HardeningConfig.from(hints, platform, false)) + .mainClass("app.Constants"); + return HardeningEngine.harden(req); + } + + @Test + public void staticFinalConstantReferencedByBundledSourceKeepsItsConstantValueOnAndroid() throws Exception { + // A bundled Android .java source references app.Constants.MODE in a case label. On Android the + // source is compiled against the transformed classes, so stripping MODE's ConstantValue would + // break that compilation. The engine preserves it (still a compile-time constant) on Android. + String value = "the mode constant a bundled source needs"; + String source = "package com.lib; class Native { int f(int x){ switch(x){ " + + "case /* app.Constants. */ 0: return app.Constants.MODE.length(); default: return 0; } } }"; + HardeningResult r = hardenClassWithBundledSource("and", value, source, "and"); + assertTrue(r.isHardened()); + byte[] cls = readAll(r.getHardenedJar()).get("app/Constants.class"); + assertEquals("MODE keeps its ConstantValue so the bundled source still compiles", value, + constantValueOf(cls, "MODE")); + // Preservation is selective: a constant NOT named by any bundled source is still encrypted. + assertEquals("an unreferenced constant is still stripped/encrypted", null, + constantValueOf(cls, "OTHER")); + } + + @Test + public void staticFinalConstantIsStrippedWhenNoSourceCompilesAgainstIt() throws Exception { + // iOS routes bundled .java into the resource tree and never compiles it, so there is no + // constant-expression hazard: MODE's ConstantValue is stripped and encrypted as usual. + String value = "the mode constant a bundled source needs"; + String source = "package com.lib; class Native { int f(){ return app.Constants.MODE.length(); } }"; + HardeningResult r = hardenClassWithBundledSource("ios", value, source, "ios"); + assertTrue(r.isHardened()); + assertEquals("on iOS the ConstantValue is stripped (encrypted), no bundled source compiles it", + null, constantValueOf(readAll(r.getHardenedJar()).get("app/Constants.class"), "MODE")); + assertTrue("the stripped constant is encrypted", r.getEncryptedStrings() >= 1); + } + @Test public void librarySharedLiteralsStayPlaintextOnParparVM() throws Exception { // On a ParparVM-C target a compile-time literal is a never-interned constant-pool object while an From 4f1e274309850a2f7221e8e64f6ef719715982f3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:50:30 +0700 Subject: [PATCH 067/110] Honor jar-wide and library exclusions in the static-final string path encryptStaticFinalStrings (and collectSelectedValues) gated on shouldEncrypt(), which -- unlike shouldEncryptLiteral() used by the method-literal paths -- does not consult jarExcluded or libraryLiterals. So a value excluded jar-wide because some method could not grow to encrypt it stayed plaintext in that method's LDC (pass 2) but was still stripped and decoded+interned in the static-final field that declared it. On ParparVM a GETSTATIC read of that field then returns the interned decoded string while the excluded literal stays a constant-pool object, so the two compare != despite Java's literal identity guarantee (the exact invariant the two-pass exclusion exists to protect); a library-shared constant leaked the same way. Both static-field sites now use shouldEncryptLiteral, so an excluded or library-shared constant keeps its plaintext ConstantValue. New test jarExcludedValueStaysPlaintextInAStaticFinalField. Co-Authored-By: Claude Opus 4.8 --- .../hardening/StringEncryptTransform.java | 8 +++-- .../hardening/StringEncryptTransformTest.java | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 86056bb65a6..bb182652250 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -922,7 +922,7 @@ && shouldEncryptLiteral((String) ((LdcInsnNode) insn).cst)) { if (cn.fields != null) { for (FieldNode fn : cn.fields) { if ((fn.access & Opcodes.ACC_STATIC) != 0 && fn.value instanceof String - && shouldEncrypt((String) fn.value)) { + && shouldEncryptLiteral((String) fn.value)) { out.add((String) fn.value); } } @@ -1102,7 +1102,11 @@ private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInte java.util.List toStrip = new java.util.ArrayList(); for (FieldNode fn : cn.fields) { boolean isStatic = (fn.access & Opcodes.ACC_STATIC) != 0; - if (isStatic && fn.value instanceof String && shouldEncrypt((String) fn.value)) { + // shouldEncryptLiteral, not shouldEncrypt: a value excluded jar-wide (some method could not + // grow to encrypt it) or shared with an unhardened library must stay plaintext HERE too, or a + // GETSTATIC read of this decoded+interned field would compare != to the excluded plaintext + // literal on ParparVM, breaking Java's literal identity guarantee. + if (isStatic && fn.value instanceof String && shouldEncryptLiteral((String) fn.value)) { if (sourceReferencedNames != null && sourceReferencedNames.contains(fn.name)) { // A carried .java/.kt source may use this constant in a constant-expression context // (a case label, an annotation value, another constant's initializer). Stripping its diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 3e0e985040f..0dc36b237bb 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -352,6 +352,35 @@ public void staticFinalEncryptionIsCappedByConstantPoolBudget() throws Exception new java.io.PrintWriter(new java.io.StringWriter())); } + @Test + public void jarExcludedValueStaysPlaintextInAStaticFinalField() throws Exception { + // A value excluded jar-wide (a method elsewhere could not grow to encrypt it) must stay plaintext + // in the static-final field that declares it, or a GETSTATIC read of that decoded+interned field + // would compare != to the excluded plaintext literal on ParparVM. The static-field path must honor + // jarExcluded exactly like the method-literal path. + String excluded = "a jar-wide excluded constant that must remain plaintext everywhere"; + String other = "an unrelated constant that is still encrypted here"; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Const", null, "java/lang/Object", null); + w.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "K", "Ljava/lang/String;", null, excluded).visitEnd(); + w.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "K2", "Ljava/lang/String;", null, other).visitEnd(); + w.visitEnd(); + + java.util.Set jarExcluded = new java.util.HashSet(); + jarExcluded.add(excluded); + StringEncryptTransform t = new StringEncryptTransform(true, 3, null, null, jarExcluded); + byte[] out = t.transform(w.toByteArray()); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + assertTrue("the jar-excluded constant keeps its plaintext ConstantValue", + StringEncryptTransform.containsStringLiteral(out, excluded)); + assertFalse("a non-excluded constant is still encrypted", + StringEncryptTransform.containsStringLiteral(out, other)); + } + @Test public void shortLiteralsAreLeftPlaintextAndDisclosed() throws Exception { // One- and two-character literals are not worth the decoder overhead; in strings:all they stay From 60132cf41ca4fbf033e394fbbe04abea5fd8ccd1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:10:11 +0700 Subject: [PATCH 068/110] Exclude the ParparVM runtime jar's literals and jar-wide-exclude budget-skipped constants Two more ParparVM literal-identity gaps: - The library-literal exclusion scanned only req.getLibraryJars() (the app's compile classpath), but the ParparVM-C builders stage the target Java runtime parparvm-java-api.jar (java.lang.Boolean etc.) later and never harden it. So an app expression like Boolean.TRUE.toString() == "true" could turn false: the app "true" is encrypted then interned, while Boolean.toString() returns the runtime's non-interned constant-pool literal. Executor.hardeningLibraryJars now adds parparvm-java-api.jar on the ParparVM-C targets (new isParparVMCPlatform), so the engine's existing library-literal scan excludes its literals; it also doubles as a ProGuard -libraryjars entry. Covered by parparvmCTargetsAreRecognizedForRuntimeLiteralExclusion. - encryptStaticFinalStrings left a static-final ConstantValue plaintext when the pool budget was exhausted (and when could not accept the decode steps) WITHOUT recording the value in newlyExcluded, so pass 2 still encrypted an equal LDC elsewhere -- a GETSTATIC read of the still-plain field then compared != to that interned copy on ParparVM. Both branches now add the skipped values to newlyExcluded so pass 2 leaves them plaintext jar-wide. staticFinalEncryptionIsCappedByConstantPoolBudget now asserts the budget-skipped constants are excluded. Co-Authored-By: Claude Opus 4.8 --- .../hardening/StringEncryptTransform.java | 11 +++++++- .../hardening/StringEncryptTransformTest.java | 4 +++ .../java/com/codename1/builders/Executor.java | 27 +++++++++++++++++++ .../builders/HardeningBooleanArgTest.java | 14 ++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index bb182652250..8ad9f3015ad 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -1117,8 +1117,11 @@ private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInte continue; } if (toStrip.size() >= budget) { - // Pool budget exhausted: leave this constant plaintext (dead value, reported). + // Pool budget exhausted: leave this constant plaintext, and exclude it jar-wide so an + // equal LDC elsewhere is not encrypted+interned -- a GETSTATIC read of this still-plain + // field would otherwise compare != to that interned copy on ParparVM. clinitFullLiteralCount++; + newlyExcluded.add((String) fn.value); continue; } String plain = (String) fn.value; @@ -1136,7 +1139,13 @@ private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInte return false; } if (!clinitCanAccept(cn, init)) { + // The cannot hold the decode steps, so none of these constants can be encrypted here. + // Exclude them jar-wide so an equal LDC in another class is not encrypted+interned while these + // fields stay plaintext, which a GETSTATIC read would see as a broken == on ParparVM. clinitFullLiteralCount += toStrip.size(); + for (FieldNode fn : toStrip) { + newlyExcluded.add((String) fn.value); + } return false; } // Commit: strip each ConstantValue so the plaintext leaves the class file entirely (the slot diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 0dc36b237bb..de1651ae56a 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -347,6 +347,10 @@ public void staticFinalEncryptionIsCappedByConstantPoolBudget() throws Exception byte[] out = t.transform(w.toByteArray()); assertTrue("some constants are encrypted within the pool budget", t.getEncryptedCount() > 0); assertTrue("the rest are left plaintext and reported", t.getClinitFullLiteralCount() > 0); + // The budget-skipped constants must be excluded jar-wide, so an equal LDC in another class is not + // encrypted+interned while these fields stay plaintext (a GETSTATIC == mismatch on ParparVM). + assertFalse("budget-skipped constants are recorded for jar-wide exclusion", + t.getNewlyExcluded().isEmpty()); // The class assembles and verifies -- no ClassTooLargeException. CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, new java.io.PrintWriter(new java.io.StringWriter())); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index ef63f3b78a2..555f84a1c2a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2384,6 +2384,17 @@ protected boolean hardeningRenameSupported() { * dispatch at runtime). The caller supplies the compile/platform classpath in the * {@code cn1.hardening.libraryJars} request argument (path-separated); subclasses may add more. */ + /** + * True for the ParparVM-to-C targets (iOS/mac/watch/tv/win/linux), whose app links against the + * {@code parparvm-java-api.jar} runtime. A compile-time literal there is a constant-pool object that + * ParparVM never interns, so an encrypted app copy of the same value would not be reference-equal to + * it; the runtime's literals are therefore excluded from encryption on these targets. + */ + protected boolean isParparVMCPlatform(String platform) { + return "ios".equals(platform) || "mac".equals(platform) || "watch".equals(platform) + || "tv".equals(platform) || "win".equals(platform) || "linux".equals(platform); + } + protected java.util.List hardeningLibraryJars(BuildRequest request) { java.util.List jars = new java.util.ArrayList(); // Always include the Codename One framework jar: every builder receives it, and it carries @@ -2394,6 +2405,22 @@ protected java.util.List hardeningLibraryJars(BuildRequest request) { if (codenameOneJar != null && codenameOneJar.exists()) { jars.add(codenameOneJar); } + // On a ParparVM-C target the app also links against the ParparVM Java runtime + // (parparvm-java-api.jar -- java.lang.Boolean, java.lang.String, ...), which the builder stages + // later and never hardens. Its literals must reach the engine's library-literal exclusion scan, + // or an app value like "true" (encrypted then interned) would compare != to a runtime-returned + // copy such as Boolean.toString() -- a constant-pool literal ParparVM never interns -- breaking a + // reference comparison that held before hardening. It doubles as a -libraryjars entry for ProGuard. + if (isParparVMCPlatform(hardeningPlatform(request))) { + try { + File runtime = getResourceAsFile("/parparvm-java-api.jar", ".jar"); + if (runtime != null && runtime.exists() && !jars.contains(runtime)) { + jars.add(runtime); + } + } catch (IOException ex) { + // Best-effort: without the runtime jar the scan simply misses its literals (a rare == edge). + } + } String raw = request.getArg("cn1.hardening.libraryJars", ""); if (raw == null || raw.length() == 0) { // Fallback: the maven plugin publishes the compile classpath here (a single injection diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HardeningBooleanArgTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HardeningBooleanArgTest.java index ed398845938..f816006bcc9 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HardeningBooleanArgTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HardeningBooleanArgTest.java @@ -90,4 +90,18 @@ void truthyAndDefaultsBehaveAsExpected() { assertTrue(p.parse("maybe", true), "unrecognized -> default"); assertFalse(p.parse("maybe", false), "unrecognized -> default (false)"); } + + @Test + void parparvmCTargetsAreRecognizedForRuntimeLiteralExclusion() { + // The parparvm-java-api.jar runtime-literal exclusion applies exactly to the ParparVM-to-C + // targets, where a compile-time literal is a constant-pool object that is never interned; the + // DEX/JVM/JS targets intern their compile-time literals so no exclusion is needed there. + Probe p = new Probe(); + for (String t : new String[] {"ios", "mac", "watch", "tv", "win", "linux"}) { + assertTrue(p.isParparVMCPlatform(t), t + " translates to C via ParparVM"); + } + for (String t : new String[] {"and", "android", "javase", "desktop", "javascript"}) { + assertFalse(p.isParparVMCPlatform(t), t + " interns its compile-time literals"); + } + } } From d606187db253b19dc1845148bea77445561fbe60 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:27:21 +0700 Subject: [PATCH 069/110] Skip Android R8-rename enforcement when hardening was forced off The R8-rename requirement computed hardenRenames purely from harden.level/harden.rename, so a local android-source/local-android build that took the harden.allowUnhardenedLocalBuild escape hatch -- where preflight sets cn1.harden.forceOff and hardenSourceJar returns the ORIGINAL jar stamped cn1.hardened=false -- was still rejected if it had R8/ProGuard disabled or no release certificate, even though nothing was hardened or renamed. The decision now goes through androidRenameHardeningActive(), gated first on the VERIFIED cn1.hardened output exactly as hardeningR8Keep() does, so a forced-off build requires no R8 while a genuinely hardened rename profile still must run it. New test forcedOffLocalBuildDoesNotRequireR8. Co-Authored-By: Claude Opus 4.8 --- .../builders/AndroidGradleBuilder.java | 28 ++++++++++++++--- .../AndroidGradleBuilderVersionTest.java | 30 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index bccdca46268..cd0c9646596 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -352,6 +352,28 @@ static boolean r8RenameRequiredButDisabled(boolean renameRequested, String enabl return renameRequested && !"true".equals(enableProguardArg); } + /** + * True when a rename-delivering hardening profile is active for THIS build, so the R8 renaming the + * profile promises must be enforced. Gated first on the VERIFIED {@code cn1.hardened} output (set only + * after a successful, entitled engine run, exactly as {@link #hardeningR8Keep(BuildRequest)}): a build + * that took the {@code harden.allowUnhardenedLocalBuild} escape hatch has {@code cn1.harden.forceOff} + * set, so {@code hardenSourceJar} returned the original jar stamped {@code cn1.hardened=false} and + * nothing was renamed to enforce -- enforcing R8 there would defeat the documented escape hatch. The + * tri-state opt-outs ({@code androidHardeningEnabled}, {@code renameRequested}) are resolved by the + * caller with the engine's {@code boolTri} rules. + */ + static boolean androidRenameHardeningActive(BuildRequest request, boolean androidHardeningEnabled, + boolean renameRequested) { + if (!"true".equals(request.getArg("cn1.hardened", "false"))) { + return false; + } + String hardenLevel = request.getArg("harden.level", "off"); + return androidHardeningEnabled + && hardenLevel != null && !"off".equalsIgnoreCase(hardenLevel.trim()) + && hardenLevel.trim().length() > 0 + && renameRequested; + } + /** * True when this build will produce a signed release variant -- the only variant whose Gradle * buildType carries {@code minifyEnabled}, and therefore the only one R8 actually renames. A @@ -878,10 +900,8 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc // (false/0/off/no all mean off), so harden.rename=off and harden.rename=0 behave identically // to harden.rename=false here rather than being misread as "renaming still requested". boolean androidHardeningEnabled = hardenBoolArg(request, "harden.and.enabled", true); - boolean hardenRenames = androidHardeningEnabled - && hardenLevel != null && !"off".equalsIgnoreCase(hardenLevel.trim()) - && hardenLevel.trim().length() > 0 - && hardenBoolArg(request, "harden.rename", true); + boolean hardenRenames = androidRenameHardeningActive(request, androidHardeningEnabled, + hardenBoolArg(request, "harden.rename", true)); // R8 actually renames only for a signed RELEASE variant built with minification: minifyEnabled // lives in the release buildType and is emitted only when android.enableProguard is exactly // "true", and a debug-only build (or one with no signing certificate) runs only assembleDebug. diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java index 8e75a9e9160..2b55108140c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java @@ -81,6 +81,36 @@ void renameHardeningNeedsAReleaseVariantNotJustEnableProguard() { assertFalse(AndroidGradleBuilder.androidReleaseVariantBuilt(noCert)); } + @Test + void forcedOffLocalBuildDoesNotRequireR8() { + // harden.allowUnhardenedLocalBuild takes the escape hatch: hardenSourceJar returns the original + // jar stamped cn1.hardened=false, so the R8-rename enforcement must NOT fire even though the level + // still reads aggressive -- otherwise a local build with R8 off or no release cert is rejected + // despite opting out of hardening. + BuildRequest forcedOff = new BuildRequest(); + forcedOff.putArgument("harden.level", "aggressive"); + forcedOff.putArgument("cn1.hardened", "false"); + assertFalse(AndroidGradleBuilder.androidRenameHardeningActive(forcedOff, true, true), + "cn1.hardened=false (forced-off escape hatch) must not require R8"); + + // A build that actually hardened (verified output) with a rename profile DOES require R8. + BuildRequest hardened = new BuildRequest(); + hardened.putArgument("harden.level", "aggressive"); + hardened.putArgument("cn1.hardened", "true"); + assertTrue(AndroidGradleBuilder.androidRenameHardeningActive(hardened, true, true), + "a verified hardened rename profile requires R8"); + + // Even with cn1.hardened=true, harden.level=off or rename opted out needs no R8. + BuildRequest offLevel = new BuildRequest(); + offLevel.putArgument("harden.level", "off"); + offLevel.putArgument("cn1.hardened", "true"); + assertFalse(AndroidGradleBuilder.androidRenameHardeningActive(offLevel, true, true)); + assertFalse(AndroidGradleBuilder.androidRenameHardeningActive(hardened, true, false), + "rename opted out needs no R8"); + assertFalse(AndroidGradleBuilder.androidRenameHardeningActive(hardened, false, true), + "harden.and.enabled=false needs no R8"); + } + @Test void typedPushAutoDetectsBothAndroidProviderConfigurations() { assertTrue(AndroidGradleBuilder.usesFcmPush(3, "auto", true)); From 0d51b7cbcdc4ff77a914f00d8177fd07544e313d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:42:44 +0700 Subject: [PATCH 070/110] Require indentation before treating an 'at ...' line as a stack frame isFrameLine trimmed the line before matching, so a message that wraps onto a line matching the frame grammar exactly -- a dotted identity and a (File.java:line) location, e.g. 'at account.failed(File.java:123456)' -- was indistinguishable from a real frame and its numeric tail was preserved verbatim, bypassing digit masking and any custom scrubMessage() override in the uploaded raw stack. The strict identity check cannot catch it because it IS a syntactically valid frame; the only discriminator is that printStackTrace indents real frames (a leading tab, V8 four spaces) while a wrapped message sits at column 0. The 'at ' branch now requires that leading indentation. The '@' (Firefox/Safari) branch is unchanged -- that engine emits unindented frames and relies on the URL/file source-shape check instead. New test unindentedFrameShapedMessageContinuationIsScrubbed. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 21 +++++++++++++------ .../crash/PiiScrubberRawStackTest.java | 14 +++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index b43dd723622..1630007f054 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -154,19 +154,28 @@ private String scrubFrameLine(String line) { /// A frame requires the full grammar, not just the leading token and some digits: a /// message can wrap onto a line that begins with `at ` (`printStackTrace` puts /// `at account 123456 failed:789` on its own line) and its id must still be scrubbed. - /// The `at ` form must be a single whitespace-free identity (`.`, a JS - /// function ref, or a URL) followed by a real location -- a parenthesized - /// `(File.java:42)`/`(url:line:col)`/`(Native Method)`/`(Unknown Source)`, or a bare - /// trailing `:` (ParparVM). The `@` form must carry an `@` and a terminal - /// `::`. + /// A real `at ...` frame is additionally always INDENTED -- `printStackTrace` emits a + /// leading tab, V8 four spaces -- while a wrapped message sits at column 0; requiring + /// the indentation rejects a continuation that otherwise matches the frame grammar + /// exactly (`at account.failed(File.java:123456)`), whose numeric tail must stay + /// scrubbable. The `at ` form must then be a single whitespace-free identity + /// (`.`, a JS function ref, or a URL) followed by a real location -- a + /// parenthesized `(File.java:42)`/`(url:line:col)`/`(Native Method)`/`(Unknown Source)`, + /// or a bare trailing `:` (ParparVM). The `@` form (Firefox/Safari, unindented by + /// that engine) must carry an `@`, a URL/file source, and a terminal `::`. private static boolean isFrameLine(String line) { String t = line.trim(); if (t.startsWith("at ")) { - return atFrame(t.substring(3).trim()); + return startsWithWhitespace(line) && atFrame(t.substring(3).trim()); } return atSignFrame(t); } + /// True when a line begins with the tab or space indentation that a real `at ...` frame carries. + private static boolean startsWithWhitespace(String line) { + return line.length() > 0 && (line.charAt(0) == ' ' || line.charAt(0) == '\t'); + } + /// The body of a Firefox/Safari `fn@source:line:column` frame: a whitespace-free function /// identity (empty for an anonymous frame), an `@`, and a URL/file source before the trailing /// `::`. The source must actually look like a URL or file -- contain a `/` diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index b8ab9590d6f..956d6a0bf6d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -214,6 +214,20 @@ void messageDigitsAndEmailsStillScrubbed() { assertTrue(scrubbed.indexOf("Bar.java:42") >= 0, scrubbed); } + @Test + void unindentedFrameShapedMessageContinuationIsScrubbed() { + // A message that wraps onto a line matching the frame grammar EXACTLY -- a dotted identity and a + // (File.java:line) location -- is still a message, not a frame: printStackTrace emits it at column + // 0 while real frames are indented. Its six-digit tail must be scrubbed, not preserved as a line + // number. The genuine indented frame below keeps its coordinate. + String stack = "java.lang.RuntimeException: bad\n" + + "at account.failed(File.java:123456)\n" + + " at com.foo.Bar.baz(Bar.java:42)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("Bar.java:42") >= 0, scrubbed); + } + @Test void atSignMessageWithoutUrlSourceIsScrubbed() { // A wrapped message that merely contains an '@' and ends in two numeric groups From 0db5a6df3c7e690e51dbf67c55ddaca2b33f490b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:59:24 +0700 Subject: [PATCH 071/110] Record real source filenames in the engine's own mapping On the engine-renamed targets (iOS/JS/win/linux) the engine strips SourceFile and its mapping is a plain ProGuard map with no filename metadata, so a retrace synthesized .java -- wrong for a Kotlin class (Screen.kt) or a package-private class declared in a differently named file (Main.java), which then retrace to a non-existent file and break source links. (The round-59 fix only covered R8's INLINE metadata; the engine's own map never recorded the filename.) HardeningEngine now captures each input class's SourceFile (from the input bytes, before the strip; read WITHOUT SKIP_DEBUG, which would drop the attribute) and, for classes whose filename differs from the synthesized .java default, MappingWriter.injectSourceFiles writes an INDENTED R8-style # {"id":"sourceFile","fileName":"..."} comment under the class line -- the exact form MappingFile already parses and preferredSourceFile already prefers. Done before finalize so the mappingId covers it. Tests: MappingWriterTest (injection format + no-op guards) and engineMappingRecordsNonDefaultSourceFiles (end-to-end: Screen.kt recorded, default Widget.java not). Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/HardeningEngine.java | 44 +++++++++ .../codename1/hardening/MappingWriter.java | 49 ++++++++++ .../hardening/HardeningEngineTest.java | 97 +++++++++++++++++++ .../hardening/MappingWriterTest.java | 85 ++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 maven/cn1-hardening/src/test/java/com/codename1/hardening/MappingWriterTest.java diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 99b0705159e..cc6ed8489a8 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -355,6 +355,10 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi // engine mapping is empty -- hashing it would stamp a meaningless constant id. Leave it empty. String mappingId = ""; if (mappingFile != null && cfg.isRenameEnabled()) { + // Record the real source filename for classes whose SourceFile the engine just stripped and + // whose name cannot reconstruct it (Kotlin Screen.kt, a package-private class in Main.java), + // captured from the INPUT classes before the strip. Done before finalize so the id covers it. + MappingWriter.injectSourceFiles(mappingFile, collectSourceFiles(inClasses)); mappingId = MappingWriter.finalizeMapping(mappingFile, ENGINE_VERSION, PROGUARD_VERSION, cfg.getPlatform(), req.getBuildKey()); } @@ -554,6 +558,46 @@ static boolean compilesCarriedSource(String platform) { return "and".equals(platform) || "android".equals(platform); } + /** + * Maps the original (dotted) class name to its {@code SourceFile} attribute, for the classes worth + * recording in the mapping: those whose filename differs from the synthesized {@code .java} + * default, i.e. Kotlin sources and package-private classes declared in a differently named file. Read + * from the INPUT classes, before the engine strips the attribute. + */ + private static java.util.Map collectSourceFiles(java.util.Map classes) { + java.util.Map out = new java.util.HashMap(); + for (java.util.Map.Entry e : classes.entrySet()) { + final String[] sf = new String[1]; + new org.objectweb.asm.ClassReader(e.getValue()).accept( + new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) { + @Override + public void visitSource(String source, String debug) { + sf[0] = source; + } + // NOT SKIP_DEBUG -- that flag skips the SourceFile attribute, which is exactly what + // visitSource reports and what we are here to capture. + }, org.objectweb.asm.ClassReader.SKIP_CODE | org.objectweb.asm.ClassReader.SKIP_FRAMES); + if (sf[0] != null && sf[0].length() > 0 && !sf[0].equals(defaultSourceFile(e.getKey()))) { + out.put(e.getKey().replace('/', '.'), sf[0]); + } + } + return out; + } + + /** The {@code .java} a retrace synthesizes from an internal class name (its default). */ + private static String defaultSourceFile(String internalName) { + String simple = internalName; + int slash = simple.lastIndexOf('/'); + if (slash >= 0) { + simple = simple.substring(slash + 1); + } + int dollar = simple.indexOf('$'); + if (dollar > 0) { + simple = simple.substring(0, dollar); + } + return simple + ".java"; + } + /** Adds every Java/Kotlin identifier token in {@code source} to {@code out} (a safe over-set). */ private static void collectSourceIdentifiers(String source, java.util.Set out) { int n = source.length(); diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java index ec1d3a92023..294033ce690 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java @@ -77,6 +77,55 @@ public static String finalizeMapping(File mappingFile, String engineVersion, Str } } + /** + * Injects R8-style {@code sourceFile} metadata comments into the mapping so a retrace can report the + * real source filename. The engine strips the {@code SourceFile} attribute and ProGuard's mapping + * records no filename, so without this a Kotlin class ({@code Screen.kt}) or a package-private class + * declared in a differently named file retraces to a synthesized {@code .java} that does not + * exist. Only classes whose recorded filename differs from that synthesized default are written (the + * ordinary {@code Foo}/{@code Foo.java} case needs no comment). The comment is INDENTED so the retrace + * parser attaches it to the preceding class line, matching R8's own emission. + * + * @param sourceFileByFqcn original dotted class name to its {@code SourceFile}, for the classes worth + * recording; captured before the attribute was stripped. + */ + static void injectSourceFiles(File mappingFile, java.util.Map sourceFileByFqcn) + throws HardeningException { + if (sourceFileByFqcn == null || sourceFileByFqcn.isEmpty() || mappingFile == null + || !mappingFile.isFile()) { + return; + } + try { + java.util.List lines = Files.readAllLines(mappingFile.toPath(), + Charset.forName("UTF-8")); + StringBuilder out = new StringBuilder(); + for (String line : lines) { + out.append(line).append('\n'); + // A class line is unindented, contains " -> ", and ends with ':'. Its members follow + // indented, so inject the metadata comment right after it (also indented). + if (!line.isEmpty() && !Character.isWhitespace(line.charAt(0)) && line.endsWith(":")) { + int arrow = line.indexOf(" -> "); + if (arrow > 0) { + String original = line.substring(0, arrow).trim(); + String sf = sourceFileByFqcn.get(original); + if (sf != null) { + out.append(" # {\"id\":\"sourceFile\",\"fileName\":\"") + .append(jsonEscape(sf)).append("\"}\n"); + } + } + } + } + Files.write(mappingFile.toPath(), out.toString().getBytes(Charset.forName("UTF-8"))); + } catch (IOException e) { + throw new HardeningException("Could not inject source-file metadata into the mapping", e); + } + } + + /** Escapes the two characters that would break a JSON string value; filenames rarely need it. */ + private static String jsonEscape(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + static String sha256Hex(byte[] data) throws HardeningException { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index a2f1f18b0b8..89bc29fb070 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -100,6 +100,103 @@ private static byte[] nativeInterface(String internalName) { return cw.toByteArray(); } + /** A class with a SourceFile and a static run() that instantiates each referenced type (keeping it reachable). */ + private static byte[] mainReferencing(String internal, String sourceFile, String... refs) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter( + org.objectweb.asm.ClassWriter.COMPUTE_FRAMES); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internal, null, "java/lang/Object", null); + cw.visitSource(sourceFile, null); + org.objectweb.asm.MethodVisitor init = cw.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC, + "", "()V", null, null); + init.visitCode(); + init.visitVarInsn(org.objectweb.asm.Opcodes.ALOAD, 0); + init.visitMethodInsn(org.objectweb.asm.Opcodes.INVOKESPECIAL, "java/lang/Object", + "", "()V", false); + init.visitInsn(org.objectweb.asm.Opcodes.RETURN); + init.visitMaxs(1, 1); + init.visitEnd(); + org.objectweb.asm.MethodVisitor m = cw.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "run", "()V", null, null); + m.visitCode(); + for (String ref : refs) { + m.visitTypeInsn(org.objectweb.asm.Opcodes.NEW, ref); + m.visitInsn(org.objectweb.asm.Opcodes.DUP); + m.visitMethodInsn(org.objectweb.asm.Opcodes.INVOKESPECIAL, ref, "", "()V", false); + m.visitInsn(org.objectweb.asm.Opcodes.POP); + } + m.visitInsn(org.objectweb.asm.Opcodes.RETURN); + m.visitMaxs(2, 0); + m.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + + /** A class carrying a {@code SourceFile} attribute plus a constructor and a method to rename. */ + private static byte[] classWithSource(String internal, String sourceFile) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter( + org.objectweb.asm.ClassWriter.COMPUTE_FRAMES); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internal, null, "java/lang/Object", null); + cw.visitSource(sourceFile, null); + org.objectweb.asm.MethodVisitor init = cw.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC, + "", "()V", null, null); + init.visitCode(); + init.visitVarInsn(org.objectweb.asm.Opcodes.ALOAD, 0); + init.visitMethodInsn(org.objectweb.asm.Opcodes.INVOKESPECIAL, "java/lang/Object", + "", "()V", false); + init.visitInsn(org.objectweb.asm.Opcodes.RETURN); + init.visitMaxs(1, 1); + init.visitEnd(); + org.objectweb.asm.MethodVisitor m = cw.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC, + "doThing", "()V", null, null); + m.visitCode(); + m.visitInsn(org.objectweb.asm.Opcodes.RETURN); + m.visitMaxs(0, 1); + m.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + + @Test + public void engineMappingRecordsNonDefaultSourceFiles() throws Exception { + org.junit.Assume.assumeTrue("ProGuard renamer needs JDK <=20", HardeningEngine.proguardCanRunHere()); + // Screen is a Kotlin class (Screen.kt) whose name can't reconstruct the file; Widget's Widget.java + // IS the synthesized default. The engine strips SourceFile, so it must record Screen.kt in the + // mapping (else a retrace points Screen at a non-existent Screen.java) but need not record Widget. + File jar = tmp.newFile("srcfile.jar"); + FileOutputStream fo = new FileOutputStream(jar); + ZipOutputStream zos = new ZipOutputStream(fo); + zos.putNextEntry(new ZipEntry("app/Main.class")); + // Main (kept) references Screen and Widget so ProGuard reaches and renames them. + zos.write(mainReferencing("app/Main", "Main.java", "app/Screen", "app/Widget")); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("app/Screen.class")); + zos.write(classWithSource("app/Screen", "Screen.kt")); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("app/Widget.class")); + zos.write(classWithSource("app/Widget", "Widget.java")); + zos.closeEntry(); + zos.finish(); + fo.close(); + + Map hints = new HashMap(); + hints.put("harden.level", "standard"); // rename on (engine-renamed target) + File mapping = tmp.newFile("srcfile-map.txt"); + HardeningRequest req = new HardeningRequest() + .inputJar(jar).outputJar(tmp.newFile("srcfile-out.jar")).mappingFile(mapping) + .workDir(tmp.newFolder("srcfile-work")) + .config(HardeningConfig.from(hints, "ios", true)) + .mainClass("app.Main"); + HardeningResult r = HardeningEngine.harden(req); + assertTrue(r.isHardened()); + String map = new String(java.nio.file.Files.readAllBytes(mapping.toPath()), "UTF-8"); + assertTrue("the Kotlin source file must be recorded: " + map, + map.contains("\"fileName\":\"Screen.kt\"")); + assertFalse("the default Widget.java need not be recorded", + map.contains("\"fileName\":\"Widget.java\"")); + } + /** A class whose {@code run()} method loads each given string literal (and discards it). */ private static byte[] classWithLiterals(String internal, String... literals) { org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/MappingWriterTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/MappingWriterTest.java new file mode 100644 index 00000000000..5d34ee67522 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/MappingWriterTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.hardening; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** The mapping must carry the real source filename for classes whose name can't reconstruct it. */ +public class MappingWriterTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private File mappingWith(String body) throws Exception { + File f = tmp.newFile("mapping.txt"); + Files.write(f.toPath(), body.getBytes(Charset.forName("UTF-8"))); + return f; + } + + private String read(File f) throws Exception { + return new String(Files.readAllBytes(f.toPath()), Charset.forName("UTF-8")); + } + + @Test + public void injectsIndentedSourceFileMetadataForNonDefaultFilesOnly() throws Exception { + File map = mappingWith("com.foo.Screen -> a:\n" + + " void onClick() -> b\n" + + "com.foo.Widget -> c:\n"); + Map sf = new HashMap(); + sf.put("com.foo.Screen", "Screen.kt"); // Kotlin: name can't reconstruct the file + // Widget deliberately absent: its file is the synthesized default, so nothing to record. + + MappingWriter.injectSourceFiles(map, sf); + String out = read(map); + // The comment is INDENTED and placed immediately after the Screen class line, so the retrace + // parser attaches it to Screen (a column-0 comment would be skipped). + assertTrue(out, out.contains("com.foo.Screen -> a:\n" + + " # {\"id\":\"sourceFile\",\"fileName\":\"Screen.kt\"}\n")); + // The member line survives and stays under Screen. + assertTrue(out, out.contains("void onClick() -> b")); + // Widget got no comment (not in the map). + assertFalse(out, out.contains("Widget.kt")); + assertFalse(out, out.contains("\"fileName\":\"Widget")); + } + + @Test + public void noMapOrMissingFileIsANoOp() throws Exception { + File map = mappingWith("com.foo.A -> a:\n"); + MappingWriter.injectSourceFiles(map, null); + MappingWriter.injectSourceFiles(map, new HashMap()); + assertTrue(read(map).contains("com.foo.A -> a:")); + // A non-existent file must not throw. + MappingWriter.injectSourceFiles(new File(tmp.getRoot(), "nope.txt"), + java.util.Collections.singletonMap("com.foo.A", "A.kt")); + } +} From 862d10b987912073ebc3baa0f18fd78d0b56b697 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:10:54 +0700 Subject: [PATCH 072/110] Require a URL path separator in an @-frame source, not just a dot The round-58 source-shape check accepted a Firefox/Safari @-frame when its source contained a '/' OR a '.', but a normal dotted hostname satisfies the '.' branch: an email-shaped message continuation like 'status@host.com:1:123456' was then treated as a frame and its six-digit tail preserved verbatim as a fake column, bypassing digit masking and any scrubMessage() override in the uploaded raw stack. A real script source is always a URL with a path separator (scheme://host/path, file:///a.js, webpack:///./x.js), while an email domain has none, so the source must now contain a '/'. The Firefox frames the suite already exercises use http://host/app.js and keep their coordinate; new test atSignMessageWithDottedHostButNoPathIsScrubbed covers the email-shaped case. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 15 ++++++++------- .../codename1/crash/PiiScrubberRawStackTest.java | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index 1630007f054..c53bca9bbea 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -177,12 +177,13 @@ private static boolean startsWithWhitespace(String line) { } /// The body of a Firefox/Safari `fn@source:line:column` frame: a whitespace-free function - /// identity (empty for an anonymous frame), an `@`, and a URL/file source before the trailing - /// `::`. The source must actually look like a URL or file -- contain a `/` - /// (`scheme://host/path`) or a `.` (`file.ext`). Without that check a wrapped message such as - /// `send status@host:1:123456` matches merely by containing an `@` and ending in two numeric - /// groups, and its six-digit tail would be preserved verbatim as a fake column instead of being - /// scrubbed. A bare source word like `host` fails the shape test, so the message stays scrubbed. + /// identity (empty for an anonymous frame), an `@`, and a URL source before the trailing + /// `::`. The source must carry a URL path separator `/` (`scheme://host/path`, + /// `file:///a.js`, `webpack:///./x.js`) -- a real script source is always a URL. A plain `.` + /// is NOT enough: an email-shaped continuation like `status@host.com:1:123456` has a dotted + /// domain but no path, so requiring `/` keeps its six-digit tail scrubbable (it would otherwise + /// be preserved verbatim as a fake column, bypassing digit masking and any scrubMessage override). + /// A bare word like `host` or a dotted host `host.com` fails the check, so the message stays scrubbed. private static boolean atSignFrame(String t) { int at = t.indexOf('@'); if (at < 0 || !endsWithLineColumn(t)) { @@ -199,7 +200,7 @@ private static boolean atSignFrame(String t) { return false; } String source = t.substring(at + 1, loc); - return source.indexOf('/') >= 0 || source.indexOf('.') >= 0; + return source.indexOf('/') >= 0; } /// The body of an `at ...` line: a whitespace-free identity plus a real location. A message diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 956d6a0bf6d..05cb317beba 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -241,4 +241,18 @@ void atSignMessageWithoutUrlSourceIsScrubbed() { assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); assertTrue(scrubbed.indexOf("app.js:10:5") >= 0, scrubbed); } + + @Test + void atSignMessageWithDottedHostButNoPathIsScrubbed() { + // An email-shaped continuation (status@host.com:1:123456) has a DOTTED domain but no URL path, + // so it is not a Firefox frame -- a real script source is always a URL with a '/'. Its six-digit + // tail must be scrubbed, not preserved as a fake column. The genuine URL-sourced frame below, + // which carries a path separator, keeps its coordinate. + String stack = "java.lang.RuntimeException: verifying\n" + + "status@host.com:1:123456\n" + + "renderApp@http://host/app.js:10:5\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("app.js:10:5") >= 0, scrubbed); + } } From 20202155d47c2f4c180d4edf6b83ac6ec7899eed Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:34:54 +0700 Subject: [PATCH 073/110] Exclude a pre-Java-8 interface's un-encryptable constant jar-wide A pre-Java-8 interface cannot host a /decoder, so its static-final String ConstantValue stays plaintext -- but the branch only counted it (legacyInterfaceConstantCount) without adding the value to newlyExcluded. So pass 2 still encrypted+interned an equal LDC in another class, and a GETSTATIC read of the still-plaintext interface field then compared != to that interned copy on ParparVM, breaking Java's literal identity guarantee (the same gap fixed for the method-full, pool-budget and clinit-full paths). The value is now recorded in newlyExcluded so the second pass leaves it plaintext throughout the jar. preJava8InterfaceConstantIsCountedAsExcluded now asserts the jar-wide exclusion. Co-Authored-By: Claude Opus 4.8 --- .../java/com/codename1/hardening/StringEncryptTransform.java | 4 ++++ .../com/codename1/hardening/StringEncryptTransformTest.java | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 8ad9f3015ad..92b5f358470 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -418,6 +418,10 @@ public byte[] transform(byte[] classBytes) { if ((f.access & Opcodes.ACC_STATIC) != 0 && (f.access & Opcodes.ACC_FINAL) != 0 && f.value instanceof String && shouldEncryptLiteral((String) f.value)) { legacyInterfaceConstantCount++; + // Exclude it jar-wide: this ConstantValue stays plaintext here, so an equal LDC in + // another class must NOT be encrypted+interned or a GETSTATIC read of this field + // would compare != to that interned copy on ParparVM (a broken literal ==). + newlyExcluded.add((String) f.value); } } } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index de1651ae56a..753ad5c86c5 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -527,6 +527,10 @@ public void preJava8InterfaceConstantIsCountedAsExcluded() throws Exception { assertEquals("its constant must be counted as an exclusion", 1, t.getLegacyInterfaceConstantCount()); assertTrue("the constant is left as-is (reported, not silently dropped)", StringEncryptTransform.containsStringLiteral(out, secret)); + // It must ALSO be excluded jar-wide, or an equal LDC elsewhere would be encrypted+interned and a + // GETSTATIC read of this still-plaintext interface field would compare != to it on ParparVM. + assertTrue("the un-encryptable interface constant is recorded for jar-wide exclusion", + t.getNewlyExcluded().contains(secret)); } @Test From fa59896835fac116b19730e6aa411591a37f7a08 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:49:45 +0700 Subject: [PATCH 074/110] Accept multi-word V8 frame identities in the at-paren form A V8 stack labels async/constructor/accessor frames with a space ("async load", "new Promise", "Object.x [as y]"), so the whitespace-free identity requirement rejected them: scrubRawStack then sent the whole indented frame through scrubMessage and masked the minified bundle's six-digit column to [num], breaking source-map symbolication. Now that isFrameLine requires indentation (real frames vs unindented message continuations) and the parenthesized (url:line:col) location is the discriminator -- and scrubFrameLine scrubs everything before the coordinate anyway -- the at-paren form only requires a non-empty identity, so a genuine V8 async frame keeps its coordinate. New test v8AsyncFrameWithMultiWordLabelKeepsItsCoordinate. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 13 ++++++++----- .../codename1/crash/PiiScrubberRawStackTest.java | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index c53bca9bbea..9690ec9f5bb 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -216,11 +216,14 @@ private static boolean atFrame(String rest) { return false; } String inside = rest.substring(open + 1, rest.length() - 1); - // The parenthesized location is the discriminator here (JVM `(File.java:42)`, Chrome - // `(url:line:col)`, or the `(Native Method)`/`(Unknown Source)` literals), so a plain - // whitespace-free identity before it is enough -- a Chrome frame's identity can be a bare - // function name with no dot. - return isParenLocation(inside) && isFrameIdentity(rest.substring(0, open).trim()); + // The parenthesized location is the discriminator here (JVM `(File.java:42)`, V8 + // `(url:line:col)`, or the `(Native Method)`/`(Unknown Source)` literals). The identity may + // contain spaces -- a V8 frame labels async/constructor/accessor frames `async load`, + // `new Promise`, `Object.x [as y]` -- so a non-empty identity is enough here. The line is + // already known to be INDENTED (isFrameLine rejects an unindented message continuation), and + // scrubFrameLine scrubs everything before the coordinate, so a whitespace-free requirement + // would only drop legitimate V8 frames and break their source-map symbolication. + return isParenLocation(inside) && rest.substring(0, open).trim().length() > 0; } int start = trailingLocationStart(rest); if (start <= 0) { diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 05cb317beba..3ad531576ef 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -242,6 +242,20 @@ void atSignMessageWithoutUrlSourceIsScrubbed() { assertTrue(scrubbed.indexOf("app.js:10:5") >= 0, scrubbed); } + @Test + void v8AsyncFrameWithMultiWordLabelKeepsItsCoordinate() { + // V8 labels async/constructor/accessor frames with spaces ("async load", "new Promise", + // "Object.x [as y]"). Such an INDENTED frame with a real (url:line:col) is a genuine frame -- its + // minified column must survive for source-map symbolication, not be masked to [num]. An unindented + // look-alike message is still rejected by the indentation gate. + String stack = "Error: boom\n" + + " at async load (https://host/app.js:1:123456)\n" + + " at new Promise (https://host/app.js:2:98765)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("app.js:1:123456") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("app.js:2:98765") >= 0, scrubbed); + } + @Test void atSignMessageWithDottedHostButNoPathIsScrubbed() { // An email-shaped continuation (status@host.com:1:123456) has a DOTTED domain but no URL path, From 7cd841194c57d95a76de216be941fa737611e7b2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:55:16 +0700 Subject: [PATCH 075/110] Reject conflicting per-slice hardening opt-outs in a combined iOS+Mac build A combined build (macNative.enabled=true) emits both an iOS and a native-Mac slice from ONE shared hardened jar, but hardeningPlatform() reports "mac", so the engine consulted only harden.mac.enabled: setting it false left the iOS artifact unhardened even with harden.ios.enabled=true, and setting only harden.ios.enabled=false was ignored and still hardened iOS. A shared jar cannot be hardened one way for one slice and another for the other, so hardenSourceJar now calls a builder hook hardeningOptOutConflict() and IPhoneBuilder rejects the build (with a clear message) when the two per-slice opt-outs disagree, rather than silently applying one slice's choice to both. Agreeing opt-outs and plain single-slice iOS builds are unaffected. Covered by IPhoneBuilderHardeningOptOutTest. Co-Authored-By: Claude Opus 4.8 --- .../java/com/codename1/builders/Executor.java | 17 ++++++ .../com/codename1/builders/IPhoneBuilder.java | 24 +++++++++ .../IPhoneBuilderHardeningOptOutTest.java | 54 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 555f84a1c2a..c6d67b723fb 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2395,6 +2395,16 @@ protected boolean isParparVMCPlatform(String platform) { || "tv".equals(platform) || "win".equals(platform) || "linux".equals(platform); } + /** + * An explanation to fail the build with when this builder emits multiple output slices from the one + * shared hardened jar and their per-slice {@code harden..enabled} opt-outs disagree (a shared + * jar cannot be hardened one way for one slice and another for the other), or {@code null} when there + * is no such conflict. The default builder emits a single slice and never conflicts. + */ + protected String hardeningOptOutConflict(BuildRequest request) { + return null; + } + protected java.util.List hardeningLibraryJars(BuildRequest request) { java.util.List jars = new java.util.ArrayList(); // Always include the Codename One framework jar: every builder receives it, and it carries @@ -2531,6 +2541,13 @@ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildEx log("cn1-hardening: forced off for this local build; building unhardened"); return sourceZip; } + // A builder that emits more than one output slice from this one shared jar (the combined + // iOS + native-Mac build) cannot harden one slice but not the other; reject a conflicting + // per-slice opt-out here rather than silently applying one slice's choice to both. + String optOutConflict = hardeningOptOutConflict(request); + if (optOutConflict != null) { + throw new BuildException(optOutConflict); + } try { File engine = getResourceAsFile("/cn1-hardening.jar", ".jar"); File workDir = new File(sourceZip.getParentFile(), "cn1-harden-work"); 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 c129f9f0f8d..273d3e9f33f 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 @@ -488,6 +488,30 @@ protected String hardeningPlatform(BuildRequest request) { return "ios"; } + @Override + protected String hardeningOptOutConflict(BuildRequest request) { + // A combined build (macNative.enabled=true) emits both an iOS and a native-Mac slice from the one + // shared hardened jar. hardeningPlatform() reports "mac", so the engine consults only + // harden.mac.enabled; harden.ios.enabled would be silently ignored. When the two per-slice + // opt-outs disagree the shared jar cannot satisfy both, so reject rather than harden one slice + // against its opt-out. Resolved with the engine's tri-state boolTri rules. + return combinedIosMacOptOutConflict( + "true".equals(request.getArg("macNative.enabled", "false")), + hardenBoolArg(request, "harden.ios.enabled", true), + hardenBoolArg(request, "harden.mac.enabled", true)); + } + + /** The rejection message when a combined iOS+Mac build's per-slice opt-outs disagree, else null. */ + static String combinedIosMacOptOutConflict(boolean macNative, boolean iosEnabled, boolean macEnabled) { + if (!macNative || iosEnabled == macEnabled) { + return null; + } + return "A combined iOS + native-Mac build hardens one shared application jar, so it cannot harden " + + "one slice but not the other: harden.ios.enabled=" + iosEnabled + " conflicts with " + + "harden.mac.enabled=" + macEnabled + ". Set both to the same value, or use " + + "harden.level=off to disable hardening for the whole build."; + } + /** * The watch lifecycle entry class is resolved by its ORIGINAL fully-qualified name at run time -- * {@code CN1WatchBootstrap} embeds it in {@code cn1_watch_runtime_start("")} -- and that diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java new file mode 100644 index 00000000000..b0c7ed6e609 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java @@ -0,0 +1,54 @@ +/* + * 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.builders; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +/** + * A combined iOS + native-Mac build hardens ONE shared jar, so it cannot harden one slice but not the + * other; conflicting per-slice opt-outs must be rejected rather than silently applying one slice's choice. + */ +class IPhoneBuilderHardeningOptOutTest { + + @Test + void combinedBuildRejectsConflictingPerSliceOptOuts() { + // macNative=true and the two opt-outs disagree -> reject (a shared jar can't satisfy both). + assertNotNull(IPhoneBuilder.combinedIosMacOptOutConflict(true, false, true), + "ios opted out but mac on -> conflict"); + assertNotNull(IPhoneBuilder.combinedIosMacOptOutConflict(true, true, false), + "mac opted out but ios on -> conflict"); + } + + @Test + void agreeingOrNonCombinedBuildsAreAccepted() { + // Agreeing opt-outs (both on / both off) are fine. + assertNull(IPhoneBuilder.combinedIosMacOptOutConflict(true, true, true)); + assertNull(IPhoneBuilder.combinedIosMacOptOutConflict(true, false, false)); + // A plain iOS build (no Mac slice) never conflicts, whatever the flags say. + assertNull(IPhoneBuilder.combinedIosMacOptOutConflict(false, true, false)); + assertNull(IPhoneBuilder.combinedIosMacOptOutConflict(false, false, true)); + } +} From 07e05bbdcf7a53327058a1140008f79bb2a610d7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:01:29 +0700 Subject: [PATCH 076/110] Combine per-slice hardening opt-outs instead of consulting one slice, matching the daemon Replaces the previous reject-on-conflict take with the daemon builder's established design so the local and cloud decisions agree. A combined build ships several Apple slices (iOS app plus native-Mac/watch/tv) from ONE shared hardened jar, but hardeningPlatform() reports a single tag ('mac' for a combined build), so the engine consulted only harden.mac.enabled -- silently ignoring harden.ios.enabled (setting it false left iOS unhardened; setting only harden.ios.enabled=false still hardened iOS). Executor.writeHardeningConfig now coalesces every shipped slice via the new effectiveHardeningPlatforms() (IPhoneBuilder lists ios + any mac/watch/tv slice from the raw hints) and anySliceHardeningEnabled(): a shared jar is hardened as a whole, so hardening runs unless EVERY slice opted out. Mirrors the daemon's effectiveHardeningPlatforms / harden-unless-all-opted-out exactly. Covered by IPhoneBuilderHardeningOptOutTest. Co-Authored-By: Claude Opus 4.8 --- .../java/com/codename1/builders/Executor.java | 54 +++++++++++++---- .../com/codename1/builders/IPhoneBuilder.java | 48 +++++++++------ .../IPhoneBuilderHardeningOptOutTest.java | 59 +++++++++++++------ 3 files changed, 113 insertions(+), 48 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index c6d67b723fb..c33dcfb9c63 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2396,13 +2396,41 @@ protected boolean isParparVMCPlatform(String platform) { } /** - * An explanation to fail the build with when this builder emits multiple output slices from the one - * shared hardened jar and their per-slice {@code harden..enabled} opt-outs disagree (a shared - * jar cannot be hardened one way for one slice and another for the other), or {@code null} when there - * is no such conflict. The default builder emits a single slice and never conflicts. + * The platform tags this one build ships from the SAME hardened jar. The default builder emits a + * single slice ({@link #hardeningPlatform(BuildRequest)}); the Apple builder widens it to the iOS app + * plus any native-Mac, watch or tv slice. A shared jar cannot be hardened per-slice, so its + * {@code harden..enabled} opt-outs are combined: hardening runs unless EVERY shipped slice is + * opted out (see {@link #writeHardeningConfig}). Kept consistent with the daemon builder. */ - protected String hardeningOptOutConflict(BuildRequest request) { - return null; + protected java.util.List effectiveHardeningPlatforms(BuildRequest request) { + return java.util.Collections.singletonList(hardeningPlatform(request)); + } + + /** + * True when a {@code harden.*} boolean reads as disabled, matching the engine's tri-state parsing: + * {@code false}, {@code 0} and {@code off} all mean off, so a per-slice opt-out is recognized + * consistently rather than only as the literal {@code false}. + */ + protected static boolean hardenDisabled(String value) { + if (value == null) { + return false; + } + String t = value.trim().toLowerCase(); + return "false".equals(t) || "0".equals(t) || "off".equals(t); + } + + /** + * True when at least one of the shipped {@code slices} still wants hardening, i.e. NOT every slice + * opted out via {@code harden..enabled}. A shared jar is hardened as a whole, so the combined + * decision is this OR: hardening runs unless every slice is opted out. + */ + static boolean anySliceHardeningEnabled(java.util.List slices, BuildRequest request) { + for (String slice : slices) { + if (!hardenDisabled(request.getArg("harden." + slice + ".enabled", "true"))) { + return true; + } + } + return false; } protected java.util.List hardeningLibraryJars(BuildRequest request) { @@ -2541,13 +2569,6 @@ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildEx log("cn1-hardening: forced off for this local build; building unhardened"); return sourceZip; } - // A builder that emits more than one output slice from this one shared jar (the combined - // iOS + native-Mac build) cannot harden one slice but not the other; reject a conflicting - // per-slice opt-out here rather than silently applying one slice's choice to both. - String optOutConflict = hardeningOptOutConflict(request); - if (optOutConflict != null) { - throw new BuildException(optOutConflict); - } try { File engine = getResourceAsFile("/cn1-hardening.jar", ".jar"); File workDir = new File(sourceZip.getParentFile(), "cn1-harden-work"); @@ -2629,6 +2650,13 @@ private void writeHardeningConfig(File config, BuildRequest request) throws IOEx } } p.setProperty("cn1.platform", hardeningPlatform(request)); + // A build can ship several slices from this ONE shared hardened jar (the iOS app plus a + // native-Mac/watch/tv slice). The engine reads a single harden..enabled, so a + // combined build would otherwise honor only one slice's opt-out and silently ignore the others'. + // Coalesce them: hardening runs unless EVERY shipped slice is opted out. A shared jar cannot be + // hardened one way for one slice and another for the other, so this is the honest combination. + p.setProperty("harden." + hardeningPlatform(request) + ".enabled", + Boolean.toString(anySliceHardeningEnabled(effectiveHardeningPlatforms(request), request))); // The keep rule must name the FULLY QUALIFIED main class: getMainClass() is the simple name // (the stubs combine it with getPackageName()), so passing it bare would keep a default-package // class and let ProGuard rename the real application class out from under the generated stub. 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 273d3e9f33f..080a89903f8 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 @@ -488,28 +488,40 @@ protected String hardeningPlatform(BuildRequest request) { return "ios"; } + /** + * Every Apple slice this one build ships from the SAME hardened jar: the iOS app plus any native-Mac, + * watchOS or tvOS target. hardeningPlatform() reports one tag ("mac" for a combined build), so the + * engine reads a single harden..enabled; listing every slice here lets each target's + * harden..enabled participate in the combined opt-out (Executor.writeHardeningConfig hardens + * unless EVERY slice is opted out). The slices cannot be hardened independently -- there is one binary. + * Reads the RAW hints, because this runs in the shared Executor before the slice builders' parseHints. + */ @Override - protected String hardeningOptOutConflict(BuildRequest request) { - // A combined build (macNative.enabled=true) emits both an iOS and a native-Mac slice from the one - // shared hardened jar. hardeningPlatform() reports "mac", so the engine consults only - // harden.mac.enabled; harden.ios.enabled would be silently ignored. When the two per-slice - // opt-outs disagree the shared jar cannot satisfy both, so reject rather than harden one slice - // against its opt-out. Resolved with the engine's tri-state boolTri rules. - return combinedIosMacOptOutConflict( - "true".equals(request.getArg("macNative.enabled", "false")), - hardenBoolArg(request, "harden.ios.enabled", true), - hardenBoolArg(request, "harden.mac.enabled", true)); + protected java.util.List effectiveHardeningPlatforms(BuildRequest request) { + return appleHardeningSlices(request); } - /** The rejection message when a combined iOS+Mac build's per-slice opt-outs disagree, else null. */ - static String combinedIosMacOptOutConflict(boolean macNative, boolean iosEnabled, boolean macEnabled) { - if (!macNative || iosEnabled == macEnabled) { - return null; + /** The Apple slices a build ships from the shared hardened jar: ios plus any mac/watch/tv target. */ + static java.util.List appleHardeningSlices(BuildRequest request) { + java.util.List platforms = new java.util.ArrayList(); + platforms.add("ios"); + if ("true".equals(request.getArg("macNative.enabled", "false"))) { + platforms.add("mac"); } - return "A combined iOS + native-Mac build hardens one shared application jar, so it cannot harden " - + "one slice but not the other: harden.ios.enabled=" + iosEnabled + " conflicts with " - + "harden.mac.enabled=" + macEnabled + ". Set both to the same value, or use " - + "harden.level=off to disable hardening for the whole build."; + if (watchTargetEnabled(request)) { + platforms.add("watch"); + } + if (tvTargetEnabled(request)) { + platforms.add("tv"); + } + return platforms; + } + + /** True when this build ships a tvOS slice (tvNative.enabled or a tvMain entry point). */ + static boolean tvTargetEnabled(BuildRequest request) { + return "true".equals(request.getArg("tvNative.enabled", "false")) + || request.getArg("tvMain", + request.getArg("tvNative.mainClass", "")).trim().length() > 0; } /** diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java index b0c7ed6e609..4e874fa8706 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java @@ -22,33 +22,58 @@ */ package com.codename1.builders; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Arrays; import org.junit.jupiter.api.Test; /** - * A combined iOS + native-Mac build hardens ONE shared jar, so it cannot harden one slice but not the - * other; conflicting per-slice opt-outs must be rejected rather than silently applying one slice's choice. + * A combined build ships several Apple slices from one shared hardened jar, so every slice's + * harden.<platform>.enabled must participate in the opt-out: hardening runs unless EVERY slice is + * opted out. Matches the daemon builder so the local and cloud decisions agree. */ class IPhoneBuilderHardeningOptOutTest { @Test - void combinedBuildRejectsConflictingPerSliceOptOuts() { - // macNative=true and the two opt-outs disagree -> reject (a shared jar can't satisfy both). - assertNotNull(IPhoneBuilder.combinedIosMacOptOutConflict(true, false, true), - "ios opted out but mac on -> conflict"); - assertNotNull(IPhoneBuilder.combinedIosMacOptOutConflict(true, true, false), - "mac opted out but ios on -> conflict"); + void combinedBuildListsEveryAppleSlice() { + BuildRequest plain = new BuildRequest(); + assertEquals(Arrays.asList("ios"), IPhoneBuilder.appleHardeningSlices(plain), + "a plain iOS build ships only the iOS slice"); + + BuildRequest combined = new BuildRequest(); + combined.putArgument("macNative.enabled", "true"); + combined.putArgument("watchNative.enabled", "true"); + assertEquals(Arrays.asList("ios", "mac", "watch"), + IPhoneBuilder.appleHardeningSlices(combined), + "a combined build lists the iOS app plus its native-Mac and watch slices"); } @Test - void agreeingOrNonCombinedBuildsAreAccepted() { - // Agreeing opt-outs (both on / both off) are fine. - assertNull(IPhoneBuilder.combinedIosMacOptOutConflict(true, true, true)); - assertNull(IPhoneBuilder.combinedIosMacOptOutConflict(true, false, false)); - // A plain iOS build (no Mac slice) never conflicts, whatever the flags say. - assertNull(IPhoneBuilder.combinedIosMacOptOutConflict(false, true, false)); - assertNull(IPhoneBuilder.combinedIosMacOptOutConflict(false, false, true)); + void hardeningRunsUnlessEverySliceOptedOut() { + BuildRequest req = new BuildRequest(); + java.util.List iosMac = Arrays.asList("ios", "mac"); + + // Neither opted out -> harden. + assertTrue(Executor.anySliceHardeningEnabled(iosMac, req)); + + // Only the iOS slice opted out, Mac still on -> still harden (the shared jar is hardened for Mac). + req.putArgument("harden.ios.enabled", "false"); + assertTrue(Executor.anySliceHardeningEnabled(iosMac, req), + "harden.ios.enabled=false alone must NOT skip hardening the shared jar the Mac slice wants"); + + // Only the Mac slice opted out, iOS still on -> still harden (fixes the old 'consult only mac' bug). + req = new BuildRequest(); + req.putArgument("harden.mac.enabled", "off"); + assertTrue(Executor.anySliceHardeningEnabled(iosMac, req), + "harden.mac.enabled=off must no longer leave the iOS artifact unhardened"); + + // EVERY slice opted out -> skip. + req = new BuildRequest(); + req.putArgument("harden.ios.enabled", "false"); + req.putArgument("harden.mac.enabled", "0"); + assertFalse(Executor.anySliceHardeningEnabled(iosMac, req), + "hardening is skipped only when every shipped slice opted out"); } } From b3ae16c49070b67db2d68e1840e4066bace96dab Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:04:49 +0700 Subject: [PATCH 077/110] Restrict at-paren multi-word identities to genuine V8 label shapes The round-71 relaxation widened the at-paren identity from a whitespace-free token to any non-empty text, which re-admitted an INDENTED message continuation (a message containing "\n at ...") whose text resembles a frame -- e.g. " at account failed (File.java:123456)" -- so scrubFrameLine preserved its :123456 tail, bypassing digit masking. The identity must now match a genuine V8 label: a single token, optionally with a V8 modifier prefix (async/new/bound/get/set) and/or an accessor suffix ([as name]). "account failed" is neither, so it is scrubbed, while "async load"/"new Promise"/"Object.x [as y]" keep their coordinate. New test indentedMessageContinuationWithArbitraryLabelIsScrubbed; v8AsyncFrameWithMultiWordLabelKeepsItsCoordinate still passes. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 35 +++++++++++++++---- .../crash/PiiScrubberRawStackTest.java | 14 ++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index 9690ec9f5bb..0fcb79001c2 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -218,12 +218,11 @@ private static boolean atFrame(String rest) { String inside = rest.substring(open + 1, rest.length() - 1); // The parenthesized location is the discriminator here (JVM `(File.java:42)`, V8 // `(url:line:col)`, or the `(Native Method)`/`(Unknown Source)` literals). The identity may - // contain spaces -- a V8 frame labels async/constructor/accessor frames `async load`, - // `new Promise`, `Object.x [as y]` -- so a non-empty identity is enough here. The line is - // already known to be INDENTED (isFrameLine rejects an unindented message continuation), and - // scrubFrameLine scrubs everything before the coordinate, so a whitespace-free requirement - // would only drop legitimate V8 frames and break their source-map symbolication. - return isParenLocation(inside) && rest.substring(0, open).trim().length() > 0; + // contain spaces, but ONLY in the specific shapes V8 uses (`async load`, `new Promise`, + // `Object.x [as y]`) -- accepting an arbitrary multi-word identity would re-admit an indented + // message continuation such as `account failed (File.java:123456)` whose numeric tail must + // stay scrubbable. So the identity must be a genuine V8 frame label. + return isParenLocation(inside) && isV8FrameIdentity(rest.substring(0, open).trim()); } int start = trailingLocationStart(rest); if (start <= 0) { @@ -251,6 +250,30 @@ private static boolean isParenLocation(String inside) { return head.indexOf('.') >= 0 || head.indexOf('/') >= 0; } + /// A V8 stack-frame label. Usually a single whitespace-free token (`foo`, `Object.bar`), but V8 + /// also decorates it with a modifier prefix (`async foo`, `new Foo`, `bound foo`, `get x`, `set x`) + /// and/or an accessor suffix (`Object.x [as y]`). Those exact shapes are accepted; ANY OTHER + /// multi-word text (a wrapped message like `account failed`) is rejected, so its numeric tail is not + /// preserved as a fake coordinate. Strip the known suffix and prefix, then require a single token. + private static boolean isV8FrameIdentity(String id) { + String core = id.trim(); + if (core.length() == 0) { + return false; + } + int asAt = core.indexOf(" [as "); + if (asAt > 0 && core.endsWith("]")) { + core = core.substring(0, asAt).trim(); + } + String[] prefixes = {"async ", "new ", "bound ", "get ", "set "}; + for (int i = 0; i < prefixes.length; i++) { + if (core.startsWith(prefixes[i])) { + core = core.substring(prefixes[i].length()).trim(); + break; + } + } + return isFrameIdentity(core); + } + /// A frame's identity is a single token: non-empty and free of whitespace. A free-form message /// continuation (`account 123456 failed`) has spaces, so it is rejected. private static boolean isFrameIdentity(String id) { diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 3ad531576ef..f6d334504da 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -242,6 +242,20 @@ void atSignMessageWithoutUrlSourceIsScrubbed() { assertTrue(scrubbed.indexOf("app.js:10:5") >= 0, scrubbed); } + @Test + void indentedMessageContinuationWithArbitraryLabelIsScrubbed() { + // An INDENTED message continuation (the message itself contains "\n at ...") whose text happens + // to look like a frame -- an arbitrary multi-word label plus a (File.java:line) location -- must + // still be scrubbed: "account failed" is not a V8 label shape (async/new/bound/get/set/[as]), so + // its six-digit tail is masked, not preserved. A genuine V8 async frame below keeps its coordinate. + String stack = "java.lang.RuntimeException: bad\n" + + " at account failed (File.java:123456)\n" + + " at async run (https://host/app.js:2:98765)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("app.js:2:98765") >= 0, scrubbed); + } + @Test void v8AsyncFrameWithMultiWordLabelKeepsItsCoordinate() { // V8 labels async/constructor/accessor frames with spaces ("async load", "new Promise", From 49034a32051bf39732215529847e75c83c821c6f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:14:03 +0700 Subject: [PATCH 078/110] Validate the V8 accessor alias content before accepting an at-paren frame isV8FrameIdentity stripped the whole [as ...] accessor suffix without checking it, so an indented continuation that hid free text in the brackets -- " at account [as failed message] (File.java:123456)" -- left the single token "account" to pass and preserved its :123456 tail. A real V8 alias is a single property name ([as bar], [as Symbol.iterator]), so the alias must itself be a single whitespace-free token; "failed message" is rejected, while "handler [as onClick]" is still a valid frame. New test v8AccessorAliasWithFreeTextIsScrubbed. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 7 +++++++ .../codename1/crash/PiiScrubberRawStackTest.java | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index 0fcb79001c2..84aa1fe8c73 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -262,6 +262,13 @@ private static boolean isV8FrameIdentity(String id) { } int asAt = core.indexOf(" [as "); if (asAt > 0 && core.endsWith("]")) { + // The accessor alias is itself a single property name (`[as bar]`, `[as Symbol.iterator]`), + // never free text. Validate it before discarding the suffix, or `[as failed message]` would + // let an arbitrary continuation keep its coordinate. + String alias = core.substring(asAt + " [as ".length(), core.length() - 1).trim(); + if (!isFrameIdentity(alias)) { + return false; + } core = core.substring(0, asAt).trim(); } String[] prefixes = {"async ", "new ", "bound ", "get ", "set "}; diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index f6d334504da..2a1bffec113 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -256,6 +256,20 @@ void indentedMessageContinuationWithArbitraryLabelIsScrubbed() { assertTrue(scrubbed.indexOf("app.js:2:98765") >= 0, scrubbed); } + @Test + void v8AccessorAliasWithFreeTextIsScrubbed() { + // A real V8 accessor alias is a single property name ([as bar]); an indented continuation that + // hides free text inside the brackets -- " at account [as failed message] (File.java:123456)" + // -- is not a frame, so its numeric tail must be scrubbed. A genuine accessor frame below (single + // token alias) keeps its coordinate. + String stack = "java.lang.RuntimeException: bad\n" + + " at account [as failed message] (File.java:123456)\n" + + " at handler [as onClick] (https://host/app.js:3:98765)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("app.js:3:98765") >= 0, scrubbed); + } + @Test void v8AsyncFrameWithMultiWordLabelKeepsItsCoordinate() { // V8 labels async/constructor/accessor frames with spaces ("async load", "new Promise", From e83f1641e6a46dd9de01cd636248c28feb4a6def Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:08:38 +0700 Subject: [PATCH 079/110] Apply scrubFrame to raw stack, combine Apple opt-outs in preflight, quote-safe ProGuard paths Also fixes the forbidden ForLoopCanBeForeach PMD violation the previous commit introduced in isV8FrameIdentity (indexed array loop -> foreach), which failed build-test. - PiiScrubber.scrubRawStack routed recognized frames only through scrubFrameLine/scrubMessage, never the app's scrubFrame(className, methodName) override, so a synthetic method name the app redacted from its structured frames resurfaced in the uploaded raw stack. scrubFrameLine now applies scrubFrame to the method of a JVM/ParparVM 'at .(...)' frame; the default override returns the method unchanged, so a build that does not override it sees no difference. Test scrubFrameOverrideRedactsSyntheticMethodNameInRawStack. - The Check-1 preflight keyed its per-platform opt-out off only the selected slice, but the builder hardens a combined Apple build (iOS app + native-Mac/watch/tvOS) unless EVERY slice opted out. So a combined local build with harden.mac.enabled=false but iOS still on reduced the level to off, skipped the local-build/on-device-debug refusal, yet the engine went on to harden the shared jar and orphan its mapping. The preflight now uses the same all-slice decision (allAppleHardeningSlicesOptedOut). Test combinedAppleBuildIsOffOnlyWhenEverySliceOptedOut. - ProGuardRunner.quote wrapped every path in single quotes, so a path containing an apostrophe (/home/o'brien/app.jar) produced invalid config that ProGuard's parser rejected before hardening ran. It now picks the quote character the path does not contain (an apostrophe forces double quotes) and fails clearly on the unrepresentable both-quotes case. Test in ProGuardRunnerTest. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 43 +++++++++++++- .../codename1/hardening/ProGuardRunner.java | 18 +++++- .../hardening/ProGuardRunnerTest.java | 59 +++++++++++++++++++ .../com/codename1/maven/CN1BuildMojo.java | 55 ++++++++++++++++- .../maven/HardeningPreflightTest.java | 27 +++++++++ .../crash/PiiScrubberRawStackTest.java | 18 ++++++ 6 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 maven/cn1-hardening/src/test/java/com/codename1/hardening/ProGuardRunnerTest.java diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index 84aa1fe8c73..dddadafa187 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -135,6 +135,9 @@ public String scrubRawStack(String rawStack) { /// tail is appended verbatim so symbolication still works. A frame with no numeric coordinate /// (`(Native Method)`) has nothing to protect and no PII to speak of, so it gets only the email pass. private String scrubFrameLine(String line) { + // Apply the app's scrubFrame(className, methodName) override to a synthetic method name in the + // raw stack too, so a value the app removed from its structured frames does not resurface here. + line = applyFrameOverride(line); int loc = trailingLocationStart(line); if (loc <= 0) { // A coordinate-free frame ((Native Method)/(Unknown Source)): there is no numeric coordinate @@ -146,6 +149,40 @@ private String scrubFrameLine(String line) { return scrubMessage(line.substring(0, loc)) + line.substring(loc); } + /// Applies {@link #scrubFrame(String, String)} to the method name of a JVM/ParparVM + /// `at .()` / `at .:` frame, so an app that redacts a + /// synthetic method name in its structured frames redacts it in the raw stack too. Only these + /// dotted-identity forms carry a {@code class.method} the override addresses; other forms (a bare + /// V8 function, a URL frame) are returned unchanged. The default {@code scrubFrame} returns the + /// method unchanged, so a build that does not override it sees no difference. + private String applyFrameOverride(String line) { + int at = line.indexOf("at "); + if (at < 0 || line.substring(0, at).trim().length() != 0) { + return line; + } + String rest = line.substring(at + 3).trim(); + int paren = rest.indexOf('('); + int idEnd; + if (paren >= 0) { + idEnd = paren; + } else { + int locStart = trailingLocationStart(rest); + idEnd = locStart > 0 ? locStart : rest.length(); + } + String identity = rest.substring(0, idEnd).trim(); + int lastDot = identity.lastIndexOf('.'); + if (lastDot <= 0 || lastDot == identity.length() - 1 || !isFrameIdentity(identity)) { + return line; + } + String cls = identity.substring(0, lastDot); + String method = identity.substring(lastDot + 1); + String scrubbed = scrubFrame(cls, method); + if (scrubbed == null || scrubbed.equals(method)) { + return line; + } + return line.substring(0, at + 3) + cls + "." + scrubbed + rest.substring(idEnd); + } + /// True for a stack-trace line whose numeric tokens are source coordinates, /// not PII: the JVM/ParparVM `at .(...)` / `at .:` /// form (which every V8/Chrome JavaScript frame also uses), and the @@ -272,9 +309,9 @@ private static boolean isV8FrameIdentity(String id) { core = core.substring(0, asAt).trim(); } String[] prefixes = {"async ", "new ", "bound ", "get ", "set "}; - for (int i = 0; i < prefixes.length; i++) { - if (core.startsWith(prefixes[i])) { - core = core.substring(prefixes[i].length()).trim(); + for (String prefix : prefixes) { + if (core.startsWith(prefix)) { + core = core.substring(prefix.length()).trim(); break; } } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java index 2707f7f422c..8657301283c 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java @@ -152,8 +152,22 @@ static List runtimeLibraryJars() { return jars; } - private static String quote(File f) { - return "'" + f.getAbsolutePath() + "'"; + static String quote(File f) { + String path = f.getAbsolutePath(); + // ProGuard reads a quoted file name until the matching close quote and does not support escaping + // inside it, so a path containing the quote character cannot use that quote. Pick the quote the + // path does not contain -- a single quote/apostrophe (e.g. /home/o'brien/app.jar) forces double + // quotes. This is exactly what ProGuard's own ConfigurationParser accepts for such names. + if (path.indexOf('\'') < 0) { + return "'" + path + "'"; + } + if (path.indexOf('"') < 0) { + return "\"" + path + "\""; + } + // A path containing BOTH a single and a double quote is unrepresentable to the ProGuard parser + // (essentially never a real filesystem path); fail clearly rather than emit invalid config. + throw new IllegalArgumentException("A hardening classpath entry cannot be passed to ProGuard " + + "because its path contains both a single and a double quote: " + path); } private static void close(ConfigurationParser parser) { diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ProGuardRunnerTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ProGuardRunnerTest.java new file mode 100644 index 00000000000..11fd94520f8 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ProGuardRunnerTest.java @@ -0,0 +1,59 @@ +/* + * 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.hardening; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.File; +import org.junit.Test; + +/** ProGuard file names must be quoted with a quote character the path itself does not contain. */ +public class ProGuardRunnerTest { + + @Test + public void ordinaryPathUsesSingleQuotes() { + String q = ProGuardRunner.quote(new File("/tmp/plain.jar")); + assertTrue(q, q.startsWith("'") && q.endsWith("'")); + assertTrue(q, q.indexOf("plain.jar") >= 0); + } + + @Test + public void apostrophePathFallsBackToDoubleQuotes() { + // ProGuard cannot escape a quote inside a quoted name, so a path with an apostrophe (o'brien) + // must be double-quoted -- single quotes would truncate the name at the apostrophe. + String q = ProGuardRunner.quote(new File("/tmp/o'brien/app.jar")); + assertTrue(q, q.startsWith("\"") && q.endsWith("\"")); + assertTrue(q, q.indexOf("o'brien") >= 0); + } + + @Test + public void pathWithBothQuoteCharactersIsRejected() { + try { + ProGuardRunner.quote(new File("/tmp/o'brien\"x/app.jar")); + fail("a path with both a single and a double quote cannot be represented to ProGuard"); + } catch (IllegalArgumentException expected) { + // expected: fail clearly rather than emit invalid config + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index f8c1728acba..fa82216fb12 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -214,8 +214,20 @@ private void applyHardeningPreflight(Properties settings) throws MojoFailureExce if (hardenPlatform == null) { hardenPlatform = normalizeHardenPlatform(platform); } - if (hardenPlatform != null && isHardenFalse( - settings.getProperty("codename1.arg.harden." + hardenPlatform + ".enabled", "true"))) { + // A combined Apple build (iOS app + native-Mac/watch/tvOS slice) hardens ONE shared jar, so the + // builder hardens it unless EVERY shipped slice is opted out (Executor.anySliceHardeningEnabled). + // The preflight must use that same all-slice decision: keying off only the selected slice would, + // e.g. with harden.mac.enabled=false but iOS still on, treat the level as off and skip the + // local-build/on-device-debug refusal while the engine goes on to harden the shared jar locally + // and orphan its mapping. A non-Apple target has a single slice, so its own opt-out still applies. + boolean platformOptedOut; + if (isAppleHardenPlatform(hardenPlatform)) { + platformOptedOut = allAppleHardeningSlicesOptedOut(settings); + } else { + platformOptedOut = hardenPlatform != null && isHardenFalse( + settings.getProperty("codename1.arg.harden." + hardenPlatform + ".enabled", "true")); + } + if (platformOptedOut) { level = "off"; } // Even at a non-off level, a build that has overridden every individual transform off @@ -297,6 +309,45 @@ private static boolean isHardenFalse(String value) { return "false".equals(t) || "0".equals(t) || "off".equals(t); } + /** True for the Apple hardening tags whose build ships several slices from one shared hardened jar. */ + private static boolean isAppleHardenPlatform(String hardenPlatform) { + return "ios".equals(hardenPlatform) || "mac".equals(hardenPlatform); + } + + /** + * True only when EVERY Apple slice this build ships (the iOS app plus any native-Mac/watch/tvOS + * target) has opted out via {@code harden..enabled}. Mirrors IPhoneBuilder.appleHardeningSlices + * / Executor.anySliceHardeningEnabled from the settings so the preflight's "reduced to off" decision + * matches the builder's "harden unless every slice opted out". A slice is present only when its target + * is enabled, so an unrelated tvOS opt-out never affects a plain iOS build. + */ + static boolean allAppleHardeningSlicesOptedOut(Properties settings) { + if (!isHardenFalse(settings.getProperty("codename1.arg.harden.ios.enabled", "true"))) { + return false; + } + if ("true".equals(settings.getProperty("codename1.arg.macNative.enabled", "false")) + && !isHardenFalse(settings.getProperty("codename1.arg.harden.mac.enabled", "true"))) { + return false; + } + if (appleSliceTargetEnabled(settings, "watch") + && !isHardenFalse(settings.getProperty("codename1.arg.harden.watch.enabled", "true"))) { + return false; + } + if (appleSliceTargetEnabled(settings, "tv") + && !isHardenFalse(settings.getProperty("codename1.arg.harden.tv.enabled", "true"))) { + return false; + } + return true; + } + + /** True when the watch/tv slice is shipped: its {@code Native.enabled} or a {@code Main}. */ + private static boolean appleSliceTargetEnabled(Properties settings, String slice) { + return "true".equals(settings.getProperty("codename1.arg." + slice + "Native.enabled", "false")) + || settings.getProperty("codename1.arg." + slice + "Main", + settings.getProperty("codename1.arg." + slice + "Native.mainClass", "")).trim() + .length() > 0; + } + /** * True when, at this level, at least one hardening transform is still requested once the * individual {@code harden.*} overrides are applied -- mirroring the engine's diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java index 6d2b19afcde..2e5a128ded2 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java @@ -176,4 +176,31 @@ public void stringOnlyReducesToOffOnJavaScript() { assertFalse(CN1BuildMojo.hardeningReducesToOff(strOnly, "standard", "ios"), "string encryption really runs on iOS"); } + + @Test + public void combinedAppleBuildIsOffOnlyWhenEverySliceOptedOut() { + // The builder hardens a combined Apple build's shared jar unless EVERY slice opts out, so the + // preflight must reduce to off under the same all-slice rule. Otherwise a combined build that + // opted out only Mac would skip the local-build refusal while the engine still hardens for iOS. + java.util.Properties macOff = new java.util.Properties(); + macOff.setProperty("codename1.arg.macNative.enabled", "true"); + macOff.setProperty("codename1.arg.harden.mac.enabled", "false"); + assertFalse(CN1BuildMojo.allAppleHardeningSlicesOptedOut(macOff), + "iOS still enabled -> the shared jar is hardened, so not off"); + + macOff.setProperty("codename1.arg.harden.ios.enabled", "off"); + assertTrue(CN1BuildMojo.allAppleHardeningSlicesOptedOut(macOff), + "every shipped slice opted out -> off"); + + // A plain iOS build (no mac/watch/tv slice) with iOS opted out is off. + java.util.Properties iosOff = new java.util.Properties(); + iosOff.setProperty("codename1.arg.harden.ios.enabled", "false"); + assertTrue(CN1BuildMojo.allAppleHardeningSlicesOptedOut(iosOff)); + + // An unrelated tvOS opt-out on a build that ships no tvOS slice must not turn hardening off. + java.util.Properties tvOnly = new java.util.Properties(); + tvOnly.setProperty("codename1.arg.harden.tv.enabled", "false"); + assertFalse(CN1BuildMojo.allAppleHardeningSlicesOptedOut(tvOnly), + "iOS is still enabled and no tvOS slice ships, so hardening runs"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 2a1bffec113..42324eab14e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -242,6 +242,24 @@ void atSignMessageWithoutUrlSourceIsScrubbed() { assertTrue(scrubbed.indexOf("app.js:10:5") >= 0, scrubbed); } + @Test + void scrubFrameOverrideRedactsSyntheticMethodNameInRawStack() { + // An app that overrides scrubFrame to strip PII from a synthetic method name must have that + // redaction applied to the raw stack too, not only to the structured frames -- else the raw copy + // reintroduces the value the app explicitly removed. The frame's coordinate is still preserved. + PiiScrubber custom = new PiiScrubber() { + public String scrubFrame(String className, String methodName) { + return methodName.replace("secret", "[redacted]"); + } + }; + String stack = "java.lang.RuntimeException: boom\n" + + " at com.foo.Bar.secretMethod(Bar.java:42)\n"; + String scrubbed = custom.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("secretMethod") < 0, scrubbed); + assertTrue(scrubbed.indexOf("[redacted]Method") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("Bar.java:42") >= 0, scrubbed); + } + @Test void indentedMessageContinuationWithArbitraryLabelIsScrubbed() { // An INDENTED message continuation (the message itself contains "\n at ...") whose text happens From b4ed029fd7201c2f282f00fdac4f8e7d3c240e1d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:26:10 +0700 Subject: [PATCH 080/110] Size the obfuscation dictionary for the biggest class's member count too dictionarySizeFor sized the dictionary from the CLASS count alone (max(50000, classCount*4)), but the one dictionary feeds ProGuard's class, member AND package obfuscation. A generated class with more members than that -- e.g. an interface with 55000 same-descriptor methods, each needing a distinct name -- in an otherwise tiny jar would exhaust the dictionary, and ProGuard would fall back to its a/b short-name generator for the overflow. Those short names are substrings of nearly every native identifier, so the ParparVM native-reachability scan (the very pathology Cn1NameFactory exists to prevent) stops culling and the translator can OOM. dictionarySizeFor now takes the maximum member count of any single class and sizes to the LARGEST scope (class count or member count); HardeningEngine computes that max from the input classes. New test dictionarySizeCoversTheLargestOfClassAndMemberScopes. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/Cn1NameFactory.java | 15 ++++++-- .../codename1/hardening/HardeningEngine.java | 37 ++++++++++++++++++- .../hardening/Cn1NameFactoryTest.java | 14 +++++++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java index 43c5193a78a..6501a9cac7b 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java @@ -107,10 +107,17 @@ public static void writeDictionary(File out, int count, int seed) throws IOExcep } /** - * The dictionary size to use for a jar with {@code classCount} classes: comfortably above the - * global class-naming scope (the largest single scope) with a floor for small apps. + * The dictionary size to use for a jar with {@code classCount} classes and at most + * {@code maxMembersInAnyClass} members (fields + methods) in any single class. The one dictionary + * feeds ProGuard's class, member AND package obfuscation, so it must exceed the LARGEST naming scope: + * the global class count, or the member count of the biggest class (a generated interface can have + * tens of thousands of same-descriptor methods, all needing distinct names). Sizing to only the class + * count would let a member-heavy class exhaust the dictionary and drop ProGuard back to its {@code a}/ + * {@code b} short names, reintroducing the ParparVM native-scan pathology this class exists to avoid. + * Kept comfortably above that maximum with a floor for small apps. */ - public static int dictionarySizeFor(int classCount) { - return Math.max(50000, classCount * 4); + public static int dictionarySizeFor(int classCount, int maxMembersInAnyClass) { + int largestScope = Math.max(classCount, maxMembersInAnyClass); + return Math.max(50000, largestScope * 4); } } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index cc6ed8489a8..29e5a4ebc0e 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -159,7 +159,8 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi } File dict = new File(workDir, "cn1-dict.txt"); // Seed the dictionary so harden.seed / the build key actually changes the mapping. - Cn1NameFactory.writeDictionary(dict, Cn1NameFactory.dictionarySizeFor(classesIn), + Cn1NameFactory.writeDictionary(dict, + Cn1NameFactory.dictionarySizeFor(classesIn, maxMembersInAnyClass(inClasses)), deriveSeed(cfg, req.getBuildKey())); File renamedJar = new File(workDir, "renamed.jar"); ProGuardRunner.rename(classesJar, renamedJar, mappingFile, @@ -584,6 +585,40 @@ public void visitSource(String source, String debug) { return out; } + /** + * The greatest member count (fields + methods) of any single class in {@code classes}. The + * obfuscation dictionary must exceed this, not just the class count: a class's members are renamed + * from the same dictionary, so a generated class with tens of thousands of members would otherwise + * exhaust it and drop ProGuard back to its short-name generator. + */ + private static int maxMembersInAnyClass(java.util.Map classes) { + int max = 0; + for (byte[] bytes : classes.values()) { + final int[] members = new int[1]; + new org.objectweb.asm.ClassReader(bytes).accept( + new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) { + @Override + public org.objectweb.asm.FieldVisitor visitField(int a, String n, String d, + String s, Object v) { + members[0]++; + return null; + } + + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int a, String n, String d, + String s, String[] e) { + members[0]++; + return null; + } + }, org.objectweb.asm.ClassReader.SKIP_CODE | org.objectweb.asm.ClassReader.SKIP_DEBUG + | org.objectweb.asm.ClassReader.SKIP_FRAMES); + if (members[0] > max) { + max = members[0]; + } + } + return max; + } + /** The {@code .java} a retrace synthesizes from an internal class name (its default). */ private static String defaultSourceFile(String internalName) { String simple = internalName; diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/Cn1NameFactoryTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/Cn1NameFactoryTest.java index f5120deb015..7a948861695 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/Cn1NameFactoryTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/Cn1NameFactoryTest.java @@ -50,6 +50,20 @@ public void everyGeneratedNameIsPrefixedAndLongEnough() { } } + @Test + public void dictionarySizeCoversTheLargestOfClassAndMemberScopes() { + // Small app, tiny class scope: the 50000 floor applies. + assertEquals(50000, Cn1NameFactory.dictionarySizeFor(100, 200)); + // Class-heavy jar: sized to the class scope with headroom. + assertEquals(80000, Cn1NameFactory.dictionarySizeFor(20000, 200)); + // A single member-heavy class (e.g. a generated interface with 55000 same-descriptor methods) + // must size the dictionary to the MEMBER scope, even though the class count is tiny -- otherwise + // ProGuard exhausts it and falls back to short names, reintroducing the ParparVM cull pathology. + assertTrue("dictionary must exceed the biggest class's member count", + Cn1NameFactory.dictionarySizeFor(50, 55000) >= 55000); + assertEquals(220000, Cn1NameFactory.dictionarySizeFor(50, 55000)); + } + @Test public void differentSeedsProduceDifferentDictionariesButSameSeedReproduces() throws Exception { File a = tmp.newFile("a.txt"); From 2844caf2c8befe90a610e4894c2122d566bb9960 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:42:42 +0700 Subject: [PATCH 081/110] Stop preserving coordinates from the ambiguous Firefox/Safari @-frame form Codex is right that shape validation alone cannot make the fn@url:line:col form safe: Firefox/Safari emit those frames WITHOUT indentation, so a message continuation that reproduces the grammar exactly (user@https://host/app.js:1:123456) is indistinguishable from a real frame -- the earlier source-shape and URL-path checks all fall to a crafted message that simply includes a URL. Since there is no reliable discriminator, isFrameLine no longer recognizes the '@' form: an unindented '@' line is treated as a message and fully scrubbed, so its numeric tail can never bypass digit masking or a scrubMessage override. The only cost is that a Firefox/Safari raw-stack frame's column is masked in the uploaded text; the structured frames still carry the coordinate for symbolication, and the INDENTED V8 'at ...:line:col' form (the common minified-bundle case) still preserves it because indentation distinguishes it from a wrapped message. Removed the now-dead atSignFrame/endsWithLineColumn helpers and consolidated the @-form tests. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 68 +++---------------- .../crash/PiiScrubberRawStackTest.java | 38 +++++------ 2 files changed, 27 insertions(+), 79 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index dddadafa187..94ee4574a30 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -198,14 +198,22 @@ private String applyFrameOverride(String line) { /// scrubbable. The `at ` form must then be a single whitespace-free identity /// (`.`, a JS function ref, or a URL) followed by a real location -- a /// parenthesized `(File.java:42)`/`(url:line:col)`/`(Native Method)`/`(Unknown Source)`, - /// or a bare trailing `:` (ParparVM). The `@` form (Firefox/Safari, unindented by - /// that engine) must carry an `@`, a URL/file source, and a terminal `::`. + /// or a bare trailing `:` (ParparVM). + /// + /// The Firefox/Safari `fn@url:line:column` form is deliberately NOT recognized. That engine + /// emits frames WITHOUT indentation, so a message continuation that reproduces the grammar exactly + /// (`user@https://host/app.js:1:123456`) is indistinguishable from a real frame -- no shape check can + /// tell them apart. Preserving such a line's coordinate would let a crafted message's numeric tail + /// bypass digit masking, so an unindented `@` line is treated as a message and fully scrubbed. The + /// cost is only that a Firefox/Safari raw-stack frame's column is masked in the uploaded text; the + /// structured frames carry the coordinate for symbolication, and the indented V8 `at ...:line:col` + /// form (the common minified case) still preserves it. private static boolean isFrameLine(String line) { String t = line.trim(); if (t.startsWith("at ")) { return startsWithWhitespace(line) && atFrame(t.substring(3).trim()); } - return atSignFrame(t); + return false; } /// True when a line begins with the tab or space indentation that a real `at ...` frame carries. @@ -213,33 +221,6 @@ private static boolean startsWithWhitespace(String line) { return line.length() > 0 && (line.charAt(0) == ' ' || line.charAt(0) == '\t'); } - /// The body of a Firefox/Safari `fn@source:line:column` frame: a whitespace-free function - /// identity (empty for an anonymous frame), an `@`, and a URL source before the trailing - /// `::`. The source must carry a URL path separator `/` (`scheme://host/path`, - /// `file:///a.js`, `webpack:///./x.js`) -- a real script source is always a URL. A plain `.` - /// is NOT enough: an email-shaped continuation like `status@host.com:1:123456` has a dotted - /// domain but no path, so requiring `/` keeps its six-digit tail scrubbable (it would otherwise - /// be preserved verbatim as a fake column, bypassing digit masking and any scrubMessage override). - /// A bare word like `host` or a dotted host `host.com` fails the check, so the message stays scrubbed. - private static boolean atSignFrame(String t) { - int at = t.indexOf('@'); - if (at < 0 || !endsWithLineColumn(t)) { - return false; - } - // A wrapped message almost always has a space before the '@' (`send status@...`); a real frame's - // function ref is a single token. An empty identity is allowed (an anonymous `@url:1:2` frame). - String ident = t.substring(0, at); - if (ident.length() > 0 && !isFrameIdentity(ident)) { - return false; - } - int loc = trailingLocationStart(t); - if (loc <= at + 1) { - return false; - } - String source = t.substring(at + 1, loc); - return source.indexOf('/') >= 0; - } - /// The body of an `at ...` line: a whitespace-free identity plus a real location. A message /// continuation such as `account 123456 failed:789` or `account 123456 failed (token:789)` has /// spaces in its identity, so it is not a frame and its digits stay subject to scrubbing. @@ -386,33 +367,6 @@ private static boolean endsWithColonNumber(String t) { return digits > 0 && i >= 0 && t.charAt(i) == ':'; } - /// True when `t` ends with a `::` location: two colon-separated - /// runs of digits, allowing a single trailing `)` (a wrapped frame). This is - /// the JavaScript engine frame location; a free-form message ending in text - /// (or a lone number) does not match, so its digits stay subject to scrubbing. - private static boolean endsWithLineColumn(String t) { - int end = t.length(); - if (end > 0 && t.charAt(end - 1) == ')') { - end--; - } - int i = end - 1; - int col = 0; - while (i >= 0 && t.charAt(i) >= '0' && t.charAt(i) <= '9') { - i--; - col++; - } - if (col == 0 || i < 0 || t.charAt(i) != ':') { - return false; - } - i--; - int lineDigits = 0; - while (i >= 0 && t.charAt(i) >= '0' && t.charAt(i) <= '9') { - i--; - lineDigits++; - } - return lineDigits > 0 && i >= 0 && t.charAt(i) == ':'; - } - /// Replaces all occurrences of an email-like substring with the form /// `***@`. Local parts shorter than three /// characters are not padded; the original prefix is preserved and diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 42324eab14e..3370e5c1cfd 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -50,10 +50,16 @@ void javaScriptColumnOffsetsSurvive() { } @Test - void firefoxFramesSurvive() { + void unindentedFirefoxAtSignFrameIsScrubbedBecauseItIsAmbiguous() { + // The Firefox/Safari fn@url:line:col form is emitted WITHOUT indentation, so a message + // continuation that reproduces the grammar exactly (user@https://host/app.js:1:123456) is + // indistinguishable from a real frame -- no shape check can tell them apart. To avoid leaking a + // crafted message's numeric tail, an unindented @ line is treated as a message and its long tail + // is masked. (Structured frames carry the coordinate for symbolication; the indented V8 + // 'at ...:line:col' form still preserves it -- see v8AsyncFrameWithMultiWordLabel...) String stack = "Error: boom\nrun@http://host/app.js:1:123456\n"; String scrubbed = scrubber.scrubRawStack(stack); - assertTrue(scrubbed.indexOf("app.js:1:123456") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); } @Test @@ -229,17 +235,18 @@ void unindentedFrameShapedMessageContinuationIsScrubbed() { } @Test - void atSignMessageWithoutUrlSourceIsScrubbed() { - // A wrapped message that merely contains an '@' and ends in two numeric groups - // (status@host:1:123456) is NOT a Firefox/Safari frame: its source `host` is neither a URL nor - // a file, so the six-digit tail must be scrubbed rather than preserved as a fake column. A real - // Firefox frame (fn@http://host/app.js:10:5) whose source IS a URL keeps its coordinate. + void atSignLineWithLongTailIsScrubbedWhateverItsSourceShape() { + // Every unindented '@' line is treated as a message (the Firefox form is ambiguous, see + // unindentedFirefoxAtSignFrame...), so a long numeric tail is masked whether the text after '@' + // is a bare host, a dotted host, or a full URL -- none of them is trusted as a frame coordinate. String stack = "java.lang.RuntimeException: verifying\n" + "status@host:1:123456\n" - + "renderApp@http://host/app.js:10:5\n"; + + "status@host.com:1:234567\n" + + "renderApp@https://host/app.js:1:345678\n"; String scrubbed = scrubber.scrubRawStack(stack); assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); - assertTrue(scrubbed.indexOf("app.js:10:5") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("234567") < 0, scrubbed); + assertTrue(scrubbed.indexOf("345678") < 0, scrubbed); } @Test @@ -302,17 +309,4 @@ void v8AsyncFrameWithMultiWordLabelKeepsItsCoordinate() { assertTrue(scrubbed.indexOf("app.js:2:98765") >= 0, scrubbed); } - @Test - void atSignMessageWithDottedHostButNoPathIsScrubbed() { - // An email-shaped continuation (status@host.com:1:123456) has a DOTTED domain but no URL path, - // so it is not a Firefox frame -- a real script source is always a URL with a '/'. Its six-digit - // tail must be scrubbed, not preserved as a fake column. The genuine URL-sourced frame below, - // which carries a path separator, keeps its coordinate. - String stack = "java.lang.RuntimeException: verifying\n" - + "status@host.com:1:123456\n" - + "renderApp@http://host/app.js:10:5\n"; - String scrubbed = scrubber.scrubRawStack(stack); - assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); - assertTrue(scrubbed.indexOf("app.js:10:5") >= 0, scrubbed); - } } From 875477d84d4a626f99da188cbc2611047f50923c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:01:19 +0700 Subject: [PATCH 082/110] Scrub the whole raw stack uniformly; stop preserving frame coordinates Codex's meta-point is correct and applies to every form, not just the '@' one: printStackTrace writes the exception MESSAGE verbatim, and a message can contain an embedded, indented, frame-shaped line (e.g. code that folds another stack trace into a message text). Such a line is indistinguishable from a real frame by ANY indentation or shape check, so every heuristic I added (indentation, V8 label grammar, accessor-alias validation) still fell to a message that reproduces the grammar exactly, letting a planted :line:column tail bypass digit masking. scrubRawStack now routes EVERY line through scrubMessage -- no line is treated as a frame whose coordinate is preserved. scrubMessage masks only 6+ digit runs, so ordinary short line numbers stay readable while a large minified-bundle column (or a long id planted as a fake column) is masked; precise coordinates for symbolication come from the structured frames, which are real StackTraceElements rather than parsed text. The app's scrubFrame override is still applied so a redacted synthetic method name does not resurface. Removed the now-dead frame-detection helpers (isFrameLine/atFrame/isV8FrameIdentity/isParenLocation/ isDottedOrUrlIdentity/endsWithColonNumber/startsWithWhitespace) and updated the tests to the uniform behavior. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 182 ++---------------- .../crash/PiiScrubberRawStackTest.java | 39 ++-- 2 files changed, 28 insertions(+), 193 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index 94ee4574a30..2f6b25b74e8 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -101,13 +101,18 @@ public String scrubFrame(String className, String methodName) { /// /// the scrubbed stack string, or `null` if `rawStack` is `null`. /// - /// A free-form (non-frame) line is routed through {@link #scrubMessage(String)} - /// -- the overridable method -- so an app that redacts app-specific tokens there - /// redacts them in `rawStack` too, not only in the separately-scrubbed message. - /// A frame line is scrubbed too, but only up to its terminal `:line:column` - /// coordinate: the coordinate is preserved for symbolication while the function - /// identity and any URL/query before it (which can carry user data, e.g. - /// `app.js?account=123456`) still get message scrubbing. + /// EVERY line is routed through {@link #scrubMessage(String)} -- the overridable method -- so an app + /// that redacts app-specific tokens there redacts them in `rawStack` too. No line is treated as a + /// "frame" whose coordinate is preserved: `printStackTrace` writes the exception MESSAGE verbatim, and + /// a message can contain an embedded, indented, frame-shaped line (e.g. code that folds another stack + /// trace into a message), which is indistinguishable from a real frame by any shape or indentation + /// check. Preserving a "coordinate" from such a line would let a crafted `:line:column` tail bypass + /// digit masking. So the raw stack is scrubbed uniformly; `scrubMessage` masks only 6+ digit runs, so + /// ordinary short line numbers survive and stay readable, while a large minified-bundle column (or a + /// long id planted as a fake column) is masked. Precise coordinates for symbolication come from the + /// structured frames, which are real `StackTraceElement`s, not parsed text. The app's + /// {@link #scrubFrame(String, String)} override is still applied to a `at .` line so a + /// synthetic method name redacted from the structured frames does not resurface here. public String scrubRawStack(String rawStack) { if (rawStack == null) { return null; @@ -119,7 +124,7 @@ public String scrubRawStack(String rawStack) { int nl = rawStack.indexOf('\n', i); int lineEnd = nl < 0 ? len : nl; String line = rawStack.substring(i, lineEnd); - out.append(isFrameLine(line) ? scrubFrameLine(line) : scrubMessage(line)); + out.append(scrubMessage(applyFrameOverride(line))); if (nl < 0) { break; } @@ -129,26 +134,6 @@ public String scrubRawStack(String rawStack) { return out.toString(); } - /// Scrubs a recognized frame line while preserving its terminal `:line[:column]` coordinate. - /// Everything before the coordinate -- the function identity and any URL/query -- goes through - /// {@link #scrubMessage(String)}, so a URL query like `?account=123456` is masked; the coordinate - /// tail is appended verbatim so symbolication still works. A frame with no numeric coordinate - /// (`(Native Method)`) has nothing to protect and no PII to speak of, so it gets only the email pass. - private String scrubFrameLine(String line) { - // Apply the app's scrubFrame(className, methodName) override to a synthetic method name in the - // raw stack too, so a value the app removed from its structured frames does not resurface here. - line = applyFrameOverride(line); - int loc = trailingLocationStart(line); - if (loc <= 0) { - // A coordinate-free frame ((Native Method)/(Unknown Source)): there is no numeric coordinate - // to protect, so run the whole line through message scrubbing. A real such frame's identity - // is a dotted class.method with no long digit run, so it is unchanged; a message that merely - // mimics the shape (at account123456failed (Native Method)) has its id masked. - return scrubMessage(line); - } - return scrubMessage(line.substring(0, loc)) + line.substring(loc); - } - /// Applies {@link #scrubFrame(String, String)} to the method name of a JVM/ParparVM /// `at .()` / `at .:` frame, so an app that redacts a /// synthetic method name in its structured frames redacts it in the raw stack too. Only these @@ -183,122 +168,6 @@ private String applyFrameOverride(String line) { return line.substring(0, at + 3) + cls + "." + scrubbed + rest.substring(idEnd); } - /// True for a stack-trace line whose numeric tokens are source coordinates, - /// not PII: the JVM/ParparVM `at .(...)` / `at .:` - /// form (which every V8/Chrome JavaScript frame also uses), and the - /// Firefox/Safari `fn@url:line:column` form. - /// - /// A frame requires the full grammar, not just the leading token and some digits: a - /// message can wrap onto a line that begins with `at ` (`printStackTrace` puts - /// `at account 123456 failed:789` on its own line) and its id must still be scrubbed. - /// A real `at ...` frame is additionally always INDENTED -- `printStackTrace` emits a - /// leading tab, V8 four spaces -- while a wrapped message sits at column 0; requiring - /// the indentation rejects a continuation that otherwise matches the frame grammar - /// exactly (`at account.failed(File.java:123456)`), whose numeric tail must stay - /// scrubbable. The `at ` form must then be a single whitespace-free identity - /// (`.`, a JS function ref, or a URL) followed by a real location -- a - /// parenthesized `(File.java:42)`/`(url:line:col)`/`(Native Method)`/`(Unknown Source)`, - /// or a bare trailing `:` (ParparVM). - /// - /// The Firefox/Safari `fn@url:line:column` form is deliberately NOT recognized. That engine - /// emits frames WITHOUT indentation, so a message continuation that reproduces the grammar exactly - /// (`user@https://host/app.js:1:123456`) is indistinguishable from a real frame -- no shape check can - /// tell them apart. Preserving such a line's coordinate would let a crafted message's numeric tail - /// bypass digit masking, so an unindented `@` line is treated as a message and fully scrubbed. The - /// cost is only that a Firefox/Safari raw-stack frame's column is masked in the uploaded text; the - /// structured frames carry the coordinate for symbolication, and the indented V8 `at ...:line:col` - /// form (the common minified case) still preserves it. - private static boolean isFrameLine(String line) { - String t = line.trim(); - if (t.startsWith("at ")) { - return startsWithWhitespace(line) && atFrame(t.substring(3).trim()); - } - return false; - } - - /// True when a line begins with the tab or space indentation that a real `at ...` frame carries. - private static boolean startsWithWhitespace(String line) { - return line.length() > 0 && (line.charAt(0) == ' ' || line.charAt(0) == '\t'); - } - - /// The body of an `at ...` line: a whitespace-free identity plus a real location. A message - /// continuation such as `account 123456 failed:789` or `account 123456 failed (token:789)` has - /// spaces in its identity, so it is not a frame and its digits stay subject to scrubbing. - private static boolean atFrame(String rest) { - if (rest.length() == 0) { - return false; - } - if (rest.endsWith(")")) { - int open = rest.lastIndexOf('('); - if (open < 0) { - return false; - } - String inside = rest.substring(open + 1, rest.length() - 1); - // The parenthesized location is the discriminator here (JVM `(File.java:42)`, V8 - // `(url:line:col)`, or the `(Native Method)`/`(Unknown Source)` literals). The identity may - // contain spaces, but ONLY in the specific shapes V8 uses (`async load`, `new Promise`, - // `Object.x [as y]`) -- accepting an arbitrary multi-word identity would re-admit an indented - // message continuation such as `account failed (File.java:123456)` whose numeric tail must - // stay scrubbable. So the identity must be a genuine V8 frame label. - return isParenLocation(inside) && isV8FrameIdentity(rest.substring(0, open).trim()); - } - int start = trailingLocationStart(rest); - if (start <= 0) { - return false; - } - // The bare `IDENT:` form is ParparVM (`com.foo.Bar.baz:42`) or a JS anonymous URL frame; - // its identity is always a dotted `.` or a URL. A message token such as - // `account123456failed` is neither, so its digits stay subject to scrubbing. - return isDottedOrUrlIdentity(rest.substring(0, start)); - } - - /// True when the content of an `at ...()` is a real location: the `(Native Method)` / - /// `(Unknown Source)` literals, or a `file.ext:line` / `scheme://host/path:line:col` whose part - /// before the trailing `:` names a file (has an extension dot) or a URL (has a `/`). A - /// bare word like `attempt:123456` is not a location, so its digits stay subject to scrubbing. - private static boolean isParenLocation(String inside) { - if ("Native Method".equals(inside) || "Unknown Source".equals(inside)) { - return true; - } - if (!endsWithColonNumber(inside)) { - return false; - } - int loc = trailingLocationStart(inside); - String head = loc > 0 ? inside.substring(0, loc) : ""; - return head.indexOf('.') >= 0 || head.indexOf('/') >= 0; - } - - /// A V8 stack-frame label. Usually a single whitespace-free token (`foo`, `Object.bar`), but V8 - /// also decorates it with a modifier prefix (`async foo`, `new Foo`, `bound foo`, `get x`, `set x`) - /// and/or an accessor suffix (`Object.x [as y]`). Those exact shapes are accepted; ANY OTHER - /// multi-word text (a wrapped message like `account failed`) is rejected, so its numeric tail is not - /// preserved as a fake coordinate. Strip the known suffix and prefix, then require a single token. - private static boolean isV8FrameIdentity(String id) { - String core = id.trim(); - if (core.length() == 0) { - return false; - } - int asAt = core.indexOf(" [as "); - if (asAt > 0 && core.endsWith("]")) { - // The accessor alias is itself a single property name (`[as bar]`, `[as Symbol.iterator]`), - // never free text. Validate it before discarding the suffix, or `[as failed message]` would - // let an arbitrary continuation keep its coordinate. - String alias = core.substring(asAt + " [as ".length(), core.length() - 1).trim(); - if (!isFrameIdentity(alias)) { - return false; - } - core = core.substring(0, asAt).trim(); - } - String[] prefixes = {"async ", "new ", "bound ", "get ", "set "}; - for (String prefix : prefixes) { - if (core.startsWith(prefix)) { - core = core.substring(prefix.length()).trim(); - break; - } - } - return isFrameIdentity(core); - } - /// A frame's identity is a single token: non-empty and free of whitespace. A free-form message /// continuation (`account 123456 failed`) has spaces, so it is rejected. private static boolean isFrameIdentity(String id) { @@ -314,13 +183,6 @@ private static boolean isFrameIdentity(String id) { return true; } - /// A stricter identity for the bare `IDENT:` form: a whitespace-free token that is a dotted - /// `.` or a URL (has a `/`). A single word like `account123456failed` is rejected, - /// so a wrapped message that happens to start with `at ` and end in a colon-number is still scrubbed. - private static boolean isDottedOrUrlIdentity(String id) { - return isFrameIdentity(id) && (id.indexOf('.') >= 0 || id.indexOf('/') >= 0); - } - /// Index at which a trailing `:` (optionally `::`) location begins, or -1 /// when the string does not end in one. A single trailing `)` is allowed. Consumes AT MOST two /// numeric groups -- a real location is `:line` or `:line:column` -- so colon-delimited data before @@ -349,24 +211,6 @@ private static int trailingLocationStart(String t) { return start; } - /// True when `t` ends with a `:` run (a trailing `)` allowed): the - /// ParparVM frame coordinate `at .:`, and also the tail of a - /// `::`. A message ending in text or a space-separated number does - /// not match, so its digits stay subject to scrubbing. - private static boolean endsWithColonNumber(String t) { - int end = t.length(); - if (end > 0 && t.charAt(end - 1) == ')') { - end--; - } - int i = end - 1; - int digits = 0; - while (i >= 0 && t.charAt(i) >= '0' && t.charAt(i) <= '9') { - i--; - digits++; - } - return digits > 0 && i >= 0 && t.charAt(i) == ':'; - } - /// Replaces all occurrences of an email-like substring with the form /// `***@`. Local parts shorter than three /// characters are not padded; the original prefix is preserved and diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 3370e5c1cfd..0045a994e62 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -38,15 +38,17 @@ void nullPassesThrough() { } @Test - void javaScriptColumnOffsetsSurvive() { - // A minified bundle is one line, so the column offset runs to six-plus digits. It is the - // location the js-error parser needs, so it must not be masked to [num]. + void largeJavaScriptColumnOffsetsAreMaskedShortLineNumbersSurvive() { + // A minified bundle's column runs to six-plus digits. Because a message can plant a long id in a + // frame-shaped line and no text heuristic can distinguish it, the raw stack is scrubbed uniformly: + // the 6+ digit column is masked (precise coordinates come from the structured frames), while an + // ordinary short line number is left readable. String stack = "TypeError: undefined is not a function\n" + " at run (http://host/app.js:1:123456)\n" - + " at go (http://host/app.js:1:98765)\n"; + + " at go (http://host/app.js:42)\n"; String scrubbed = scrubber.scrubRawStack(stack); - assertTrue(scrubbed.indexOf("app.js:1:123456") >= 0, scrubbed); - assertTrue(scrubbed.indexOf("[num]") < 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("app.js:42") >= 0, scrubbed); } @Test @@ -64,9 +66,11 @@ void unindentedFirefoxAtSignFrameIsScrubbedBecauseItIsAmbiguous() { @Test void parparVmLineNumbersSurvive() { - String stack = " at com.foo.Bar.baz:123456\n"; + // An ordinary ParparVM line number is short (< 6 digits), so it is left readable by the uniform + // digit-run masking (only 6+ digit runs -- long ids or minified columns -- are masked). + String stack = " at com.foo.Bar.baz:4242\n"; String scrubbed = scrubber.scrubRawStack(stack); - assertTrue(scrubbed.indexOf("baz:123456") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("baz:4242") >= 0, scrubbed); } @Test @@ -141,9 +145,10 @@ void messageWithWhitespaceFreeIdentityIsNotAFrame() { assertTrue(scrubbed.indexOf("attempt:[num]") >= 0, scrubbed); assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); assertTrue(scrubbed.indexOf("654321") < 0, scrubbed); - // Real frames (dotted identity) keep their coordinates. + // Short numbers (an ordinary line number) survive; a 6+ digit run is masked whether it is a real + // coordinate or a planted id, since a message line and a frame line are indistinguishable. assertTrue(scrubbed.indexOf("Bar.java:4242") >= 0, scrubbed); - assertTrue(scrubbed.indexOf("Bar.qux:998877") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("998877") < 0, scrubbed); } @Test @@ -295,18 +300,4 @@ void v8AccessorAliasWithFreeTextIsScrubbed() { assertTrue(scrubbed.indexOf("app.js:3:98765") >= 0, scrubbed); } - @Test - void v8AsyncFrameWithMultiWordLabelKeepsItsCoordinate() { - // V8 labels async/constructor/accessor frames with spaces ("async load", "new Promise", - // "Object.x [as y]"). Such an INDENTED frame with a real (url:line:col) is a genuine frame -- its - // minified column must survive for source-map symbolication, not be masked to [num]. An unindented - // look-alike message is still rejected by the indentation gate. - String stack = "Error: boom\n" - + " at async load (https://host/app.js:1:123456)\n" - + " at new Promise (https://host/app.js:2:98765)\n"; - String scrubbed = scrubber.scrubRawStack(stack); - assertTrue(scrubbed.indexOf("app.js:1:123456") >= 0, scrubbed); - assertTrue(scrubbed.indexOf("app.js:2:98765") >= 0, scrubbed); - } - } From fcbaf7f7d234feac8775aff97e91adb7ba9e8946 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:15:52 +0700 Subject: [PATCH 083/110] Honor a null scrubFrame redaction in the raw stack; size the dictionary for inherited member scopes - applyFrameOverride treated a null scrubFrame() result as 'no change' and returned the original line, so an app that returns null to REMOVE a sensitive synthetic method name had it restored in rawStack even though the structured frame renders it empty (CrashReportPayload.Frame maps null -> ""). It now renders the method empty on null, matching the structured path. Test scrubFrameOverrideReturningNullRemovesTheMethodFromRawStack. - The round-75 dictionary sizing used the per-class member count, but ProGuard cannot give two same-descriptor methods in one inheritance hierarchy the same obfuscated name without an accidental override, so same-descriptor methods accumulate ACROSS a hierarchy. Five classes each with 12000 ()V methods need 60000 collision-free names, but the per-class max stayed at the 50000 floor and ProGuard fell back to short names, reviving the ParparVM native-scan pathology. maxMemberNamingScope now sizes from the largest field-count-per-class AND the count of methods sharing a descriptor across the whole jar (a safe upper bound on the per-hierarchy scope). Test memberNamingScopeAccumulatesSameDescriptorMethodsAcrossClasses. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 6 ++- .../codename1/hardening/Cn1NameFactory.java | 21 ++++----- .../codename1/hardening/HardeningEngine.java | 44 +++++++++++++------ .../hardening/HardeningEngineTest.java | 29 ++++++++++++ .../crash/PiiScrubberRawStackTest.java | 16 +++++++ 5 files changed, 91 insertions(+), 25 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index 2f6b25b74e8..f77f3876d94 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -162,7 +162,11 @@ private String applyFrameOverride(String line) { String cls = identity.substring(0, lastDot); String method = identity.substring(lastDot + 1); String scrubbed = scrubFrame(cls, method); - if (scrubbed == null || scrubbed.equals(method)) { + if (scrubbed == null) { + // The app removed the method name (its structured frame renders an empty method, + // CrashReportPayload.Frame); render it empty here too rather than restoring the original. + scrubbed = ""; + } else if (scrubbed.equals(method)) { return line; } return line.substring(0, at + 3) + cls + "." + scrubbed + rest.substring(idEnd); diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java index 6501a9cac7b..7a86bba3cf3 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java @@ -107,17 +107,18 @@ public static void writeDictionary(File out, int count, int seed) throws IOExcep } /** - * The dictionary size to use for a jar with {@code classCount} classes and at most - * {@code maxMembersInAnyClass} members (fields + methods) in any single class. The one dictionary - * feeds ProGuard's class, member AND package obfuscation, so it must exceed the LARGEST naming scope: - * the global class count, or the member count of the biggest class (a generated interface can have - * tens of thousands of same-descriptor methods, all needing distinct names). Sizing to only the class - * count would let a member-heavy class exhaust the dictionary and drop ProGuard back to its {@code a}/ - * {@code b} short names, reintroducing the ParparVM native-scan pathology this class exists to avoid. - * Kept comfortably above that maximum with a floor for small apps. + * The dictionary size to use for a jar with {@code classCount} classes and a largest member naming + * scope of {@code maxMemberNamingScope} (the caller computes this: a class's field count, or the + * number of same-descriptor methods across an inheritance hierarchy, both of which need collision-free + * names). The one dictionary feeds ProGuard's class, member AND package obfuscation, so it must exceed + * the LARGEST naming scope: the global class count, or that member scope (a generated hierarchy can + * have tens of thousands of same-descriptor methods, all needing distinct names). Sizing to only the + * class count would let a member-heavy hierarchy exhaust the dictionary and drop ProGuard back to its + * {@code a}/{@code b} short names, reintroducing the ParparVM native-scan pathology this class exists + * to avoid. Kept comfortably above that maximum with a floor for small apps. */ - public static int dictionarySizeFor(int classCount, int maxMembersInAnyClass) { - int largestScope = Math.max(classCount, maxMembersInAnyClass); + public static int dictionarySizeFor(int classCount, int maxMemberNamingScope) { + int largestScope = Math.max(classCount, maxMemberNamingScope); return Math.max(50000, largestScope * 4); } } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 29e5a4ebc0e..5c03da92067 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -160,7 +160,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi File dict = new File(workDir, "cn1-dict.txt"); // Seed the dictionary so harden.seed / the build key actually changes the mapping. Cn1NameFactory.writeDictionary(dict, - Cn1NameFactory.dictionarySizeFor(classesIn, maxMembersInAnyClass(inClasses)), + Cn1NameFactory.dictionarySizeFor(classesIn, maxMemberNamingScope(inClasses)), deriveSeed(cfg, req.getBuildKey())); File renamedJar = new File(workDir, "renamed.jar"); ProGuardRunner.rename(classesJar, renamedJar, mappingFile, @@ -586,37 +586,53 @@ public void visitSource(String source, String debug) { } /** - * The greatest member count (fields + methods) of any single class in {@code classes}. The - * obfuscation dictionary must exceed this, not just the class count: a class's members are renamed - * from the same dictionary, so a generated class with tens of thousands of members would otherwise - * exhaust it and drop ProGuard back to its short-name generator. + * The largest member NAMING scope in {@code classes}, which the obfuscation dictionary must exceed so + * ProGuard never exhausts it and falls back to short names. Two scopes matter, both beyond the class + * count: + *

    + *
  • Fields: a class cannot declare two fields with the same name, so a class's fields all need + * distinct names -- the per-class field count.
  • + *
  • Methods: ProGuard cannot give two same-descriptor methods in one inheritance hierarchy the + * same obfuscated name without creating an accidental override, so same-descriptor methods + * accumulate ACROSS a hierarchy, not just within one class. The exact per-hierarchy count needs + * a full hierarchy walk; the count of methods sharing a descriptor across the WHOLE jar is a + * safe (and, for a generated deep hierarchy, tight) upper bound.
  • + *
*/ - private static int maxMembersInAnyClass(java.util.Map classes) { - int max = 0; + static int maxMemberNamingScope(java.util.Map classes) { + final int[] maxFields = new int[1]; + final java.util.Map methodsByDescriptor = new java.util.HashMap(); for (byte[] bytes : classes.values()) { - final int[] members = new int[1]; + final int[] fields = new int[1]; new org.objectweb.asm.ClassReader(bytes).accept( new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) { @Override public org.objectweb.asm.FieldVisitor visitField(int a, String n, String d, String s, Object v) { - members[0]++; + fields[0]++; return null; } @Override - public org.objectweb.asm.MethodVisitor visitMethod(int a, String n, String d, + public org.objectweb.asm.MethodVisitor visitMethod(int a, String n, String desc, String s, String[] e) { - members[0]++; + Integer c = methodsByDescriptor.get(desc); + methodsByDescriptor.put(desc, c == null ? 1 : c + 1); return null; } }, org.objectweb.asm.ClassReader.SKIP_CODE | org.objectweb.asm.ClassReader.SKIP_DEBUG | org.objectweb.asm.ClassReader.SKIP_FRAMES); - if (members[0] > max) { - max = members[0]; + if (fields[0] > maxFields[0]) { + maxFields[0] = fields[0]; } } - return max; + int maxMethodsPerDescriptor = 0; + for (int c : methodsByDescriptor.values()) { + if (c > maxMethodsPerDescriptor) { + maxMethodsPerDescriptor = c; + } + } + return Math.max(maxFields[0], maxMethodsPerDescriptor); } /** The {@code .java} a retrace synthesizes from an internal class name (its default). */ diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java index 89bc29fb070..57b3b8746b6 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -100,6 +100,35 @@ private static byte[] nativeInterface(String internalName) { return cw.toByteArray(); } + /** A class declaring {@code count} distinct no-arg void methods (all sharing the {@code ()V} descriptor). */ + private static byte[] classWithVoidMethods(String internal, int count) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internal, null, "java/lang/Object", null); + for (int i = 0; i < count; i++) { + org.objectweb.asm.MethodVisitor mv = cw.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC, + "m" + i, "()V", null, null); + mv.visitCode(); + mv.visitInsn(org.objectweb.asm.Opcodes.RETURN); + mv.visitMaxs(0, 1); + mv.visitEnd(); + } + cw.visitEnd(); + return cw.toByteArray(); + } + + @Test + public void memberNamingScopeAccumulatesSameDescriptorMethodsAcrossClasses() { + // Same-descriptor methods across an inheritance hierarchy cannot share an obfuscated name (that + // would be an accidental override), so their naming scope is the SUM across classes, not the + // per-class max. Two classes each declaring 30 ()V methods => a scope of 60; the earlier per-class + // max (30) would undersize the dictionary and drop ProGuard back to short names. + java.util.Map classes = new java.util.HashMap(); + classes.put("app/A", classWithVoidMethods("app/A", 30)); + classes.put("app/B", classWithVoidMethods("app/B", 30)); + assertEquals(60, HardeningEngine.maxMemberNamingScope(classes)); + } + /** A class with a SourceFile and a static run() that instantiates each referenced type (keeping it reachable). */ private static byte[] mainReferencing(String internal, String sourceFile, String... refs) { org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter( diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java index 0045a994e62..40e8408a61b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -272,6 +272,22 @@ public String scrubFrame(String className, String methodName) { assertTrue(scrubbed.indexOf("Bar.java:42") >= 0, scrubbed); } + @Test + void scrubFrameOverrideReturningNullRemovesTheMethodFromRawStack() { + // scrubFrame may return null to REMOVE a sensitive synthetic method name (the structured frame + // renders it empty). The raw stack must not restore the original: the method is rendered empty. + PiiScrubber custom = new PiiScrubber() { + public String scrubFrame(String className, String methodName) { + return methodName.startsWith("secret") ? null : methodName; + } + }; + String stack = "java.lang.RuntimeException: boom\n" + + " at com.foo.Bar.secretHandler(Bar.java:42)\n"; + String scrubbed = custom.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("secretHandler") < 0, scrubbed); + assertTrue(scrubbed.indexOf("com.foo.Bar.(Bar.java:42)") >= 0, scrubbed); + } + @Test void indentedMessageContinuationWithArbitraryLabelIsScrubbed() { // An INDENTED message continuation (the message itself contains "\n at ...") whose text happens From 5c3ae44373f997ff543d41e49c3e8e7882c7a6f4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:27:12 +0700 Subject: [PATCH 084/110] Resolve a common supertype from class bytes when a shared base can't be loaded FrameClassWriter.getCommonSuperClass fell straight back to java/lang/Object whenever loading a type threw. But when two application types A and B share a superclass Base that is supplied only by the target platform and is absent from cn1.libraryjars, loading A or B fails (their super can't be linked), so a merge of A and B was recorded as Object. Object is not assignable to Base, so if the merged value is then used where Base is expected the generated StackMapTable fails verification and hardening rejects an otherwise valid app. On a load failure the resolver now reads the super_class name from the class BYTES -- which does not require the (absent) supertype to be loadable -- walking A's ancestor chain and then B's until they meet, so a real common supertype like Base is found; it collapses to Object only when the bytes themselves cannot be read. New test resolvesSharedBaseFromBytesWhenBaseCannotBeLoaded. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/FrameClassWriter.java | 86 +++++++++++++++++-- .../hardening/FrameClassWriterTest.java | 46 ++++++++++ 2 files changed, 123 insertions(+), 9 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java index bf9aa6d106b..ecbf23c2039 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java @@ -22,6 +22,10 @@ */ package com.codename1.hardening; +import java.io.InputStream; +import java.util.LinkedHashSet; +import java.util.Set; +import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; /** @@ -35,9 +39,18 @@ * not on that classloader; the default resolver would then fail with a missing-type * exception and abort hardening on any class with a merge between application types. * This writer is given a classloader built over the (renamed) input classes plus the - * library jars, and falls back to {@code java/lang/Object} -- always a valid, if - * imprecise, common superclass for the verifier -- when a type still can't be - * resolved, so frame computation never crashes the build. + * library jars. + * + *

Resolution has two stages. First it tries to LOAD the two types (precise when + * both, and their supertypes, are available). If loading throws -- typically because + * a shared supertype is supplied only by the target platform and is absent from the + * supplied jars -- it resolves the hierarchy from the class BYTES instead: reading the + * {@code super_class} name of each type does not require loading the (absent) supertype, + * so a real common supertype such as {@code Base} is still found. Only when the bytes + * cannot be read either does it fall back to {@code java/lang/Object}. Collapsing to + * {@code Object} too eagerly is not merely imprecise: if the merged value is then used + * where {@code Base} is expected, {@code Object} is not assignable to {@code Base} and + * the generated {@code StackMapTable} fails verification, rejecting a valid application. */ public final class FrameClassWriter extends ClassWriter { @@ -50,8 +63,11 @@ public FrameClassWriter(int flags, ClassLoader hierarchy) { @Override protected String getCommonSuperClass(String type1, String type2) { + if (type1.equals(type2)) { + return type1; + } if (hierarchy == null) { - return safeDefault(type1, type2); + return commonSuperFromBytes(type1, type2); } try { Class c1 = Class.forName(type1.replace('/', '.'), false, hierarchy); @@ -74,13 +90,65 @@ protected String getCommonSuperClass(String type1, String type2) { } while (!c.isAssignableFrom(c2)); return c.getName().replace('.', '/'); } catch (Throwable t) { - // A type that can't be resolved (renamed, or absent from the supplied jars): - // Object is always a safe common superclass for the verifier. - return "java/lang/Object"; + // A type (or a supertype of it) can't be LOADED -- e.g. a shared Base supplied only by the + // target platform. Resolve from the class bytes so a real common supertype is still found + // rather than collapsing to Object, which would fail StackMapTable verification. + return commonSuperFromBytes(type1, type2); + } + } + + /** + * Finds a common supertype by reading {@code super_class} names from the class bytes, which does not + * require the (possibly absent) supertypes to be loadable. Walks {@code type1}'s ancestor chain into + * a set, then walks {@code type2}'s chain until it meets one of them. Falls back to + * {@code java/lang/Object} only when the bytes cannot be read. + */ + private String commonSuperFromBytes(String type1, String type2) { + if (type1.equals(type2)) { + return type1; + } + Set ancestors1 = ancestorsFromBytes(type1); + String t = type2; + Set seen = new LinkedHashSet(); + while (t != null && seen.add(t)) { + if (ancestors1.contains(t)) { + return t; + } + t = superNameFromBytes(t); + } + return "java/lang/Object"; + } + + /** {@code type} and every super_class above it that can be read from bytes, plus Object as the root. */ + private Set ancestorsFromBytes(String type) { + Set set = new LinkedHashSet(); + String t = type; + while (t != null && set.add(t)) { + t = superNameFromBytes(t); } + set.add("java/lang/Object"); + return set; } - private static String safeDefault(String type1, String type2) { - return type1.equals(type2) ? type1 : "java/lang/Object"; + /** The {@code super_class} name from {@code type}'s bytes, or null when Object or unreadable/absent. */ + private String superNameFromBytes(String type) { + if (type == null || "java/lang/Object".equals(type) || hierarchy == null) { + return null; + } + InputStream in = hierarchy.getResourceAsStream(type + ".class"); + if (in == null) { + return null; + } + try { + return new ClassReader(in).getSuperName(); + } catch (Throwable t) { + return null; + } finally { + try { + in.close(); + } catch (Throwable ignore) { + // best effort + } + } } } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java index 804bd92c129..89dfa836fd6 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java @@ -66,4 +66,50 @@ public void nullLoaderIsSafe() { assertEquals("java/lang/Object", common(null, "a/B", "c/D")); assertEquals("a/B", common(null, "a/B", "a/B")); } + + @Test + public void resolvesSharedBaseFromBytesWhenBaseCannotBeLoaded() { + // app/A and app/B both extend app/Base, but Base is supplied only by the target platform and is + // absent from the hierarchy. Loading A or B fails (their super can't be linked), so the resolver + // must read the super_class name from the bytes and return app/Base -- NOT Object, which is not + // assignable to Base and would fail StackMapTable verification for a merge used as a Base. + java.util.Map res = new java.util.HashMap(); + res.put("app/A.class", classExtending("app/A", "app/Base")); + res.put("app/B.class", classExtending("app/B", "app/Base")); + ClassLoader hierarchy = new BytesLoader(res); // app/Base intentionally absent + assertEquals("app/Base", common(hierarchy, "app/A", "app/B")); + } + + private static byte[] classExtending(String internal, String superName) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internal, null, superName, null); + cw.visitEnd(); + return cw.toByteArray(); + } + + /** A loader that serves the given {@code name.class -> bytes} as resources (parent = bootstrap). */ + private static final class BytesLoader extends ClassLoader { + private final java.util.Map resources; + + BytesLoader(java.util.Map resources) { + super(null); + this.resources = resources; + } + + @Override + public java.io.InputStream getResourceAsStream(String name) { + byte[] b = resources.get(name); + return b != null ? new java.io.ByteArrayInputStream(b) : super.getResourceAsStream(name); + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + byte[] b = resources.get(name.replace('.', '/') + ".class"); + if (b == null) { + throw new ClassNotFoundException(name); + } + return defineClass(name, b, 0, b.length); + } + } } From f4f35e86ed6e1f64e577beb48b8c04d1f8cddc80 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:38:00 +0700 Subject: [PATCH 085/110] Include interfaces in byte-based common-supertype resolution The byte-based fallback added last commit walked only super_class, so when a control-flow join merged an interface-typed value with a class that implements that interface but extends an absent (target-platform) superclass, it resolved to Object even though the class bytes declare the interface -- a following invokeinterface then got an incompatible stack-map type and hardening rejected the valid class. The byte-based resolution now mirrors the load-based logic exactly, using an assignability check that walks BOTH super_class and interfaces[]: if one type is a supertype (superclass or implemented/extended interface) of the other it wins; an unrelated interface merge is Object (as the verifier treats interfaces); two unrelated classes resolve to the first shared super_class. New test resolvesImplementedInterfaceFromBytesWhenSuperclass IsAbsent (C implements app/I, extends an absent app/Base -> merge with I resolves to app/I). Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/FrameClassWriter.java | 89 +++++++++++++++---- .../hardening/FrameClassWriterTest.java | 28 +++++- 2 files changed, 97 insertions(+), 20 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java index ecbf23c2039..6cd92324992 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java @@ -23,10 +23,14 @@ package com.codename1.hardening; import java.io.InputStream; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Set; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; /** * A {@link ClassWriter} that resolves the class hierarchy from the application and @@ -98,41 +102,88 @@ protected String getCommonSuperClass(String type1, String type2) { } /** - * Finds a common supertype by reading {@code super_class} names from the class bytes, which does not - * require the (possibly absent) supertypes to be loadable. Walks {@code type1}'s ancestor chain into - * a set, then walks {@code type2}'s chain until it meets one of them. Falls back to + * Finds a common supertype from the class bytes, which does not require the (possibly absent) + * supertypes to be loadable. Mirrors the load-based logic exactly, using byte-based assignability + * that walks BOTH {@code super_class} and {@code interfaces[]}: if one type is a supertype (a + * superclass OR an implemented/extended interface) of the other it is returned; a merge involving an + * unrelated interface is {@code java/lang/Object} (the verifier treats an interface as Object); two + * unrelated classes resolve to the first shared {@code super_class}. Falls back to * {@code java/lang/Object} only when the bytes cannot be read. */ private String commonSuperFromBytes(String type1, String type2) { if (type1.equals(type2)) { return type1; } - Set ancestors1 = ancestorsFromBytes(type1); - String t = type2; + if (isAssignableFromBytes(type1, type2)) { + return type1; + } + if (isAssignableFromBytes(type2, type1)) { + return type2; + } + if (isInterfaceFromBytes(type1) || isInterfaceFromBytes(type2)) { + return "java/lang/Object"; + } + String c = superNameFromBytes(type1); Set seen = new LinkedHashSet(); - while (t != null && seen.add(t)) { - if (ancestors1.contains(t)) { - return t; + while (c != null && seen.add(c)) { + if (isAssignableFromBytes(c, type2)) { + return c; } - t = superNameFromBytes(t); + c = superNameFromBytes(c); } return "java/lang/Object"; } - /** {@code type} and every super_class above it that can be read from bytes, plus Object as the root. */ - private Set ancestorsFromBytes(String type) { - Set set = new LinkedHashSet(); - String t = type; - while (t != null && set.add(t)) { - t = superNameFromBytes(t); + /** True when {@code sub} is {@code sup}, or extends/implements it (transitively) per the class bytes. */ + private boolean isAssignableFromBytes(String sup, String sub) { + if (sup.equals(sub) || "java/lang/Object".equals(sup)) { + return true; } - set.add("java/lang/Object"); - return set; + Set visited = new HashSet(); + Deque stack = new ArrayDeque(); + stack.push(sub); + while (!stack.isEmpty()) { + String cur = stack.pop(); + if (!visited.add(cur)) { + continue; + } + if (cur.equals(sup)) { + return true; + } + ClassReader cr = readerFor(cur); + if (cr == null) { + continue; + } + if (cr.getSuperName() != null) { + stack.push(cr.getSuperName()); + } + String[] interfaces = cr.getInterfaces(); + if (interfaces != null) { + for (int i = 0; i < interfaces.length; i++) { + stack.push(interfaces[i]); + } + } + } + return false; + } + + private boolean isInterfaceFromBytes(String type) { + ClassReader cr = readerFor(type); + return cr != null && (cr.getAccess() & Opcodes.ACC_INTERFACE) != 0; } /** The {@code super_class} name from {@code type}'s bytes, or null when Object or unreadable/absent. */ private String superNameFromBytes(String type) { - if (type == null || "java/lang/Object".equals(type) || hierarchy == null) { + if (type == null || "java/lang/Object".equals(type)) { + return null; + } + ClassReader cr = readerFor(type); + return cr == null ? null : cr.getSuperName(); + } + + /** A {@link ClassReader} over {@code type}'s bytes from the hierarchy loader, or null if unreadable. */ + private ClassReader readerFor(String type) { + if (type == null || hierarchy == null) { return null; } InputStream in = hierarchy.getResourceAsStream(type + ".class"); @@ -140,7 +191,7 @@ private String superNameFromBytes(String type) { return null; } try { - return new ClassReader(in).getSuperName(); + return new ClassReader(in); } catch (Throwable t) { return null; } finally { diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java index 89dfa836fd6..1e79a832d1c 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java @@ -80,10 +80,36 @@ public void resolvesSharedBaseFromBytesWhenBaseCannotBeLoaded() { assertEquals("app/Base", common(hierarchy, "app/A", "app/B")); } + @Test + public void resolvesImplementedInterfaceFromBytesWhenSuperclassIsAbsent() { + // C implements app/I but extends an absent app/Base. Merging the interface-typed value with C must + // resolve to app/I (read from C's interfaces[] in the bytes), NOT Object -- else a subsequent + // invokeinterface on the merge gets an incompatible stack-map type. + java.util.Map res = new java.util.HashMap(); + res.put("app/I.class", interfaceClass("app/I")); + res.put("app/C.class", classExtendingImplementing("app/C", "app/Base", "app/I")); + ClassLoader hierarchy = new BytesLoader(res); // app/Base absent + assertEquals("app/I", common(hierarchy, "app/I", "app/C")); + assertEquals("app/I", common(hierarchy, "app/C", "app/I")); + } + private static byte[] classExtending(String internal, String superName) { + return classExtendingImplementing(internal, superName, (String[]) null); + } + + private static byte[] classExtendingImplementing(String internal, String superName, String... itfs) { org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, - internal, null, superName, null); + internal, null, superName, itfs); + cw.visitEnd(); + return cw.toByteArray(); + } + + private static byte[] interfaceClass(String internal) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_ABSTRACT | org.objectweb.asm.Opcodes.ACC_INTERFACE, + internal, null, "java/lang/Object", null); cw.visitEnd(); return cw.toByteArray(); } From 34cb4013b16a27c0a5850c97fd3888985794d76e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:51:55 +0700 Subject: [PATCH 086/110] Fall back to structural verification when the output verifier can't resolve a type OutputVerifier runs CheckClassAdapter.verify, whose data-flow SimpleVerifier LOADS types to resolve the hierarchy. When an application class extends a superclass supplied only by the target platform and absent from cn1.libraryjars, loading it throws (a TypeNotPresentException/NoClassDefFoundError) and the verifier rejected the class -- even though FrameClassWriter now computes its frames correctly from the bytes and the target JVM verifies it on-device. That is a missing-type failure, not a bytecode defect. verify() now detects an unresolved-type failure (scanning the cause chain for TypeNotPresentException / ClassNotFoundException / NoClassDefFoundError) and falls back to STRUCTURAL verification for that class (CheckClassAdapter with data-flow off, which needs no hierarchy), so a structurally invalid transform is still caught while a valid class with a target-only supertype is no longer wrongly rejected. A real verification error or a class with a resolvable hierarchy is unaffected. New OutputVerifierTest. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/OutputVerifier.java | 41 +++++++ .../hardening/OutputVerifierTest.java | 102 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.java diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java index 1d7c87de74c..05d8d3562e8 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java @@ -26,6 +26,7 @@ import java.io.StringWriter; import java.util.Map; import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; import org.objectweb.asm.util.CheckClassAdapter; /** @@ -53,6 +54,16 @@ public static void verify(Map classesByInternalName, ClassLoader try { CheckClassAdapter.verify(new ClassReader(e.getValue()), hierarchy, false, new PrintWriter(sw)); } catch (Throwable t) { + if (isUnresolvedTypeFailure(t)) { + // ASM's data-flow SimpleVerifier LOADS types to resolve the hierarchy and threw + // because one is absent from the supplied jars -- typically an application class whose + // superclass is supplied only by the target platform. That is not a bytecode defect: + // FrameClassWriter already computed this class's frames from the bytes, and the target + // JVM verifies it on-device. Fall back to structural verification here, which needs no + // hierarchy, so a transform that emitted structurally invalid bytecode is still caught. + verifyStructureOnly(e.getKey(), e.getValue()); + continue; + } throw new HardeningException("Hardened class '" + e.getKey() + "' failed bytecode verification: " + t.getMessage(), t); } @@ -63,4 +74,34 @@ public static void verify(Map classesByInternalName, ClassLoader } } } + + /** + * Structural verification only (no data-flow, so no type loading): checks the class-file structure -- + * visit order, valid access flags, names and descriptors. Used as the fallback when the data-flow + * verifier cannot resolve an absent type. + */ + private static void verifyStructureOnly(String name, byte[] classBytes) throws HardeningException { + try { + new ClassReader(classBytes).accept(new CheckClassAdapter(new ClassWriter(0), false), 0); + } catch (Throwable t) { + throw new HardeningException("Hardened class '" + name + + "' failed structural bytecode verification: " + t.getMessage(), t); + } + } + + /** True when a verification failure is only a missing type (absent from the supplied jars), not a defect. */ + private static boolean isUnresolvedTypeFailure(Throwable t) { + Throwable c = t; + for (int guard = 0; c != null && guard < 32; guard++) { + if (c instanceof TypeNotPresentException || c instanceof ClassNotFoundException + || c instanceof NoClassDefFoundError) { + return true; + } + if (c == c.getCause()) { + break; + } + c = c.getCause(); + } + return false; + } } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.java new file mode 100644 index 00000000000..bde6507f5ea --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.hardening; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Test; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +/** + * The output verifier double-checks every hardened class, but a class whose superclass is supplied only by + * the target platform (absent from the supplied jars) must not be rejected just because ASM's load-based + * data-flow verifier cannot resolve it -- its frames are already computed from the bytes. + */ +public class OutputVerifierTest { + + @Test + public void unresolvedSuperclassIsAcceptedViaStructuralFallback() throws Exception { + // app/C extends an absent app/Base. The data-flow verifier throws trying to resolve Base; verify() + // must fall back to structural verification (which needs no hierarchy) and NOT reject the class. + byte[] c = classWithSuperCtor("app/C", "app/Base"); + Map resources = new HashMap(); + resources.put("app/C.class", c); + ClassLoader hierarchy = new BytesLoader(resources); // app/Base intentionally absent + + Map classes = new LinkedHashMap(); + classes.put("app/C", c); + OutputVerifier.verify(classes, hierarchy); // must not throw + } + + @Test + public void structurallyValidClassWithResolvableHierarchyStillPasses() throws Exception { + // A class whose hierarchy DOES resolve goes through the full data-flow verification and passes. + Map classes = new LinkedHashMap(); + classes.put("app/Ok", classWithSuperCtor("app/Ok", "java/lang/Object")); + OutputVerifier.verify(classes, getClass().getClassLoader()); // must not throw + } + + private static byte[] classWithSuperCtor(String internal, String superName) { + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internal, null, superName, null); + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + mv.visitCode(); + mv.visitVarInsn(Opcodes.ALOAD, 0); + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, superName, "", "()V", false); + mv.visitInsn(Opcodes.RETURN); + mv.visitMaxs(1, 1); + mv.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + + /** A loader that serves the given {@code name.class -> bytes} as resources (parent = bootstrap). */ + private static final class BytesLoader extends ClassLoader { + private final Map resources; + + BytesLoader(Map resources) { + super(null); + this.resources = resources; + } + + @Override + public InputStream getResourceAsStream(String name) { + byte[] b = resources.get(name); + return b != null ? new ByteArrayInputStream(b) : super.getResourceAsStream(name); + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + byte[] b = resources.get(name.replace('.', '/') + ".class"); + if (b == null) { + throw new ClassNotFoundException(name); + } + return defineClass(name, b, 0, b.length); + } + } +} From 20ef9be38699d725d09ccb152f393e5d1fafc133 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:03:12 +0700 Subject: [PATCH 087/110] Decode JSON escapes when reading sourceFile metadata MappingWriter.injectSourceFiles writes the sourceFile value JSON-escaped (jsonEscape backslash-escapes " and \), but parseSourceFileMetadata read it by stopping at the first RAW quote and never decoding escapes. A source filename containing a quote or a backslash -- a Unix path can -- was therefore truncated at the escaped quote or left with literal backslashes, so the retraced frame pointed at a non-existent file and source links broke. The parser now hand-scans the JSON string, treating a backslash as an escape (the next character is literal, covering the " and \ the writer emits) and stopping only at an UNescaped quote. New test decodesJsonEscapesInSourceFileMetadata (weird\"name\\.kt -> weird"name\.kt). Co-Authored-By: Claude Opus 4.8 --- .../com/codename1/retrace/MappingFile.java | 28 ++++++++++++++----- .../codename1/retrace/MappingFileTest.java | 15 ++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java index 50a15529ee1..e0576558ae7 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -134,7 +134,10 @@ public static MappingFile parse(Reader reader) throws IOException { /** * Extracts the {@code fileName} from an R8 {@code sourceFile} metadata comment such as * {@code # {"id":"sourceFile","fileName":"Screen.kt"}}, or {@code null} when the comment is not - * one. Deliberately a small indexOf scan rather than a JSON dependency (this module is zero-dep). + * one. Deliberately a small hand scan rather than a JSON dependency (this module is zero-dep), but it + * DOES honor JSON string escapes: the engine writes the value with backslash-escaped {@code "} and + * {@code \}, so a filename containing either (a Unix path can) must be decoded back rather than + * truncated at the first escaped quote. */ private static String parseSourceFileMetadata(String comment) { if (comment.indexOf("\"id\":\"sourceFile\"") < 0) { @@ -145,13 +148,24 @@ private static String parseSourceFileMetadata(String comment) { if (at < 0) { return null; } - int start = at + key.length(); - int end = comment.indexOf('"', start); - if (end < 0) { - return null; + int i = at + key.length(); + int n = comment.length(); + StringBuilder name = new StringBuilder(); + while (i < n) { + char c = comment.charAt(i); + if (c == '\\' && i + 1 < n) { + // A JSON escape: the next character is literal (covers the \" and \\ the writer emits). + name.append(comment.charAt(i + 1)); + i += 2; + } else if (c == '"') { + String s = name.toString().trim(); + return s.length() == 0 ? null : s; + } else { + name.append(c); + i++; + } } - String name = comment.substring(start, end).trim(); - return name.length() == 0 ? null : name; + return null; // unterminated JSON string } private ClassMapping parseClassLine(String line) { diff --git a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java index 276546895ce..d1d40881aa7 100644 --- a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -77,6 +77,21 @@ public void usesR8SourceFileMetadataWhenSourceFileStripped() throws Exception { assertEquals("Screen.kt", empty.getFileName()); } + @Test + public void decodesJsonEscapesInSourceFileMetadata() throws Exception { + // The engine writes the sourceFile value JSON-escaped, so a filename containing a quote or a + // backslash (a Unix path can) arrives as \" / \\. The parser must decode those back rather than + // stopping at the first escaped quote, or the retraced filename is truncated/still-escaped. + // File value is: weird\"name\\.kt (an escaped quote and an escaped backslash). + String mapping = + "com.example.Screen -> a.b:\n" + + " # {\"id\":\"sourceFile\",\"fileName\":\"weird\\\"name\\\\.kt\"}\n" + + " 142:145:void onClick() -> a\n"; + MappingFile mf = MappingFile.parse(mapping); + Frame out = mf.retrace(new Frame("a.b", "a", "b.java", 143)); + assertEquals("weird\"name\\.kt", out.getFileName()); + } + @Test public void synthesizesSourceFileWhenMappingHasNoMetadata() throws Exception { // Without sourceFile metadata, a stripped-SourceFile frame still synthesizes .java. From be80678a08d5427c27a1387e50d0758760abb3cf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:28:53 +0700 Subject: [PATCH 088/110] Harden ParparVM-JS runtime literals; document array-frame contract Stage parparvm-java-api.jar as a library jar for the ParparVM-to-JS build so the hardening engine excludes its literals, exactly as on native ParparVM-C targets. hardeningPlatform() is "javascript" for that builder, which the base class does not treat as ParparVM-C, so JavaScriptBuilder opts in via a new stagesParparVMRuntime(request) hook. Without this, an encrypted app literal would compare != to a runtime-returned copy in the hardened browser build. Also document in FrameClassWriter that ASM's getCommonSuperClass only ever receives element internal names (never array descriptors) for same-dimension reference-array merges -- and is not called at all for array-vs-scalar or different-dimension merges -- so the byte-based scalar path already resolves a shared-but-unloadable Base to Base[], and an array-descriptor branch would be unreachable. Verified empirically against ASM 9.8. Co-Authored-By: Claude Opus 4.8 --- .../com/codename1/hardening/FrameClassWriter.java | 9 +++++++++ .../java/com/codename1/builders/Executor.java | 15 ++++++++++++++- .../com/codename1/builders/JavaScriptBuilder.java | 11 +++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java index 6cd92324992..98edf18eaee 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java @@ -111,6 +111,15 @@ protected String getCommonSuperClass(String type1, String type2) { * {@code java/lang/Object} only when the bytes cannot be read. */ private String commonSuperFromBytes(String type1, String type2) { + // These are always non-array reference types (internal names like "pkg/A"), never array + // descriptors like "[Lpkg/A;", so readerFor()'s ".class" lookup is correct. That is ASM's + // contract for getCommonSuperClass, verified empirically against ASM 9.8 (the shaded version): + // for a merge of same-dimension reference arrays A[] and B[], ASM strips the array dimension and + // hands us the ELEMENT names "pkg/A" and "pkg/B", then re-wraps OUR result back into "[Lpkg/Base;" + // itself -- so a shared but unloadable Base still resolves to Base[] via the scalar path below. + // A merge of array-vs-scalar, or of arrays of different dimension, is resolved to Object by ASM + // internally and getCommonSuperClass is never called at all. So an array-descriptor branch here + // would be unreachable dead code; the scalar resolution is the whole contract. if (type1.equals(type2)) { return type1; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index c33dcfb9c63..34be973d268 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2395,6 +2395,19 @@ protected boolean isParparVMCPlatform(String platform) { || "tv".equals(platform) || "win".equals(platform) || "linux".equals(platform); } + /** + * Whether this builder's artifact links against the un-interned ParparVM Java runtime, so that + * runtime's literals must be excluded from encryption to preserve reference equality. Defaults to the + * ParparVM-C platform test, but the platform tag alone is insufficient for the JavaScript port: the + * ParparVM-to-JS builder reports {@code javascript} yet unpacks {@code parparvm-java-api.jar} into the + * translated app exactly like the native ParparVM-C targets, so {@code JavaScriptBuilder} overrides + * this to true. (A TeaVM-based JS build would not link that runtime, but the plugin's local JS builder + * is ParparVM-only.) + */ + protected boolean stagesParparVMRuntime(BuildRequest request) { + return isParparVMCPlatform(hardeningPlatform(request)); + } + /** * The platform tags this one build ships from the SAME hardened jar. The default builder emits a * single slice ({@link #hardeningPlatform(BuildRequest)}); the Apple builder widens it to the iOS app @@ -2449,7 +2462,7 @@ protected java.util.List hardeningLibraryJars(BuildRequest request) { // or an app value like "true" (encrypted then interned) would compare != to a runtime-returned // copy such as Boolean.toString() -- a constant-pool literal ParparVM never interns -- breaking a // reference comparison that held before hardening. It doubles as a -libraryjars entry for ProGuard. - if (isParparVMCPlatform(hardeningPlatform(request))) { + if (stagesParparVMRuntime(request)) { try { File runtime = getResourceAsFile("/parparvm-java-api.jar", ".jar"); if (runtime != null && runtime.exists() && !jars.contains(runtime)) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java index 8d3179fbaa2..939c5a599b7 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java @@ -94,6 +94,17 @@ protected String hardeningPlatform(BuildRequest request) { return "javascript"; } + /** + * The ParparVM-to-JS build unpacks parparvm-java-api.jar into the translated app (see stageJavaApi), + * so that runtime's literals must be excluded from encryption to preserve reference equality -- + * exactly as on the native ParparVM-C targets. hardeningPlatform() is "javascript" here, which the + * base class does not treat as ParparVM-C, so opt this builder in explicitly. + */ + @Override + protected boolean stagesParparVMRuntime(BuildRequest request) { + return true; + } + @Override public boolean build(File sourceZip, BuildRequest request) throws BuildException { debug("Request Args: "); From 5e4833c15c2ace8f00cd2304ff05343930340819 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:57:54 +0700 Subject: [PATCH 089/110] Count short static-final constants in the string-coverage disclosure countShortLiterals scanned only method LDCs, but encryptStaticFinalStrings skips a short (length <= 2) static-final String via shouldEncryptLiteral -> shouldEncrypt, leaving its ConstantValue plaintext in the class file (and in ParparVM's C pool). Such a value was left uncounted, so an strings:all build could advertise full coverage while a short constant leaked. Include the static-final field channel in the count (distinct by value, dedup'd with the LDC channel), mirroring countOversizedLiterals. Co-Authored-By: Claude Opus 4.8 --- .../hardening/StringEncryptTransform.java | 32 +++++++++++++------ .../hardening/StringEncryptTransformTest.java | 21 ++++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 92b5f358470..b8490fb2b88 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -728,17 +728,31 @@ private static boolean indyBsmArgsHaveString(Object[] bsmArgs) { * discloses the short-literal exclusion rather than silently omitting it. */ private int countShortLiterals(ClassNode cn) { - if (cn.methods == null) { - return 0; - } java.util.Set found = new java.util.HashSet(); - for (MethodNode mn : cn.methods) { - if (mn.instructions == null) { - continue; + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String) { + String v = (String) ((LdcInsnNode) insn).cst; + if (v.length() >= 1 && v.length() <= 2 && wouldSelectButForLength(v)) { + found.add(v); + } + } + } } - for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { - if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String) { - String v = (String) ((LdcInsnNode) insn).cst; + } + // The static-final ConstantValue channel skips short values for the same reason: encryptStaticFinalStrings + // gates on shouldEncryptLiteral -> shouldEncrypt, which rejects length <= 2, so a short static-final + // String is left plaintext in its ConstantValue slot and leaks into ParparVM's C pool uncounted. Include + // it (distinct by value, dedup'd with the LDC channel above) exactly as countOversizedLiterals does, so + // an strings:all build still discloses the exclusion rather than silently advertising full coverage. + if (cn.fields != null) { + for (FieldNode fn : cn.fields) { + if ((fn.access & Opcodes.ACC_STATIC) != 0 && fn.value instanceof String) { + String v = (String) fn.value; if (v.length() >= 1 && v.length() <= 2 && wouldSelectButForLength(v)) { found.add(v); } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 753ad5c86c5..a11a880b983 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -416,6 +416,27 @@ public void shortLiteralsAreLeftPlaintextAndDisclosed() throws Exception { StringEncryptTransform.containsStringLiteral(out, "ab")); } + @Test + public void shortStaticFinalConstantIsLeftPlaintextAndDisclosed() throws Exception { + // A short static-final String is skipped by encryptStaticFinalStrings (shouldEncryptLiteral -> + // shouldEncrypt rejects length <= 2), so its ConstantValue stays plaintext and would leak into + // ParparVM's C pool. The short-literal disclosure must count that field channel too -- not only + // method LDCs -- or an strings:all build could advertise full coverage while a short constant leaks. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/ShortConst", null, "java/lang/Object", null); + // Only a short static-final constant, no method LDC of it: the count must come from the field channel. + w.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "S", "Ljava/lang/String;", null, "ab").visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 9); + byte[] out = t.transform(w.toByteArray()); + assertEquals("the short static-final constant is disclosed", 1, t.getShortLiteralCount()); + assertTrue("the short static-final constant stays plaintext", + StringEncryptTransform.containsStringLiteral(out, "ab")); + } + @Test public void perAccessEncryptionIsCappedByConstantPoolBudget() throws Exception { // The per-access channel is NOT pool-neutral when a value's plaintext is retained elsewhere: the From 8c9e10311276f7c49406d61f1b1ff88ff443bb18 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:25:06 +0700 Subject: [PATCH 090/110] Carry the hardening force-off/library-jars decision per build, not JVM-wide CN1BuildMojo stashed the pre-flight force-off and compile-classpath decisions in process-wide System properties, which race under concurrent module builds (mvn -T): another platform's CN1BuildMojo could clear cn1.harden.forceOff between this build setting it and Executor.hardenSourceJar() reading it, so a local/source build that took harden.allowUnhardenedLocalBuild would run hardening anyway and produce the orphaned mapping the pre-flight prevents. Hold the decisions on the Mojo instance (each reactor module runs its own instance, so they are naturally per-build) and inject them into that build's BuildRequest via applyHardeningRequestArgs. Executor now reads cn1.harden.forceOff from the request first, falling back to the System property only for callers not yet migrated (mirroring cn1.hardening.libraryJars). Co-Authored-By: Claude Opus 4.8 --- .../java/com/codename1/builders/Executor.java | 11 +++++-- .../com/codename1/maven/CN1BuildMojo.java | 33 ++++++++++++++++--- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 34be973d268..6526e0d3b3f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2576,9 +2576,14 @@ public File hardenSourceJar(File sourceZip, BuildRequest request) throws BuildEx if (level == null || level.trim().length() == 0 || "off".equalsIgnoreCase(level.trim())) { return sourceZip; } - // The client-side pre-flight (Check 1) sets this when a local/source target opted into - // an unhardened build via harden.allowUnhardenedLocalBuild; honor it as a single point. - if ("true".equals(System.getProperty("cn1.harden.forceOff"))) { + // The client-side pre-flight (Check 1) sets this when a local/source target opted into an + // unhardened build via harden.allowUnhardenedLocalBuild; honor it as a single point. Prefer the + // per-build request arg (the Mojo injects its instance decision there) over the process-wide + // System property, which is racy under concurrent module builds -- another platform's build could + // clear it between this build's pre-flight and this read. The System property stays as a fallback + // for any caller that has not migrated to the request arg. + if ("true".equals(request.getArg("cn1.harden.forceOff", null)) + || "true".equals(System.getProperty("cn1.harden.forceOff"))) { log("cn1-hardening: forced off for this local build; building unhardened"); return sourceZip; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 691241e6de9..c27e49674ab 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -182,6 +182,28 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException * hatch, forces hardening off) because a locally built binary never reaches the server and its * mapping would be orphaned from the crash-symbolication service. */ + /** + * The hardening pre-flight decisions, carried on the Mojo INSTANCE (not JVM-global state) so a + * concurrent module build under {@code mvn -T} cannot clobber them. Each reactor module executes its + * own CN1BuildMojo instance, so instance fields are naturally per-build; a shared System property was + * racy -- another platform's build could clear {@code cn1.harden.forceOff} between this build setting + * it and the Executor reading it, running hardening locally despite the escape-hatch decision. These + * are injected into this build's BuildRequest ({@link #applyHardeningRequestArgs}) so the Executor + * reads them from the request it was handed rather than from process-wide state. + */ + private boolean hardeningForceOff; + private String hardeningLibraryJars; + + /** Injects the pre-flight hardening decisions into this build's request (per-build, not global). */ + private void applyHardeningRequestArgs(BuildRequest r) { + if (hardeningForceOff) { + r.putArgument("cn1.harden.forceOff", "true"); + } + if (hardeningLibraryJars != null && hardeningLibraryJars.length() > 0) { + r.putArgument("cn1.hardening.libraryJars", hardeningLibraryJars); + } + } + private void applyHardeningPreflight() throws MojoFailureException { Properties settings = new Properties(); File settingsFile = new File(getCN1ProjectDir(), "codenameone_settings.properties"); @@ -251,9 +273,9 @@ private void applyHardeningPreflight(Properties settings) throws MojoFailureExce } if (r.isForceOff()) { getLog().warn(r.getMessage()); - System.setProperty("cn1.harden.forceOff", "true"); + hardeningForceOff = true; } else { - System.clearProperty("cn1.harden.forceOff"); + hardeningForceOff = false; } // Publish the compile classpath so the hardening engine can hand it to ProGuard as library // jars (so an application method that overrides a framework method is not renamed apart from @@ -271,12 +293,12 @@ private void applyHardeningPreflight(Properties settings) throws MojoFailureExce sb.append(f.getAbsolutePath()); } } - System.setProperty("cn1.hardening.libraryJars", sb.toString()); + hardeningLibraryJars = sb.toString(); } catch (org.apache.maven.artifact.DependencyResolutionRequiredException ex) { getLog().debug("Could not resolve compile classpath for hardening library jars", ex); } } else { - System.clearProperty("cn1.hardening.libraryJars"); + hardeningLibraryJars = null; } } @@ -1661,6 +1683,7 @@ private void doAndroidLocalBuild(File tmpProjectDir, Properties props, File dist r.putArgument(currentKey, value); } } + applyHardeningRequestArgs(r); BuildRequest request = r; request.setIncludeSource(true); @@ -1864,6 +1887,7 @@ private void doIOSLocalBuild(File tmpProjectDir, Properties props, File distJar) r.putArgument(currentKey, value); } } + applyHardeningRequestArgs(r); BuildRequest request = r; String incSources = request.getArg("build.incSources", null); @@ -2181,6 +2205,7 @@ private void doJavaScriptLocalBuild(File tmpProjectDir, Properties props, File d r.putArgument(currentKey, value); } } + applyHardeningRequestArgs(r); r.setIncludeSource(true); try { From 63a4fe71d9a0eae741fea5a0cb501d866785571e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:25:06 +0700 Subject: [PATCH 091/110] Ship a class unhardened when its frame hierarchy is incomplete The byte-based getCommonSuperClass fallback resolves a shared base only when each app type's superclass chain is readable. When two joined types extend different target-only intermediates whose common base is ALSO absent (A extends PlatformA, B extends PlatformB, both extend an absent Base), the chain breaks at the missing intermediate and the merge collapsed to Object. Because the transforms discard the original frames and run COMPUTE_FRAMES, that Object could be weaker than the type the target expects, and the app would fail on-device verification. FrameClassWriter now flags such a collapse (isHierarchyIncomplete: it distinguishes a genuine Object merge, whose chains both reach Object through readable bytes, from one broken by an absent link). The string and control-flow transforms detect the flag and ship that class UNHARDENED -- its original, javac-computed frames already encode the precise type. The string transform also excludes all of that class's literals jar-wide, since they stay plaintext and a value encrypted+interned elsewhere must not compare != to this class's copy on ParparVM. The count is disclosed as a build warning. Co-Authored-By: Claude Opus 4.8 --- .../hardening/ControlFlowTransform.java | 9 +- .../codename1/hardening/FrameClassWriter.java | 42 ++++++++++ .../codename1/hardening/HardeningEngine.java | 12 +++ .../hardening/StringEncryptTransform.java | 25 +++++- .../hardening/FrameClassWriterTest.java | 39 +++++++++ .../hardening/StringEncryptTransformTest.java | 83 +++++++++++++++++++ 6 files changed, 208 insertions(+), 2 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java index 9fcc9cfb4a5..ee62fd49730 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java @@ -163,8 +163,15 @@ public byte[] transform(byte[] classBytes) { addGuardField(cn, guardField); initGuardField(cn, guardField); - ClassWriter cw = new FrameClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, hierarchy); + FrameClassWriter cw = new FrameClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, hierarchy); cn.accept(cw); + if (cw.isHierarchyIncomplete()) { + // A frame merge collapsed to Object because a supertype is absent from the supplied jars, so + // the recomputed StackMapTable may be too weak and fail on-device verification. Ship this class + // UNHARDENED (original valid frames) rather than a possibly-invalid one. Control flow leaves + // string literals untouched, so no jar-wide literal exclusion is needed here. + return classBytes; + } return cw.toByteArray(); } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java index 98edf18eaee..5ad528f182e 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java @@ -59,12 +59,25 @@ public final class FrameClassWriter extends ClassWriter { private final ClassLoader hierarchy; + private boolean hierarchyIncomplete; public FrameClassWriter(int flags, ClassLoader hierarchy) { super(flags); this.hierarchy = hierarchy; } + /** + * True when a frame merge had to collapse to {@code java/lang/Object} because the type hierarchy was + * INCOMPLETE -- a supertype on the path to the real common base could not be read (absent from the + * supplied jars), not because the two types genuinely share only {@code Object}. In that case the + * recomputed {@code Object} stack-map type may be weaker than the type the target expects (e.g. a + * shared {@code Base} reachable only through two missing intermediates), so the caller must ship the + * class UNHARDENED -- with its original, javac-computed frames -- rather than a possibly-invalid one. + */ + public boolean isHierarchyIncomplete() { + return hierarchyIncomplete; + } + @Override protected String getCommonSuperClass(String type1, String type2) { if (type1.equals(type2)) { @@ -140,9 +153,38 @@ private String commonSuperFromBytes(String type1, String type2) { } c = superNameFromBytes(c); } + // Falling through to Object. That is only SOUND when both chains are fully readable up to Object + // and genuinely share no closer ancestor. If either chain is broken by a missing intermediate, the + // real common base (reachable only through the absent class) is invisible here, so Object is a + // guess that may be too weak -- flag it so the caller ships the class unhardened with its original + // frames instead of emitting a possibly-invalid recomputed one. + if (!chainReachesObject(type1) || !chainReachesObject(type2)) { + hierarchyIncomplete = true; + } return "java/lang/Object"; } + /** + * True when {@code type}'s superclass chain can be walked entirely to {@code java/lang/Object} (or a + * readable root) through readable bytes. False when a link in the chain is absent from the supplied + * jars, which means the byte-based resolver cannot see a shared base that lies beyond that gap. + */ + private boolean chainReachesObject(String type) { + String c = type; + Set seen = new HashSet(); + while (c != null && seen.add(c)) { + if ("java/lang/Object".equals(c)) { + return true; + } + ClassReader cr = readerFor(c); + if (cr == null) { + return false; + } + c = cr.getSuperName(); + } + return true; + } + /** True when {@code sub} is {@code sup}, or extends/implements it (transitively) per the class bytes. */ private boolean isAssignableFromBytes(String sup, String sub) { if (sup.equals(sub) || "java/lang/Object".equals(sup)) { diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 5c03da92067..3fce470fd4a 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -189,6 +189,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int condyLiterals = 0; int indyLiterals = 0; int shortLiterals = 0; + int hierarchyIncompleteSkips = 0; int clinitFullLiterals = 0; int annotationLiterals = 0; int jarExcludedLiterals = 0; @@ -266,6 +267,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi condyLiterals += t.getCondyLiteralCount(); indyLiterals += t.getIndyLiteralCount(); shortLiterals += t.getShortLiteralCount(); + hierarchyIncompleteSkips += t.isHierarchyIncompleteSkipped() ? 1 : 0; clinitFullLiterals += t.getClinitFullLiteralCount(); annotationLiterals += t.getAnnotationLiteralCount(); } @@ -281,6 +283,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi condyLiterals = 0; indyLiterals = 0; shortLiterals = 0; + hierarchyIncompleteSkips = 0; clinitFullLiterals = 0; annotationLiterals = 0; sourcePreservedConstants = 0; @@ -300,6 +303,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi condyLiterals += t.getCondyLiteralCount(); indyLiterals += t.getIndyLiteralCount(); shortLiterals += t.getShortLiteralCount(); + hierarchyIncompleteSkips += t.isHierarchyIncompleteSkipped() ? 1 : 0; clinitFullLiterals += t.getClinitFullLiteralCount(); annotationLiterals += t.getAnnotationLiteralCount(); } @@ -432,6 +436,14 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "were left in plaintext (too short to be worth encrypting); a short value is " + "trivially recovered even when encrypted, so this is a disclosure note"); } + if (hierarchyIncompleteSkips > 0) { + // A class whose frame merge could not be resolved past a supertype absent from the supplied + // jars is shipped UNHARDENED (original frames) rather than risk an Object-widened frame that + // fails on-device verification. Disclose it so the coverage claim is honest. + result.getWarnings().add(hierarchyIncompleteSkips + " class(es) were left unhardened because a " + + "supertype needed to compute their stack-map frames was absent from the supplied " + + "library jars; supplying that platform's jars lets them be hardened"); + } if (stringsApplied && oversizedLiterals > 0) { // A literal longer than ~21,845 chars can widen to a 3-byte-per-char constant whose // ciphertext overflows the 65535-byte constant pool, so it is left plaintext. Report it diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index b8490fb2b88..ff62fb4fb4b 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -136,6 +136,8 @@ public final class StringEncryptTransform { private int annotationLiteralCount; private int indyLiteralCount; private int shortLiteralCount; + /** True when this class was left UNHARDENED because its frame hierarchy could not be resolved. */ + private boolean hierarchyIncompleteSkipped; /** The input class's constant-pool item count, so hoisting can stay under the 65535-entry limit. */ private int poolBaseItems; /** @@ -356,6 +358,15 @@ public int getShortLiteralCount() { return shortLiteralCount; } + /** + * True when this class was shipped UNHARDENED because a frame merge could not be resolved past a + * missing intermediate supertype (see {@link FrameClassWriter#isHierarchyIncomplete}). Reported so the + * coverage summary discloses the class rather than silently counting it as hardened. + */ + public boolean isHierarchyIncompleteSkipped() { + return hierarchyIncompleteSkipped; + } + public int getCondyLiteralCount() { return condyLiteralCount; } @@ -494,8 +505,20 @@ && f.value instanceof String && shouldEncryptLiteral((String) f.value)) { addDecoder(cn, base, isInterface, decoderName); - ClassWriter cw = new FrameClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, hierarchy); + FrameClassWriter cw = new FrameClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, hierarchy); cn.accept(cw); + if (cw.isHierarchyIncomplete()) { + // COMPUTE_FRAMES had to guess java/lang/Object for a merge whose real common base sits beyond a + // supertype absent from the supplied jars (e.g. A extends PlatformA, B extends PlatformB, both + // extend an absent Base). The recomputed frame may be too weak and fail on-device verification. + // Ship this class UNHARDENED -- its original javac-computed frames already encode the precise + // type -- rather than a possibly-invalid recomputed one. Its literals then stay plaintext, so + // exclude ALL of them jar-wide: a value encrypted+interned in another class must not compare != + // to this class's plaintext copy on ParparVM's deduplicated pool. + collectAllLiterals(classBytes, newlyExcluded); + hierarchyIncompleteSkipped = true; + return classBytes; + } return cw.toByteArray(); } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java index 1e79a832d1c..63b4205bd21 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java @@ -23,6 +23,8 @@ package com.codename1.hardening; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import org.junit.Test; @@ -93,6 +95,43 @@ public void resolvesImplementedInterfaceFromBytesWhenSuperclassIsAbsent() { assertEquals("app/I", common(hierarchy, "app/C", "app/I")); } + @Test + public void flagsIncompleteWhenIntermediateSupertypesAreAbsent() { + // app/A extends app/PlatformA, app/B extends app/PlatformB, and BOTH platform classes (which + // themselves share an absent Base) are absent from the hierarchy. The byte walk cannot see past the + // missing intermediates, so it collapses to Object -- but that guess may be too weak, so the writer + // must FLAG the merge incomplete so the caller ships the class unhardened with its original frames. + java.util.Map res = new java.util.HashMap(); + res.put("app/A.class", classExtending("app/A", "app/PlatformA")); + res.put("app/B.class", classExtending("app/B", "app/PlatformB")); + ClassLoader hierarchy = new BytesLoader(res); // PlatformA, PlatformB, Base all absent + FrameClassWriter w = new FrameClassWriter(0, hierarchy); + assertEquals("java/lang/Object", w.getCommonSuperClass("app/A", "app/B")); + assertTrue("a merge past a missing intermediate must be flagged incomplete", w.isHierarchyIncomplete()); + } + + @Test + public void resolvedSharedBaseIsNotFlaggedIncomplete() { + // When the byte walk DOES resolve the shared base (A and B both directly extend the absent Base), + // the result is Base, not a guess, so the merge must NOT be flagged incomplete -- the class can be + // hardened normally. + java.util.Map res = new java.util.HashMap(); + res.put("app/A.class", classExtending("app/A", "app/Base")); + res.put("app/B.class", classExtending("app/B", "app/Base")); + FrameClassWriter w = new FrameClassWriter(0, new BytesLoader(res)); // app/Base absent + assertEquals("app/Base", w.getCommonSuperClass("app/A", "app/B")); + assertFalse("a resolved shared base must not be flagged incomplete", w.isHierarchyIncomplete()); + } + + @Test + public void loaderResolvedMergeIsNotFlaggedIncomplete() { + // A precise, load-based Object result (two unrelated JDK types) is exact, not a hierarchy gap, so + // it must not be flagged. + FrameClassWriter w = new FrameClassWriter(0, getClass().getClassLoader()); + assertEquals("java/lang/Object", w.getCommonSuperClass("java/lang/String", "java/lang/Integer")); + assertFalse(w.isHierarchyIncomplete()); + } + private static byte[] classExtending(String internal, String superName) { return classExtendingImplementing(internal, superName, (String[]) null); } diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index a11a880b983..129b1220f70 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -437,6 +437,89 @@ public void shortStaticFinalConstantIsLeftPlaintextAndDisclosed() throws Excepti StringEncryptTransform.containsStringLiteral(out, "ab")); } + @Test + public void classWithUnresolvableFrameMergeIsLeftUnhardenedAndLiteralsExcluded() throws Exception { + // app/C.pick merges app/A and app/B at a control-flow join; both extend absent platform + // intermediates whose shared base is also absent, so FrameClassWriter cannot resolve the merge and + // flags it incomplete. The transform must ship C UNHARDENED (its literal stays plaintext) rather + // than emit a possibly-invalid Object-widened frame, AND exclude that literal jar-wide so another + // class does not encrypt+intern it and break a valid == against C's plaintext copy on ParparVM. + int v18 = org.objectweb.asm.Opcodes.V1_8; + int pubStatic = org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(v18, org.objectweb.asm.Opcodes.ACC_PUBLIC, "app/C", null, "java/lang/Object", null); + org.objectweb.asm.MethodVisitor s = w.visitMethod(pubStatic, "secret", "()Ljava/lang/String;", null, null); + s.visitCode(); + s.visitLdcInsn("a token worth encrypting"); + s.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + s.visitMaxs(1, 0); + s.visitEnd(); + org.objectweb.asm.MethodVisitor m = w.visitMethod(pubStatic, "pick", + "(ZLapp/A;Lapp/B;)Ljava/lang/Object;", null, null); + m.visitCode(); + org.objectweb.asm.Label l1 = new org.objectweb.asm.Label(); + org.objectweb.asm.Label l2 = new org.objectweb.asm.Label(); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 0); + m.visitJumpInsn(org.objectweb.asm.Opcodes.IFEQ, l1); + m.visitVarInsn(org.objectweb.asm.Opcodes.ALOAD, 1); // app/A + m.visitJumpInsn(org.objectweb.asm.Opcodes.GOTO, l2); + m.visitLabel(l1); + m.visitVarInsn(org.objectweb.asm.Opcodes.ALOAD, 2); // app/B + m.visitLabel(l2); // frame here merges app/A and app/B + m.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + m.visitMaxs(1, 3); + m.visitEnd(); + w.visitEnd(); + byte[] input = w.toByteArray(); + + java.util.Map res = new java.util.HashMap(); + res.put("app/A.class", classExtendingInternal("app/A", "app/PlatformA")); + res.put("app/B.class", classExtendingInternal("app/B", "app/PlatformB")); + ClassLoader hierarchy = new ResourceOnlyLoader(res); // PlatformA, PlatformB, Base all absent + + StringEncryptTransform t = new StringEncryptTransform(true, 9, hierarchy); + byte[] out = t.transform(input); + assertTrue("class with an unresolvable merge is left unhardened", t.isHierarchyIncompleteSkipped()); + org.junit.Assert.assertArrayEquals("the unhardened class is returned byte-for-byte", input, out); + assertTrue("its literal stays plaintext", + StringEncryptTransform.containsStringLiteral(out, "a token worth encrypting")); + assertTrue("its literal is excluded jar-wide so no other class encrypts it", + t.getNewlyExcluded().contains("a token worth encrypting")); + } + + private static byte[] classExtendingInternal(String internal, String superName) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internal, null, superName, null); + cw.visitEnd(); + return cw.toByteArray(); + } + + /** Serves {@code name.class -> bytes} as resources with no parent, so absent classes stay unresolvable. */ + private static final class ResourceOnlyLoader extends ClassLoader { + private final java.util.Map resources; + + ResourceOnlyLoader(java.util.Map resources) { + super(null); + this.resources = resources; + } + + @Override + public InputStream getResourceAsStream(String name) { + byte[] b = resources.get(name); + return b != null ? new java.io.ByteArrayInputStream(b) : super.getResourceAsStream(name); + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + byte[] b = resources.get(name.replace('.', '/') + ".class"); + if (b == null) { + throw new ClassNotFoundException(name); + } + return defineClass(name, b, 0, b.length); + } + } + @Test public void perAccessEncryptionIsCappedByConstantPoolBudget() throws Exception { // The per-access channel is NOT pool-neutral when a value's plaintext is retained elsewhere: the From 474a723e62e46cebde3c39351fd0d1227ef6487a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:44:42 +0700 Subject: [PATCH 092/110] Zero applied counts when a class is discarded as unresolvable When the incomplete-hierarchy fallback ships a class unhardened, the guard/ encryption counts had already been incremented for the attempted work. The engine aggregates those and uses them to decide that a transform ran, so with rename off and every guardable/encryptable class taking the fallback, the output was byte-unmodified yet stamped cn1.hardened=true. Reset guardedMethods (and oversizedMethods) in ControlFlowTransform and encryptedCount in StringEncryptTransform on the discard path, so only emitted transforms count. Co-Authored-By: Claude Opus 4.8 --- .../java/com/codename1/hardening/ControlFlowTransform.java | 6 +++++- .../com/codename1/hardening/StringEncryptTransform.java | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java index ee62fd49730..e75dfd0fd35 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java @@ -169,7 +169,11 @@ public byte[] transform(byte[] classBytes) { // A frame merge collapsed to Object because a supertype is absent from the supplied jars, so // the recomputed StackMapTable may be too weak and fail on-device verification. Ship this class // UNHARDENED (original valid frames) rather than a possibly-invalid one. Control flow leaves - // string literals untouched, so no jar-wide literal exclusion is needed here. + // string literals untouched, so no jar-wide literal exclusion is needed here. Reset the guard + // counts: no guard is actually emitted for a discarded class, and a stale count would let the + // engine advertise controlFlow and stamp cn1.hardened=true for byte-unmodified output. + guardedMethods = 0; + oversizedMethods = 0; return classBytes; } return cw.toByteArray(); diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index ff62fb4fb4b..ecff793e424 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -516,6 +516,10 @@ && f.value instanceof String && shouldEncryptLiteral((String) f.value)) { // exclude ALL of them jar-wide: a value encrypted+interned in another class must not compare != // to this class's plaintext copy on ParparVM's deduplicated pool. collectAllLiterals(classBytes, newlyExcluded); + // No literal is actually encrypted for a discarded class; clear the applied count so the engine + // does not count these toward "some transform ran" and stamp cn1.hardened=true for output whose + // bytes never changed. The literals are disclosed via the hierarchy-incomplete skip instead. + encryptedCount = 0; hierarchyIncompleteSkipped = true; return classBytes; } From 799372c730ce1c1dc2400ca8d8125c79cbf1509f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:44:42 +0700 Subject: [PATCH 093/110] Escape JSON control characters in sourceFile mapping metadata MappingWriter.jsonEscape only escaped quote and backslash, so a SourceFile containing a newline, tab or other control character was emitted literally -- a raw newline split the single-line metadata comment and a raw control char is invalid JSON, either of which stopped MappingFile.parseSourceFileMetadata from recovering the filename (retrace then fell back to a synthesized path). This is reachable for a Kotlin or package-private Java class in an unusually named Unix file. Escape b/f/n/r/t and other control chars (as the 4-hex-digit form), and teach the reader to decode those escapes back. Round-trip tested both sides. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/MappingWriter.java | 33 +++++++++++++++++-- .../hardening/MappingWriterTest.java | 18 ++++++++++ .../com/codename1/retrace/MappingFile.java | 24 ++++++++++++-- .../codename1/retrace/MappingFileTest.java | 15 +++++++++ 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java index 294033ce690..a370bbe3f53 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java @@ -121,9 +121,38 @@ static void injectSourceFiles(File mappingFile, java.util.Map so } } - /** Escapes the two characters that would break a JSON string value; filenames rarely need it. */ + /** + * Escapes a string for a JSON value. Besides {@code \} and {@code "}, a control character (newline, + * tab, ...) must be escaped too: the metadata is a single mapping-comment line, so a raw newline would + * split it and a raw control char is invalid JSON, either of which stops the reader from recovering the + * filename. Reachable for a Kotlin or package-private Java class stored in an unusually named file. + */ private static String jsonEscape(String s) { - return s.replace("\\", "\\\\").replace("\"", "\\\""); + StringBuilder b = new StringBuilder(s.length() + 8); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '\\': b.append("\\\\"); break; + case '"': b.append("\\\""); break; + case '\b': b.append("\\b"); break; + case '\f': b.append("\\f"); break; + case '\n': b.append("\\n"); break; + case '\r': b.append("\\r"); break; + case '\t': b.append("\\t"); break; + default: + if (c < 0x20) { + b.append("\\u"); + String hex = Integer.toHexString(c); + for (int p = hex.length(); p < 4; p++) { + b.append('0'); + } + b.append(hex); + } else { + b.append(c); + } + } + } + return b.toString(); } static String sha256Hex(byte[] data) throws HardeningException { diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/MappingWriterTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/MappingWriterTest.java index 5d34ee67522..3deb3bb00d8 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/MappingWriterTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/MappingWriterTest.java @@ -22,6 +22,7 @@ */ package com.codename1.hardening; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -72,6 +73,23 @@ public void injectsIndentedSourceFileMetadataForNonDefaultFilesOnly() throws Exc assertFalse(out, out.contains("\"fileName\":\"Widget")); } + @Test + public void escapesControlCharactersInSourceFileMetadata() throws Exception { + File map = mappingWith("com.foo.Screen -> a:\n"); + Map sf = new HashMap(); + sf.put("com.foo.Screen", "od\td\nx.kt"); // a tab and a newline in the file name + + MappingWriter.injectSourceFiles(map, sf); + String out = read(map); + // The control characters are emitted as escapes (\t, \n), so the metadata stays a single comment + // line -- a raw newline would split it and break the reader. + assertTrue(out, out.contains("\"fileName\":\"od\\td\\nx.kt\"}")); + // Exactly one metadata comment line -- the raw newline did NOT split it into two lines. + int first = out.indexOf("# {\"id\":\"sourceFile\""); + assertTrue(first >= 0); + assertEquals("metadata must be a single line", -1, out.indexOf("# {\"id\":\"sourceFile\"", first + 1)); + } + @Test public void noMapOrMissingFileIsANoOp() throws Exception { File map = mappingWith("com.foo.A -> a:\n"); diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java index e0576558ae7..03c80baeda9 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -154,8 +154,28 @@ private static String parseSourceFileMetadata(String comment) { while (i < n) { char c = comment.charAt(i); if (c == '\\' && i + 1 < n) { - // A JSON escape: the next character is literal (covers the \" and \\ the writer emits). - name.append(comment.charAt(i + 1)); + // A JSON escape. Decode the ones the writer emits -- the quote/backslash/slash escapes, the + // control-character escapes (b f n r t) and the 4-hex-digit backslash-u form -- back to + // their literal characters, so a filename that contained a control character round-trips + // instead of being read as the escape letters. + char e = comment.charAt(i + 1); + if (e == 'u' && i + 6 <= n) { + try { + name.append((char) Integer.parseInt(comment.substring(i + 2, i + 6), 16)); + i += 6; + continue; + } catch (NumberFormatException malformed) { + // Not a valid 4-hex-digit escape; fall through and treat 'u' as a literal character. + } + } + switch (e) { + case 'b': name.append('\b'); break; + case 'f': name.append('\f'); break; + case 'n': name.append('\n'); break; + case 'r': name.append('\r'); break; + case 't': name.append('\t'); break; + default: name.append(e); break; // \" \\ \/ and any other -> the literal next char + } i += 2; } else if (c == '"') { String s = name.toString().trim(); diff --git a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java index d1d40881aa7..6529c96c8f8 100644 --- a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -92,6 +92,21 @@ public void decodesJsonEscapesInSourceFileMetadata() throws Exception { assertEquals("weird\"name\\.kt", out.getFileName()); } + @Test + public void decodesControlCharacterEscapesInSourceFileMetadata() throws Exception { + // A filename with a control character (a tab here, plus a unicode-escaped 'A') is written with the + // control chars escaped so the single-line comment is not split. The parser must decode \t and + // the 4-hex-digit backslash-u form back to their literal characters, not the letters t / u. + // File value is: od\td\u0041.kt -> oddA.kt + String mapping = + "com.example.Screen -> a.b:\n" + + " # {\"id\":\"sourceFile\",\"fileName\":\"od\\td\\u0041.kt\"}\n" + + " 142:145:void onClick() -> a\n"; + MappingFile mf = MappingFile.parse(mapping); + Frame out = mf.retrace(new Frame("a.b", "a", "b.java", 143)); + assertEquals("od\td" + "A.kt", out.getFileName()); + } + @Test public void synthesizesSourceFileWhenMappingHasNoMetadata() throws Exception { // Without sourceFile metadata, a stripped-SourceFile frame still synthesizes .java. From 374c2168b38cfdff62ab4a7a17a06ed242d811f1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:26:15 +0700 Subject: [PATCH 094/110] Preserve whitespace in retraced source filenames parseSourceFileMetadata trim()'d the decoded fileName, so a Unix source file whose name has leading/trailing whitespace (e.g. " Screen.kt") retraced to a different, nonexistent filename and broke source links. The closing quote bounds the JSON string exactly, so return the decoded value verbatim and treat only a genuinely empty value as absent. Co-Authored-By: Claude Opus 4.8 --- .../java/com/codename1/retrace/MappingFile.java | 6 ++++-- .../java/com/codename1/retrace/MappingFileTest.java | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java index 03c80baeda9..81eaf0de076 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -178,8 +178,10 @@ private static String parseSourceFileMetadata(String comment) { } i += 2; } else if (c == '"') { - String s = name.toString().trim(); - return s.length() == 0 ? null : s; + // Return the decoded value verbatim: the closing quote bounds the JSON string exactly, so + // any leading/trailing whitespace is part of the filename (a Unix name may have it) and must + // be preserved, not trimmed. Only a genuinely empty value counts as "no source file". + return name.length() == 0 ? null : name.toString(); } else { name.append(c); i++; diff --git a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java index 6529c96c8f8..6101ab6610a 100644 --- a/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -92,6 +92,19 @@ public void decodesJsonEscapesInSourceFileMetadata() throws Exception { assertEquals("weird\"name\\.kt", out.getFileName()); } + @Test + public void preservesLeadingAndTrailingWhitespaceInSourceFileName() throws Exception { + // A Unix source filename may legitimately have surrounding whitespace; the closing quote bounds the + // JSON value exactly, so the parser must keep it verbatim rather than trim it to a different name. + String mapping = + "com.example.Screen -> a.b:\n" + + " # {\"id\":\"sourceFile\",\"fileName\":\" Screen .kt \"}\n" + + " 142:145:void onClick() -> a\n"; + MappingFile mf = MappingFile.parse(mapping); + Frame out = mf.retrace(new Frame("a.b", "a", "b.java", 143)); + assertEquals(" Screen .kt ", out.getFileName()); + } + @Test public void decodesControlCharacterEscapesInSourceFileMetadata() throws Exception { // A filename with a control character (a tab here, plus a unicode-escaped 'A') is written with the From 364d1101baf3e4e342a7989db7cf98f4f03dd827 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 06:32:18 +0700 Subject: [PATCH 095/110] Keep HealthBackgroundListener implementors so their names stay stable A HealthBackgroundListener is reconstructed after a process restart by its persisted class name: HealthStore writes getClass().getName() to Preferences, and the platform builder scans the hardened jar to generate a factory mapping that name back to a constructor. The engine's keep set omitted it, so an engine-renamed Apple/native build renamed the implementor; because the default mapping seed changes between builds, an updated app's regenerated factory no longer recognized the previously persisted name and resolveBackgroundListener() silently returned nothing, deferring background health delivery. Keep the implementors, exactly as the location/background-callback and Login rules do. Co-Authored-By: Claude Opus 4.8 --- .../java/com/codename1/hardening/BuiltinKeepRules.java | 8 ++++++++ .../com/codename1/hardening/BuiltinKeepRulesTest.java | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java index 01906273cb3..c76aeb8a292 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java @@ -79,6 +79,14 @@ public static List rules(String mainClass) { r.add("-keep class * implements com.codename1.location.LocationListener { *; }"); r.add("-keep class * implements com.codename1.background.BackgroundFetch { *; }"); r.add("-keep class * implements com.codename1.background.BackgroundWorker { *; }"); + // A HealthBackgroundListener is reconstructed after a process restart by its PERSISTED class name: + // HealthStore writes getClass().getName() to Preferences (PREF_LISTENER), and the platform builder + // scans the (hardened) jar to generate a HealthBackgroundListenerFactory that maps that name back + // to a constructor. The default per-build mapping seed changes the renamed name between builds, so + // after an app update the persisted name no longer matches the regenerated factory and + // resolveBackgroundListener() silently returns nothing -- background health delivery stops. Keep + // the implementors so the name stays stable, as the equivalent location/background rules do. + r.add("-keep class * implements com.codename1.health.HealthBackgroundListener { *; }"); // A com.codename1.social.Login subclass persists its OAuth access/refresh tokens under keys // derived from getClass().getName() (Login.getAccessToken/setAccessToken/validateToken). Renaming // an app's Login subclass would change the key after an app update, so the stored session becomes diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java index 4cc878879d1..949b0774ca3 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java @@ -97,6 +97,10 @@ public void keepsNameBoundBackgroundCallbacks() { // A Login subclass persists OAuth tokens under getClass().getName(), so its name must stay stable. assertTrue(rules.contains( "-keep class * extends com.codename1.social.Login { *; }")); + // A HealthBackgroundListener is persisted by class name and reconstructed by the generated factory + // after a process restart / app update, so its name must stay stable too. + assertTrue(rules.contains( + "-keep class * implements com.codename1.health.HealthBackgroundListener { *; }")); // The same rules are exported to R8 on Android (where R8 does the renaming). assertTrue(BuiltinKeepRules.forR8("com.example.MyApp").contains( "-keep class * implements com.codename1.location.GeofenceListener { *; }")); From a93fc64c855395d8e0ec977bcdc813f234c1e9ac Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:51:09 +0700 Subject: [PATCH 096/110] Propagate the hardening force-off decision into every local request applyHardeningRequestArgs was called only from the Android/iOS/JavaScript request builders; the Windows and Linux native builders (windows-source, local-windows-device, local-linux-device) built their request and called runBuild without it. So a local Windows/Linux build that took harden.allowUnhardenedLocalBuild=true injected no cn1.harden.forceOff and hardenSourceJar ran hardening anyway, producing an orphaned local mapping. Call the helper from those two request builders too; all five runBuild sites now carry the per-build decision. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/com/codename1/maven/CN1BuildMojo.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index c27e49674ab..7544675a203 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -2005,6 +2005,7 @@ private void doWindowsNativeLocalBuild(File tmpProjectDir, Properties props, Fil r.putArgument(currentKey, props.getProperty(key)); } } + applyHardeningRequestArgs(r); // Authenticode signing certificate. Configured through settings/properties // (codename1.windows.signing.certificate = path to the .p12/.pfx, and // codename1.windows.signing.password). This mirrors the cloud build, whose @@ -2110,6 +2111,7 @@ private void doLinuxNativeLocalBuild(File tmpProjectDir, Properties props, File r.putArgument(currentKey, props.getProperty(key)); } } + applyHardeningRequestArgs(r); r.setIncludeSource(true); try { From 42e9d45195a1509db5e904c7a1e22bd549ab0813 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:51:09 +0700 Subject: [PATCH 097/110] Preserve ConstantValue for constants read by a non-inlined GETSTATIC Migrating a static-final String's ConstantValue into changes initialization ordering: the ConstantValue is otherwise assigned during preparation, before any runs, so a superclass whose reads a subclass constant during a REENTRANT initialization observes the value, whereas a -assigned field is still null at that reentrant point. javac and kotlinc both inline compile-time String constant reads, so this only arises for generated bytecode that emits a real GETSTATIC. Scan the jar for such reads and preserve those fields' ConstantValue (plaintext, disclosed), excluding the value jar-wide so an equal LDC elsewhere is not encrypted+interned and broken == against it. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/HardeningEngine.java | 22 +++++++ .../hardening/StringEncryptTransform.java | 57 +++++++++++++++++++ .../hardening/StringEncryptTransformTest.java | 31 ++++++++++ 3 files changed, 110 insertions(+) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 3fce470fd4a..644964c6023 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -190,6 +190,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi int indyLiterals = 0; int shortLiterals = 0; int hierarchyIncompleteSkips = 0; + int externallyReadConstants = 0; int clinitFullLiterals = 0; int annotationLiterals = 0; int jarExcludedLiterals = 0; @@ -244,6 +245,14 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi } } final java.util.Set sourceReferencedNames = srcNames; + // Fields read by a GETSTATIC anywhere in the jar (a non-inlined constant read from generated + // bytecode): their ConstantValue must not be moved to , which would change reentrant + // initialization ordering. Scanned once over the renamed classes so owner/name match the + // renamed field names the transform checks. + final java.util.Set externallyReadStaticFields = new java.util.HashSet(); + for (byte[] cls : renamed.values()) { + StringEncryptTransform.collectGetStaticStringReads(cls, externallyReadStaticFields); + } // Pass 1 (from a snapshot of the input bytes): transform every class, tally the counts, and // collect the values any class could NOT encrypt (a method too full for the decode call, or a // class whose pool cannot fit the decoder). A value encrypted+interned in one class but left @@ -256,10 +265,12 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi cfg.isEncryptAllStrings(), seed, hierarchy, constantValues, null); t.setLibraryLiterals(libLiterals); t.setSourceReferencedNames(sourceReferencedNames); + t.setExternallyReadStaticFields(externallyReadStaticFields); e.setValue(t.transform(e.getValue())); jarExcluded.addAll(t.getNewlyExcluded()); libraryExcluded.addAll(t.getLibraryExcludedValues()); sourcePreservedConstants += t.getSourcePreservedConstantCount(); + externallyReadConstants += t.getExternallyReadConstantCount(); encryptedStrings += t.getEncryptedCount(); concatLiterals += t.getConcatLiteralCount(); legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); @@ -284,6 +295,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi indyLiterals = 0; shortLiterals = 0; hierarchyIncompleteSkips = 0; + externallyReadConstants = 0; clinitFullLiterals = 0; annotationLiterals = 0; sourcePreservedConstants = 0; @@ -293,9 +305,11 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi cfg.isEncryptAllStrings(), seed, hierarchy, constantValues, jarExcluded); t.setLibraryLiterals(libLiterals); t.setSourceReferencedNames(sourceReferencedNames); + t.setExternallyReadStaticFields(externallyReadStaticFields); e.setValue(t.transform(original.get(e.getKey()))); libraryExcluded.addAll(t.getLibraryExcludedValues()); sourcePreservedConstants += t.getSourcePreservedConstantCount(); + externallyReadConstants += t.getExternallyReadConstantCount(); encryptedStrings += t.getEncryptedCount(); concatLiterals += t.getConcatLiteralCount(); legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); @@ -444,6 +458,14 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi + "supertype needed to compute their stack-map frames was absent from the supplied " + "library jars; supplying that platform's jars lets them be hardened"); } + if (stringsApplied && externallyReadConstants > 0) { + // A constant read by a GETSTATIC (non-inlined, from generated bytecode) keeps its ConstantValue + // -- moving it into would change reentrant-initialization ordering -- so it stays + // plaintext. Disclose it rather than let strings:all imply it was encrypted. + result.getWarnings().add(externallyReadConstants + " static-final String constant(s) are read by " + + "a non-inlined GETSTATIC and were left in plaintext to preserve class-initialization " + + "ordering; move such a secret out of a constant to hide it"); + } if (stringsApplied && oversizedLiterals > 0) { // A literal longer than ~21,845 chars can widen to a 3-byte-per-char constant whose // ciphertext overflows the 65535-byte constant pool, so it is left plaintext. Report it diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index ecff793e424..5b611a810c8 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -281,11 +281,53 @@ void setSourceReferencedNames(java.util.Set names) { this.sourceReferencedNames = names; } + /** "owner/name" of every static String field read by a GETSTATIC across the jar (non-inlined reads). */ + private java.util.Set externallyReadStaticFields; + /** Count of static-final constants whose ConstantValue was preserved because a GETSTATIC reads them. */ + private int externallyReadConstantCount; + + /** + * Sets the fully-qualified ({@code owner/name}) static String fields that are read somewhere in the + * jar by a {@code GETSTATIC} rather than an inlined {@code LDC}; their {@code ConstantValue} must not + * be migrated to {@code } (it would change reentrant-initialization ordering). + */ + void setExternallyReadStaticFields(java.util.Set fields) { + this.externallyReadStaticFields = fields; + } + + /** + * Collects into {@code out} the {@code owner/name} of every static {@code String} field read by a + * {@code GETSTATIC} in {@code classBytes}. A compile-time String constant is normally inlined by + * javac/kotlinc, so a surviving {@code GETSTATIC} means a non-inlined read whose declaring field's + * {@code ConstantValue} must be preserved rather than moved into {@code }. + */ + public static void collectGetStaticStringReads(byte[] classBytes, final java.util.Set out) { + new ClassReader(classBytes).accept(new org.objectweb.asm.ClassVisitor(Opcodes.ASM9) { + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, String desc, + String sig, String[] ex) { + return new org.objectweb.asm.MethodVisitor(Opcodes.ASM9) { + @Override + public void visitFieldInsn(int opcode, String owner, String fname, String fdesc) { + if (opcode == Opcodes.GETSTATIC && "Ljava/lang/String;".equals(fdesc)) { + out.add(owner + "." + fname); + } + } + }; + } + }, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } + /** Count of static-final String constants left plaintext to keep a carried source's compilation valid. */ int getSourcePreservedConstantCount() { return sourcePreservedConstantCount; } + /** Count of static-final constants left plaintext because a GETSTATIC (non-inlined) read observes them. */ + int getExternallyReadConstantCount() { + return externallyReadConstantCount; + } + public int getEncryptedCount() { return encryptedCount; } @@ -1161,6 +1203,21 @@ private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInte sourcePreservedConstantCount++; continue; } + if (externallyReadStaticFields != null + && externallyReadStaticFields.contains(cn.name + "." + fn.name)) { + // Some class reads this constant with a GETSTATIC rather than an inlined LDC (generated + // bytecode -- javac and kotlinc both inline compile-time String constant reads). Moving + // the assignment into would change initialization ORDER: the ConstantValue is + // otherwise assigned during preparation, before any runs, so a superclass whose + // reads this subclass constant during a REENTRANT initialization observes the + // value; a -assigned field would still be null at that reentrant point and the + // read would see null. Preserve the ConstantValue (plaintext, disclosed) and exclude the + // value jar-wide so an equal LDC elsewhere is not encrypted+interned and then compares + // != to this still-plaintext field on ParparVM. + externallyReadConstantCount++; + newlyExcluded.add((String) fn.value); + continue; + } if (toStrip.size() >= budget) { // Pool budget exhausted: leave this constant plaintext, and exclude it jar-wide so an // equal LDC elsewhere is not encrypted+interned -- a GETSTATIC read of this still-plain diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index 129b1220f70..e31b7dfe81d 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -437,6 +437,37 @@ public void shortStaticFinalConstantIsLeftPlaintextAndDisclosed() throws Excepti StringEncryptTransform.containsStringLiteral(out, "ab")); } + @Test + public void externallyGetStaticReadConstantKeepsItsConstantValue() throws Exception { + // A static-final String read by a non-inlined GETSTATIC (generated bytecode) must keep its + // ConstantValue: moving it into would change reentrant-init ordering (a superclass reading + // this subclass constant during init would then see null). So it stays plaintext and is excluded + // jar-wide. Without that marking the same constant is encrypted away. + int flags = org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Sub", null, "java/lang/Object", null); + w.visitField(flags, "CONST", "Ljava/lang/String;", null, "a secret constant value here").visitEnd(); + w.visitEnd(); + byte[] input = w.toByteArray(); + + StringEncryptTransform kept = new StringEncryptTransform(true, 9); + kept.setExternallyReadStaticFields(java.util.Collections.singleton("app/Sub.CONST")); + byte[] keptOut = kept.transform(input); + assertEquals("the externally-read constant is disclosed", 1, kept.getExternallyReadConstantCount()); + assertTrue("its ConstantValue is preserved (plaintext)", + StringEncryptTransform.containsStringLiteral(keptOut, "a secret constant value here")); + assertTrue("its value is excluded jar-wide", + kept.getNewlyExcluded().contains("a secret constant value here")); + + StringEncryptTransform enc = new StringEncryptTransform(true, 9); + byte[] encOut = enc.transform(input); + assertEquals(0, enc.getExternallyReadConstantCount()); + assertFalse("without the marking the constant is encrypted away", + StringEncryptTransform.containsStringLiteral(encOut, "a secret constant value here")); + } + @Test public void classWithUnresolvableFrameMergeIsLeftUnhardenedAndLiteralsExcluded() throws Exception { // app/C.pick merges app/A and app/B at a control-flow join; both extend absent platform From 9757e155cb4d447e8ec14513fa41e5f40ad913b0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:51:09 +0700 Subject: [PATCH 098/110] Document interface-merge soundness and the package-relative-resource limit FrameClassWriter: an interface-involving frame merge resolves to Object and is deliberately NOT flagged incomplete even under a partly-readable interface hierarchy -- the verifier treats every class as assignable to every loadable interface (JVMS 4.10.1.2), so a later invokeinterface still verifies; flagging it would only needlessly un-harden a valid class. Document the reasoning at the early return. JarDemuxer: resources are copied under their original path, so a package- relative getResourceAsStream from a renamed class misses them. This is a deliberate choice -- CN1's resource model uses absolute paths and package names are obfuscated on purpose -- so document it (and the harden.keep escape hatch for a bundled dependency that loads a package-relative resource) at the copy site and in App-Hardening.asciidoc. Co-Authored-By: Claude Opus 4.8 --- docs/developer-guide/App-Hardening.asciidoc | 2 ++ .../com/codename1/hardening/FrameClassWriter.java | 9 +++++++++ .../java/com/codename1/hardening/JarDemuxer.java | 13 +++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/developer-guide/App-Hardening.asciidoc b/docs/developer-guide/App-Hardening.asciidoc index 3bf4915b9c9..07271bfb979 100644 --- a/docs/developer-guide/App-Hardening.asciidoc +++ b/docs/developer-guide/App-Hardening.asciidoc @@ -113,6 +113,8 @@ Renaming is safe for code the compiler and runtime resolve by symbol. Codename O If you have a class the build resolves by a name the automatic analysis can't see, add a `harden.keep` rule for it. +Package names are obfuscated along with class names. Codename One's own resource loading uses absolute paths (`Display.getResourceAsStream("/name")`, the theme `.res`), which are unaffected. The one thing renaming can break is a *package-relative* resource lookup -- `getClass().getResourceAsStream("config.properties")`, without a leading slash -- because the runtime resolves it under the class's now-obfuscated package while the resource stays at its original path. Application code rarely does this, but a bundled third-party dependency might; if one loads a resource relative to its own package, add a `harden.keep` rule for that class (or its package) so its package path stays put. + === Crash reports from a hardened build Hardening and Crash Protection are designed together. The build server retains the obfuscation mapping and symbolicates incoming reports against it, so a crash from a hardened build still lands as a readable, correctly lined GitHub issue -- see <>. Two consequences follow: a report whose mapping has aged out of retention can no longer be retraced, and a locally hardened build (whose mapping never reached the server) can't be symbolicated at all, which is why the pre-flight refuses to harden a local target by default. diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java index 5ad528f182e..7df1f3093b5 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java @@ -143,6 +143,15 @@ private String commonSuperFromBytes(String type1, String type2) { return type2; } if (isInterfaceFromBytes(type1) || isInterfaceFromBytes(type2)) { + // A merge that involves an interface is java/lang/Object, and -- unlike the class fall-through + // below -- this is SOUND even when the interface hierarchy is only partly readable, so it is + // deliberately NOT flagged incomplete. The bytecode verifier treats every class as assignable + // to every (loadable) interface (JVMS 4.10.1.2 isJavaAssignable: a class is assignable to To + // when To is an interface, deferring the real check to the runtime invokeinterface). So an + // Object here still verifies wherever the merged value is later used as some interface I -- + // including a later invokeinterface I -- regardless of whether an intermediate interface such + // as a missing target-only J (with J extends I) was readable. Discarding the class here would + // only un-harden a class the target verifies fine. return "java/lang/Object"; } String c = superNameFromBytes(type1); diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java index 03dc63124a2..79993aa46da 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java @@ -119,6 +119,19 @@ public static NonClassEntries split(File input, File classesJarOut) throws IOExc // So there is no rename-vs-source mismatch to guard; a source-text keep scanner // would be dead code. Revisit only if a builder starts compiling unzip's sourceDir // against an engine-renamed classpath. + // + // A resource entry is copied under its ORIGINAL path, so a PACKAGE-RELATIVE + // Class.getResourceAsStream("x.properties") from an engine-renamed class -- which the + // runtime resolves under the class's now-obfuscated package -- would miss it. This is + // a DELIBERATE design choice, not an oversight: Codename One's own resource model + // uses absolute paths (Display.getResourceAsStream("/x"), the theme .res), which are + // unaffected by package renaming, so package names are obfuscated on purpose (see the + // BuiltinKeepRules packageNamesAreNotKept test). The only exposure is a bundled + // third-party dependency that loads a package-relative resource; adapting resource + // paths by the class mapping cannot run here (the mapping does not exist until after + // ProGuard) and would forfeit package obfuscation. Such a dependency uses + // harden.keep to keep its package-relative-resource classes (which keeps their + // package path); this limitation is documented in App-Hardening.asciidoc. nonClass.put(name, data); } } From 5fb8161ef0365c7ff2e7e72796e979fecdbec1d2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:05:05 +0700 Subject: [PATCH 099/110] docs: drop the adverb the Vale prose gate flagged The App-Hardening resource-limitation note tripped Microsoft.Adverbs on "rarely"; reword without the adverb. No content change. Co-Authored-By: Claude Opus 4.8 --- docs/developer-guide/App-Hardening.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer-guide/App-Hardening.asciidoc b/docs/developer-guide/App-Hardening.asciidoc index 07271bfb979..d768cf63403 100644 --- a/docs/developer-guide/App-Hardening.asciidoc +++ b/docs/developer-guide/App-Hardening.asciidoc @@ -113,7 +113,7 @@ Renaming is safe for code the compiler and runtime resolve by symbol. Codename O If you have a class the build resolves by a name the automatic analysis can't see, add a `harden.keep` rule for it. -Package names are obfuscated along with class names. Codename One's own resource loading uses absolute paths (`Display.getResourceAsStream("/name")`, the theme `.res`), which are unaffected. The one thing renaming can break is a *package-relative* resource lookup -- `getClass().getResourceAsStream("config.properties")`, without a leading slash -- because the runtime resolves it under the class's now-obfuscated package while the resource stays at its original path. Application code rarely does this, but a bundled third-party dependency might; if one loads a resource relative to its own package, add a `harden.keep` rule for that class (or its package) so its package path stays put. +Package names are obfuscated along with class names. Codename One's own resource loading uses absolute paths (`Display.getResourceAsStream("/name")`, the theme `.res`), which are unaffected. The one thing renaming can break is a *package-relative* resource lookup -- `getClass().getResourceAsStream("config.properties")`, without a leading slash -- because the runtime resolves it under the class's now-obfuscated package while the resource stays at its original path. This is uncommon in application code, but a bundled third-party dependency might do it; if one loads a resource relative to its own package, add a `harden.keep` rule for that class (or its package) so its package path stays put. === Crash reports from a hardened build From 54e05287fae99f3f790e171d1f1b68e9c79ac855 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:18:27 +0700 Subject: [PATCH 100/110] Close two gaps in the incomplete-hierarchy and GETSTATIC-owner handling FrameClassWriter: the superclass walk returned java/lang/Object via isAssignableFromBytes(Object, type2) -- always true -- BEFORE the chainReachesObject incompleteness check, so a merge where type2's chain is broken by a missing intermediate that hides a nearer common base (A extends Base, B extends an absent Missing extends Base) was emitted as Object without being flagged. Stop the walk before Object so reaching it falls through to the incompleteness check; a broken type2 chain is now flagged and the class shipped unhardened. StringEncryptTransform: the GETSTATIC collector recorded the reference owner (C.X), but an inherited field read (GETSTATIC C.X for a field declared by superclass B) must key on the declaring class B.X, which is what encryptStaticFinalStrings checks. Resolve the owner to the field's declaring class through the hierarchy before recording it, so the ConstantValue of an inherited, externally-read constant is preserved rather than migrated. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/FrameClassWriter.java | 7 ++- .../codename1/hardening/HardeningEngine.java | 2 +- .../hardening/StringEncryptTransform.java | 63 ++++++++++++++++++- .../hardening/FrameClassWriterTest.java | 17 +++++ .../hardening/StringEncryptTransformTest.java | 34 ++++++++++ 5 files changed, 119 insertions(+), 4 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java index 7df1f3093b5..a1d6242c252 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java @@ -156,7 +156,12 @@ private String commonSuperFromBytes(String type1, String type2) { } String c = superNameFromBytes(type1); Set seen = new LinkedHashSet(); - while (c != null && seen.add(c)) { + // Stop before Object: isAssignableFromBytes(Object, type2) is unconditionally true, so testing it + // here would return Object as a "found" common super and skip the incompleteness check below -- + // masking the case where type2's chain is broken by a missing intermediate that actually extends a + // nearer base (type1=A extends Base, type2=B extends an absent Missing extends Base). Reaching + // Object in this walk means no CLOSER readable ancestor was found, which is exactly the fall-through. + while (c != null && !"java/lang/Object".equals(c) && seen.add(c)) { if (isAssignableFromBytes(c, type2)) { return c; } diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java index 644964c6023..d767f5fbf6c 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -251,7 +251,7 @@ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, Fi // renamed field names the transform checks. final java.util.Set externallyReadStaticFields = new java.util.HashSet(); for (byte[] cls : renamed.values()) { - StringEncryptTransform.collectGetStaticStringReads(cls, externallyReadStaticFields); + StringEncryptTransform.collectGetStaticStringReads(cls, externallyReadStaticFields, hierarchy); } // Pass 1 (from a snapshot of the input bytes): transform every class, tally the counts, and // collect the values any class could NOT encrypt (a method too full for the decode call, or a diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java index 5b611a810c8..c6c246402d6 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -300,8 +300,14 @@ void setExternallyReadStaticFields(java.util.Set fields) { * {@code GETSTATIC} in {@code classBytes}. A compile-time String constant is normally inlined by * javac/kotlinc, so a surviving {@code GETSTATIC} means a non-inlined read whose declaring field's * {@code ConstantValue} must be preserved rather than moved into {@code }. + * + *

The symbolic owner of a {@code GETSTATIC} may be a SUBCLASS that inherits the field ({@code + * GETSTATIC C.X} for a field {@code X} declared by superclass {@code B} is valid). The transform keys + * on the DECLARING class, so the owner is resolved to it through {@code hierarchy}; if it cannot be + * resolved, the reference owner is recorded as a safe over-approximation. */ - public static void collectGetStaticStringReads(byte[] classBytes, final java.util.Set out) { + public static void collectGetStaticStringReads(byte[] classBytes, final java.util.Set out, + final ClassLoader hierarchy) { new ClassReader(classBytes).accept(new org.objectweb.asm.ClassVisitor(Opcodes.ASM9) { @Override public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, String desc, @@ -310,7 +316,7 @@ public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, Stri @Override public void visitFieldInsn(int opcode, String owner, String fname, String fdesc) { if (opcode == Opcodes.GETSTATIC && "Ljava/lang/String;".equals(fdesc)) { - out.add(owner + "." + fname); + out.add(resolveDeclaringClass(hierarchy, owner, fname) + "." + fname); } } }; @@ -318,6 +324,59 @@ public void visitFieldInsn(int opcode, String owner, String fname, String fdesc) }, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); } + /** + * Walks {@code owner}'s superclass chain through {@code hierarchy} to the class that actually declares + * a static {@code String} field named {@code fieldName} (JVM field resolution binds an inherited + * {@code GETSTATIC} to that class). Returns {@code owner} unchanged when the hierarchy is unavailable + * or the declaring class cannot be read. + */ + private static String resolveDeclaringClass(ClassLoader hierarchy, String owner, final String fieldName) { + if (hierarchy == null) { + return owner; + } + String c = owner; + java.util.Set seen = new java.util.HashSet(); + while (c != null && seen.add(c)) { + final boolean[] declaresHere = {false}; + final String[] superName = {null}; + java.io.InputStream in = hierarchy.getResourceAsStream(c + ".class"); + if (in == null) { + break; + } + try { + new ClassReader(in).accept(new org.objectweb.asm.ClassVisitor(Opcodes.ASM9) { + @Override + public void visit(int v, int a, String name, String sig, String sup, String[] itf) { + superName[0] = sup; + } + + @Override + public org.objectweb.asm.FieldVisitor visitField(int access, String name, String desc, + String sig, Object value) { + if ((access & Opcodes.ACC_STATIC) != 0 && fieldName.equals(name) + && "Ljava/lang/String;".equals(desc)) { + declaresHere[0] = true; + } + return null; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } catch (Throwable t) { + break; + } finally { + try { + in.close(); + } catch (Throwable ignore) { + // best effort + } + } + if (declaresHere[0]) { + return c; + } + c = superName[0]; + } + return owner; + } + /** Count of static-final String constants left plaintext to keep a carried source's compilation valid. */ int getSourcePreservedConstantCount() { return sourcePreservedConstantCount; diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java index 63b4205bd21..d34a11f8c25 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java @@ -110,6 +110,23 @@ public void flagsIncompleteWhenIntermediateSupertypesAreAbsent() { assertTrue("a merge past a missing intermediate must be flagged incomplete", w.isHierarchyIncomplete()); } + @Test + public void flagsIncompleteWhenOneReadableChainHidesANearerBaseBehindAMissingIntermediate() { + // app/A extends app/Base (fully readable to Object); app/B extends an absent app/Missing that in + // reality extends Base. Walking A's chain reaches Object without finding a closer readable ancestor, + // but B's chain is broken at the missing intermediate, so the real common base (Base) is hidden. The + // merge must be flagged incomplete rather than silently returning the too-weak Object -- even though + // A's own chain is intact, the early loop return must not bypass the incomplete-chain check. + java.util.Map res = new java.util.HashMap(); + res.put("app/A.class", classExtending("app/A", "app/Base")); + res.put("app/B.class", classExtending("app/B", "app/Missing")); + res.put("app/Base.class", classExtending("app/Base", "java/lang/Object")); + FrameClassWriter w = new FrameClassWriter(0, new BytesLoader(res)); // app/Missing absent + assertEquals("java/lang/Object", w.getCommonSuperClass("app/A", "app/B")); + assertTrue("a nearer base hidden behind a missing intermediate must flag incomplete", + w.isHierarchyIncomplete()); + } + @Test public void resolvedSharedBaseIsNotFlaggedIncomplete() { // When the byte walk DOES resolve the shared base (A and B both directly extend the absent Base), diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java index e31b7dfe81d..32c7c27bace 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -437,6 +437,40 @@ public void shortStaticFinalConstantIsLeftPlaintextAndDisclosed() throws Excepti StringEncryptTransform.containsStringLiteral(out, "ab")); } + @Test + public void getStaticCollectorResolvesInheritedFieldToDeclaringClass() throws Exception { + // A GETSTATIC app/C.X whose field X is declared by superclass app/B must be recorded as app/B.X -- + // the declaring class the transform keys on -- not the reference owner app/C, or the preservation + // would miss it and B.X's ConstantValue would still be migrated. + int fld = org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL; + org.objectweb.asm.ClassWriter b = new org.objectweb.asm.ClassWriter(0); + b.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/B", null, "java/lang/Object", null); + b.visitField(fld, "X", "Ljava/lang/String;", null, "a declared constant value").visitEnd(); + b.visitEnd(); + org.objectweb.asm.ClassWriter c = new org.objectweb.asm.ClassWriter(0); + c.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/C", null, "app/B", null); + org.objectweb.asm.MethodVisitor m = c.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "m", "()Ljava/lang/String;", null, null); + m.visitCode(); + m.visitFieldInsn(org.objectweb.asm.Opcodes.GETSTATIC, "app/C", "X", "Ljava/lang/String;"); + m.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + c.visitEnd(); + + java.util.Map res = new java.util.HashMap(); + res.put("app/B.class", b.toByteArray()); + res.put("app/C.class", c.toByteArray()); + ClassLoader hierarchy = new ResourceOnlyLoader(res); + java.util.Set out = new java.util.HashSet(); + StringEncryptTransform.collectGetStaticStringReads(c.toByteArray(), out, hierarchy); + assertTrue("inherited GETSTATIC resolves to the declaring class", out.contains("app/B.X")); + assertFalse("the reference owner is not recorded", out.contains("app/C.X")); + } + @Test public void externallyGetStaticReadConstantKeepsItsConstantValue() throws Exception { // A static-final String read by a non-inlined GETSTATIC (generated bytecode) must keep its From 1bf1604ca7a61b3aa5993592cceed508eba55cef Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:23:49 +0700 Subject: [PATCH 101/110] Gate parparvm-text trace classification on a ParparVM-C platform deriveTraceFormat labeled any non-JavaScript raw stack containing a " at X.Y:N" line (no parens) as parparvm-text purely by shape. An Android/desktop (real-JVM) throwable whose MESSAGE contains such a line -- printStackTrace echoes the message into the raw stack -- was then misclassified, letting the server fabricate a frame from message text or drop a real cause trace. Gate the classification on an actual ParparVM-C runtime platform (ios/mac/linux/win); a real-JVM stack now stays none regardless of message contents. The shape check still guards an unexpected stack on a C target. Co-Authored-By: Claude Opus 4.8 --- .../codename1/crash/CrashReportPayload.java | 38 ++++++++++++++----- .../com/codename1/crash/TraceFormatTest.java | 16 ++++++++ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java index 5326bf675db..b92abe8351a 100644 --- a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java +++ b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java @@ -142,21 +142,39 @@ static String deriveTraceFormat(List frames, String rawStack, String plat if (isJavaScriptPlatform(platform)) { return TRACE_JS; } - // A ParparVM frame line is exactly " at .:" -- no '(', URL or '@'. - int at = rawStack.indexOf(" at "); - if (at >= 0) { - int lineEnd = rawStack.indexOf('\n', at); - String body = lineEnd < 0 ? rawStack.substring(at + 7) : rawStack.substring(at + 7, lineEnd); - if (body.indexOf('(') < 0 && body.indexOf('/') < 0 && body.indexOf('@') < 0) { - return TRACE_PARPARVM; + // The " at .:" text is only produced by ParparVM's own printStackTrace on a + // C target. Gate on the platform: an Android/desktop (real-JVM) throwable whose MESSAGE happens to + // contain such a line -- the message is echoed into the raw stack by printStackTrace -- must NOT be + // labeled parparvm-text, or the server would fabricate a frame from message text or drop a real + // cause trace. The shape check still guards against an unexpected stack on a ParparVM-C platform. + if (isParparVmTextPlatform(platform)) { + // A ParparVM frame line is exactly " at .:" -- no '(', URL or '@'. + int at = rawStack.indexOf(" at "); + if (at >= 0) { + int lineEnd = rawStack.indexOf('\n', at); + String body = lineEnd < 0 ? rawStack.substring(at + 7) : rawStack.substring(at + 7, lineEnd); + if (body.indexOf('(') < 0 && body.indexOf('/') < 0 && body.indexOf('@') < 0) { + return TRACE_PARPARVM; + } } } - // Not JavaScript and not the ParparVM text shape: an ordinary JVM printStackTrace body. There - // is no JVM raw parser, so report NONE and let the server keep the text verbatim rather than - // misparsing it as a JavaScript stack. + // Not JavaScript and not a ParparVM-C target's text shape: an ordinary JVM printStackTrace body. + // There is no JVM raw parser, so report NONE and let the server keep the text verbatim rather than + // misparsing it. return TRACE_NONE; } + /// The ParparVM-to-C runtime platforms, whose {@code printStackTrace} emits the + /// " at <fqcn>.<method>:<line>" text. Matches {@link Display#getPlatformName()}; + /// {@code and} (Android) and {@code javase} (desktop) run on a real JVM and are deliberately excluded. + private static boolean isParparVmTextPlatform(String platform) { + if (platform == null) { + return false; + } + String p = platform.toLowerCase(); + return "ios".equals(p) || "mac".equals(p) || "linux".equals(p) || "win".equals(p); + } + /// The JavaScript port's platform name; its raw stack is a JS engine {@code Error().stack}. private static boolean isJavaScriptPlatform(String platform) { if (platform == null) { diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java index a4719882c3f..0c0c144b602 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java @@ -52,6 +52,22 @@ void parparVmTextIsRecognized() { " at com.foo.Bar.baz:42\n at com.foo.Bar.qux:7\n", "ios")); } + @Test + void jvmMessageWithParparvmShapedLineIsNotParparvm() { + // A real-JVM (Android/desktop) throwable whose MESSAGE contains a line shaped like a ParparVM frame + // (" at X.Y:N", no parentheses); printStackTrace echoes the message into the raw stack. The + // platform gate must keep it NONE so the server does not fabricate a frame from message text. + String raw = "java.lang.IllegalStateException: bad input:\n at fake.Type.method:12\n"; + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, raw, "Android")); + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, raw, "SE")); + // The very same raw text on an actual ParparVM-C platform IS parparvm-text -- only the platform + // distinguishes them, which is the point of the gate. + assertEquals(CrashReportPayload.TRACE_PARPARVM, + CrashReportPayload.deriveTraceFormat(null, raw, "win")); + } + @Test void jvmStackIsNotMislabeledJavaScript() { // A stackless JVM/Android throwable whose only frames are in its cause: printStackTrace From d4cc1541b61e70a67539387a38c243e8380e6ca9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:00:00 +0700 Subject: [PATCH 102/110] Distinguish native ParparVM-C from JavaSE by runtime, not platform name JavaSEPort.getPlatformName() returns mac/win for a skinless desktop app (Linux falls through to win), which collide with the native ParparVM-C names, so the platform-name gate still misclassified a JavaSE desktop throwable whose message contained a " at X.Y:N" line as parparvm-text. ParparVM's System.getProperty always returns null while a real JVM sets java.vm.name, so derive the trace format from that runtime signal: anything on a JVM (JavaSE desktop, the simulator, or Android) is never parparvm-text regardless of its displayed name. A package-private 4-arg overload takes runningOnJvm explicitly so a JVM-hosted unit test can still exercise the native path. Co-Authored-By: Claude Opus 4.8 --- .../codename1/crash/CrashReportPayload.java | 41 +++++++++++++------ .../com/codename1/crash/TraceFormatTest.java | 24 +++++++---- 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java index b92abe8351a..dc82ac170a6 100644 --- a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java +++ b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java @@ -133,6 +133,17 @@ final class CrashReportPayload { /// only for the 4-space ParparVM shape and labeled everything else -- including the tab-indented /// JVM trace -- as JavaScript; derive from the platform so that never happens. static String deriveTraceFormat(List frames, String rawStack, String platform) { + // A real JVM sets java.vm.name; ParparVM's System.getProperty always returns null. This is the + // only reliable way to tell a native ParparVM-C target from a JavaSE desktop app or the simulator, + // because JavaSEPort.getPlatformName() returns the SAME mac/win names a native build reports. + return deriveTraceFormat(frames, rawStack, platform, System.getProperty("java.vm.name") != null); + } + + /// The testable core of {@link #deriveTraceFormat(List, String, String)}: {@code runningOnJvm} is + /// supplied explicitly (production derives it from {@code java.vm.name}) so a JVM-hosted unit test can + /// exercise both the native ParparVM-C path and the JavaSE-on-mac/win path. + static String deriveTraceFormat(List frames, String rawStack, String platform, + boolean runningOnJvm) { if (frames != null && !frames.isEmpty()) { return TRACE_STRUCTURED; } @@ -143,11 +154,12 @@ static String deriveTraceFormat(List frames, String rawStack, String plat return TRACE_JS; } // The " at .:" text is only produced by ParparVM's own printStackTrace on a - // C target. Gate on the platform: an Android/desktop (real-JVM) throwable whose MESSAGE happens to - // contain such a line -- the message is echoed into the raw stack by printStackTrace -- must NOT be - // labeled parparvm-text, or the server would fabricate a frame from message text or drop a real - // cause trace. The shape check still guards against an unexpected stack on a ParparVM-C platform. - if (isParparVmTextPlatform(platform)) { + // native C target. Gate on the runtime: an Android/desktop/simulator (real-JVM) throwable whose + // MESSAGE happens to contain such a line -- the message is echoed into the raw stack by + // printStackTrace -- must NOT be labeled parparvm-text, or the server would fabricate a frame from + // message text or drop a real cause trace. The shape check still guards an unexpected stack on a + // ParparVM-C target. + if (isParparVmTextPlatform(platform, runningOnJvm)) { // A ParparVM frame line is exactly " at .:" -- no '(', URL or '@'. int at = rawStack.indexOf(" at "); if (at >= 0) { @@ -158,17 +170,20 @@ static String deriveTraceFormat(List frames, String rawStack, String plat } } } - // Not JavaScript and not a ParparVM-C target's text shape: an ordinary JVM printStackTrace body. - // There is no JVM raw parser, so report NONE and let the server keep the text verbatim rather than - // misparsing it. + // Not JavaScript and not a native ParparVM-C target's text shape: an ordinary JVM printStackTrace + // body. There is no JVM raw parser, so report NONE and let the server keep the text verbatim rather + // than misparsing it. return TRACE_NONE; } - /// The ParparVM-to-C runtime platforms, whose {@code printStackTrace} emits the - /// " at <fqcn>.<method>:<line>" text. Matches {@link Display#getPlatformName()}; - /// {@code and} (Android) and {@code javase} (desktop) run on a real JVM and are deliberately excluded. - private static boolean isParparVmTextPlatform(String platform) { - if (platform == null) { + /// True only on a native ParparVM-to-C runtime, whose {@code printStackTrace} emits the + /// " at <fqcn>.<method>:<line>" text. The displayed platform name is NOT enough: + /// a skinless JavaSE desktop app returns {@code mac}/{@code win} (Linux falls through to {@code win}) + /// and the simulator can return {@code ios}, all colliding with the native names. So anything running + /// on a real JVM ({@code runningOnJvm}: JavaSE desktop, the simulator, or Android) is excluded, and of + /// the remaining native runtimes only the C-target names qualify. + private static boolean isParparVmTextPlatform(String platform, boolean runningOnJvm) { + if (runningOnJvm || platform == null) { return false; } String p = platform.toLowerCase(); diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java index 0c0c144b602..b70ff00045e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java @@ -47,25 +47,31 @@ void javaScriptPortIsJsError() { @Test void parparVmTextIsRecognized() { + // A native ParparVM-C target (not running on a JVM) with the " at X.Y:N" text. assertEquals(CrashReportPayload.TRACE_PARPARVM, CrashReportPayload.deriveTraceFormat(null, - " at com.foo.Bar.baz:42\n at com.foo.Bar.qux:7\n", "ios")); + " at com.foo.Bar.baz:42\n at com.foo.Bar.qux:7\n", "ios", false)); } @Test void jvmMessageWithParparvmShapedLineIsNotParparvm() { - // A real-JVM (Android/desktop) throwable whose MESSAGE contains a line shaped like a ParparVM frame - // (" at X.Y:N", no parentheses); printStackTrace echoes the message into the raw stack. The - // platform gate must keep it NONE so the server does not fabricate a frame from message text. + // A real-JVM throwable whose MESSAGE contains a line shaped like a ParparVM frame (" at X.Y:N", + // no parentheses); printStackTrace echoes the message into the raw stack. On a JVM it must stay + // NONE so the server does not fabricate a frame from message text. String raw = "java.lang.IllegalStateException: bad input:\n at fake.Type.method:12\n"; assertEquals(CrashReportPayload.TRACE_NONE, - CrashReportPayload.deriveTraceFormat(null, raw, "Android")); + CrashReportPayload.deriveTraceFormat(null, raw, "Android", true)); assertEquals(CrashReportPayload.TRACE_NONE, - CrashReportPayload.deriveTraceFormat(null, raw, "SE")); - // The very same raw text on an actual ParparVM-C platform IS parparvm-text -- only the platform - // distinguishes them, which is the point of the gate. + CrashReportPayload.deriveTraceFormat(null, raw, "SE", true)); + // A skinless JavaSE desktop app on macOS/Windows reports mac/win yet runs on a JVM -- it must still + // be NONE, not parparvm, even though the name collides with the native C-target names. + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, raw, "mac", true)); + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, raw, "win", true)); + // The same raw text on a NATIVE ParparVM-C target (not on a JVM) IS parparvm-text. assertEquals(CrashReportPayload.TRACE_PARPARVM, - CrashReportPayload.deriveTraceFormat(null, raw, "win")); + CrashReportPayload.deriveTraceFormat(null, raw, "win", false)); } @Test From fba9418fcf7b1d7f006e0f7db541975bc3bee6e2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:13:48 +0700 Subject: [PATCH 103/110] Accept a verifier report whose TEXT names only a missing type OutputVerifier handled the case where CheckClassAdapter.verify THROWS on an absent target-only type, but ASM sometimes catches that failure internally and prints its stack trace to the report instead. A non-empty report was then rejected, so a class whose frame analysis needs an unavailable platform superclass could still fail hardening. Detect the unresolved-type text in the report (ClassNotFoundException / TypeNotPresentException / NoClassDefFoundError / " not present") and take the structural fallback, mirroring BytecodeComplianceMojo's isUnresolvableTypeOutput. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/OutputVerifier.java | 21 +++++++++- .../hardening/OutputVerifierTest.java | 38 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java index 05d8d3562e8..42271d2603e 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java @@ -51,8 +51,9 @@ public static void verify(Map classesByInternalName, ClassLoader throws HardeningException { for (Map.Entry e : classesByInternalName.entrySet()) { StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); try { - CheckClassAdapter.verify(new ClassReader(e.getValue()), hierarchy, false, new PrintWriter(sw)); + CheckClassAdapter.verify(new ClassReader(e.getValue()), hierarchy, false, pw); } catch (Throwable t) { if (isUnresolvedTypeFailure(t)) { // ASM's data-flow SimpleVerifier LOADS types to resolve the hierarchy and threw @@ -67,14 +68,32 @@ public static void verify(Map classesByInternalName, ClassLoader throw new HardeningException("Hardened class '" + e.getKey() + "' failed bytecode verification: " + t.getMessage(), t); } + pw.flush(); String report = sw.toString(); if (report.length() > 0) { + if (isUnresolvedTypeReport(report)) { + // Depending on where the load fails, CheckClassAdapter.verify does NOT throw but + // catches the missing-type failure internally and prints its stack trace to the report. + // That is the same absent-target-type case as the catch above (a superclass supplied + // only by the target platform), not a bytecode defect, so take the structural fallback + // instead of rejecting a valid class. Mirrors BytecodeComplianceMojo's report-text check. + verifyStructureOnly(e.getKey(), e.getValue()); + continue; + } throw new HardeningException("Hardened class '" + e.getKey() + "' failed bytecode verification:\n" + report); } } } + /** True when a verifier report's TEXT names only a missing type (ASM printed it instead of throwing). */ + private static boolean isUnresolvedTypeReport(String report) { + return report.contains("ClassNotFoundException") + || report.contains("TypeNotPresentException") + || report.contains("NoClassDefFoundError") + || report.contains(" not present"); + } + /** * Structural verification only (no data-flow, so no type loading): checks the class-file structure -- * visit order, valid access flags, names and descriptors. Used as the fallback when the data-flow diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.java index bde6507f5ea..333aa4051cb 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.java @@ -61,6 +61,44 @@ public void structurallyValidClassWithResolvableHierarchyStillPasses() throws Ex OutputVerifier.verify(classes, getClass().getClassLoader()); // must not throw } + @Test + public void unresolvedTypeInsideAMethodBodyIsAcceptedNotRejected() throws Exception { + // app/User.make() returns a new instance of an absent app/Missing. Depending on where the load + // fails, CheckClassAdapter's data-flow analyzer PRINTS the missing-type failure to the report + // instead of throwing; either way the class must be accepted via the structural fallback, not + // rejected as invalid bytecode. + byte[] u = classReturningNewInstanceOf("app/User", "app/Missing"); + Map classes = new LinkedHashMap(); + classes.put("app/User", u); + Map resources = new HashMap(); + resources.put("app/User.class", u); + OutputVerifier.verify(classes, new BytesLoader(resources)); // app/Missing absent; must not throw + } + + /** A class with a method {@code static make()} that returns {@code new ()}. */ + private static byte[] classReturningNewInstanceOf(String internal, String absentType) { + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internal, null, "java/lang/Object", null); + MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + ctor.visitCode(); + ctor.visitVarInsn(Opcodes.ALOAD, 0); + ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + ctor.visitInsn(Opcodes.RETURN); + ctor.visitMaxs(1, 1); + ctor.visitEnd(); + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "make", + "()L" + absentType + ";", null, null); + mv.visitCode(); + mv.visitTypeInsn(Opcodes.NEW, absentType); + mv.visitInsn(Opcodes.DUP); + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, absentType, "", "()V", false); + mv.visitInsn(Opcodes.ARETURN); + mv.visitMaxs(2, 0); + mv.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + private static byte[] classWithSuperCtor(String internal, String superName) { ClassWriter cw = new ClassWriter(0); cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internal, null, superName, null); From 4124a5e38ee5d908591e68fd9b39b97ca9710bf0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:13:49 +0700 Subject: [PATCH 104/110] Read retrace mapping files as UTF-8, not the platform default The standalone retrace CLI opened mapping files with FileReader (platform default charset), corrupting Unicode class/method/source-file names on a non-UTF-8 host such as Windows Java 8. The rest of the pipeline treats mappings as UTF-8 (MappingWriter reads/writes UTF-8), so read them with an explicit UTF-8 reader for consistent symbolication across hosts. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/com/codename1/retrace/RetraceMain.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/maven/cn1-retrace/src/main/java/com/codename1/retrace/RetraceMain.java b/maven/cn1-retrace/src/main/java/com/codename1/retrace/RetraceMain.java index afc1b3b864f..8e13c751a6f 100644 --- a/maven/cn1-retrace/src/main/java/com/codename1/retrace/RetraceMain.java +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/RetraceMain.java @@ -24,7 +24,7 @@ import java.io.BufferedReader; import java.io.File; -import java.io.FileReader; +import java.io.FileInputStream; import java.io.InputStreamReader; import java.nio.charset.Charset; import java.util.ArrayList; @@ -79,7 +79,11 @@ private static MappingChain loadMappings(String[] args) throws Exception { List files = new ArrayList(); for (int i = 0; i < args.length - 1; i++) { if ("--mapping".equals(args[i])) { - FileReader fr = new FileReader(new File(args[i + 1])); + // Read the mapping as UTF-8 explicitly, not the platform default charset (which would + // corrupt Unicode class/method/source-file names on a non-UTF-8 host such as Windows Java + // 8). The rest of the pipeline -- MappingWriter.injectSourceFiles -- also reads/writes UTF-8. + InputStreamReader fr = new InputStreamReader( + new FileInputStream(new File(args[i + 1])), Charset.forName("UTF-8")); try { files.add(MappingFile.parse(fr)); } finally { From ffe1519a3c247ee3f0d19ed53eea9088731b9c0f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:21:23 +0700 Subject: [PATCH 105/110] docs: correct scrubRawStack javadoc to match uniform masking The opening paragraph still described the old "mask only non-frame lines" behavior and promised a minified-JS coordinate like app.js:1:123456 survives, contradicting the accurate explanation later in the same javadoc. scrubRawStack now routes every line through scrubMessage, so document the uniform masking and its loss of large columns (short line numbers still survive; precise coordinates come from structured frames). No behavior change. Co-Authored-By: Claude Opus 4.8 --- .../src/com/codename1/crash/PiiScrubber.java | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java index f77f3876d94..3636c7666b3 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -83,15 +83,17 @@ public String scrubFrame(String className, String methodName) { /// the JavaScript port it is the engine's `Error().stack`. A stricter /// application can override this to redact aggressively. /// - /// The default scrubs emails everywhere, but applies long-digit-run masking - /// only to non-frame lines. A frame/location line carries no PII -- it is - /// class, method, file and line/column text -- and its numbers are exactly - /// what the server needs to symbolicate. In particular a minified - /// JavaScript bundle is often one line, so a `Error().stack` frame reads - /// `app.js:1:123456` where the six-plus-digit column would otherwise be - /// masked to `[num]`, destroying the location. Free-form lines (the leading - /// `ExceptionClass: message` line and any non-frame text) are still scrubbed, - /// since a message can carry a phone number or long id. + /// The default scrubs emails everywhere and applies long-digit-run masking + /// UNIFORMLY to every line, frame-shaped or not. It does not try to preserve + /// a frame's line/column: `printStackTrace` writes the exception message + /// verbatim, and a message can embed an indented, frame-shaped line that is + /// indistinguishable from a real frame -- preserving a "coordinate" from such + /// a line would let a crafted `:line:column` tail smuggle a long id past the + /// digit masking. `scrubMessage` masks only 6-or-more-digit runs, so ordinary + /// short line numbers survive, but a large minified-JavaScript column such as + /// `app.js:1:123456` is masked to `app.js:1:[num]`. That loses the column for + /// this text form; precise coordinates for symbolication come from the + /// structured frames (real `StackTraceElement`s), not this scrubbed string. /// /// #### Parameters /// From df5a3b61877917c9d1e2560ad5daa2a7c1e06da1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:57:51 +0700 Subject: [PATCH 106/110] Run hardening preflight before the Android up-to-date cache short-circuit The Android APK cache check (skip build when the APK is newer than the sources) returned before applyHardeningPreflight() ran, and getSourcesModificationTime() keys only on source timestamps -- not build hints -- so an explicit hardening request, especially one made via a -D command-line property, was silently dropped and the stale, potentially unhardened APK returned with no error. Move the preflight ahead of the cache check and overlay -D command-line hints in the early preflight so the request is seen, validated, and refused if unsupported rather than silently ignored. Co-Authored-By: Claude Opus 4.8 --- .../java/com/codename1/maven/CN1BuildMojo.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 7544675a203..b28481b2677 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -147,6 +147,14 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException return; } + // Run the hardening pre-flight BEFORE the Android up-to-date cache short-circuit below. The cache + // check keys only on source-file timestamps (getSourcesModificationTime), not on build hints, so an + // explicit hardening request -- especially one made via a -D command-line property -- would + // otherwise return a stale, potentially unhardened APK without the pre-flight ever validating (or + // refusing) it. Checking here means an invalid/unsupported hardening request fails loudly instead + // of silently succeeding with the cached artifact. + applyHardeningPreflight(); + if (platform.contains("android")) { if (!BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { String apkName = project.getBuild().getFinalName() + ".apk"; @@ -162,8 +170,6 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException } } - applyHardeningPreflight(); - try { createAntProject(); } catch (IOException ex) { @@ -214,6 +220,11 @@ private void applyHardeningPreflight() throws MojoFailureException { getLog().debug("Could not read codenameone_settings.properties for hardening pre-flight", ex); } } + // Overlay -D command-line hints (e.g. -Dcodename1.arg.harden.level=standard) so an explicit + // hardening request made only on the command line is seen by this early check -- and, because this + // runs before the Android up-to-date cache short-circuit, is not silently dropped when a prior APK + // is newer than the sources (getSourcesModificationTime does not account for build hints). + overlayCommandLineBuildHints(settings); applyHardeningPreflight(settings); } From 8b6485ffc185b4398ad31aab7833cf7a9258fa2f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:57:51 +0700 Subject: [PATCH 107/110] Verify data flow per method so a real error survives a missing-type peer OutputVerifier classified an entire CheckClassAdapter report as an unresolved-type failure by substring, but ASM can append both a missing-type diagnostic (one method) and a genuine analyzer error (another method) to the same report; the structural fallback then discarded the real error and shippable invalid bytecode. Verify structure once, then run SimpleVerifier data-flow PER METHOD: a method that fails only because a target-only type is absent is tolerated, while any other analyzer failure fails the build, naming the method. Co-Authored-By: Claude Opus 4.8 --- .../codename1/hardening/OutputVerifier.java | 96 +++++++++++-------- .../hardening/OutputVerifierTest.java | 48 ++++++++++ 2 files changed, 105 insertions(+), 39 deletions(-) diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java index 42271d2603e..9ca890eff48 100644 --- a/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java @@ -22,18 +22,28 @@ */ package com.codename1.hardening; -import java.io.PrintWriter; -import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.analysis.Analyzer; +import org.objectweb.asm.tree.analysis.BasicValue; +import org.objectweb.asm.tree.analysis.SimpleVerifier; import org.objectweb.asm.util.CheckClassAdapter; /** * Verifies every class the engine is about to ship. A transform bug that produces - * invalid bytecode must fail the build here, not at first launch on a device: the - * same {@code CheckClassAdapter} data-flow verification the framework already uses - * elsewhere is run over each output class. + * invalid bytecode must fail the build here, not at first launch on a device. + * Each class gets a structural check ({@code CheckClassAdapter}, no type loading) + * plus per-method data-flow analysis ({@code SimpleVerifier}); the data-flow pass + * runs method-by-method so a method that references a target-only type absent from + * the supplied jars can be tolerated without masking a genuine error elsewhere in + * the same class. */ public final class OutputVerifier { @@ -50,50 +60,58 @@ private OutputVerifier() { public static void verify(Map classesByInternalName, ClassLoader hierarchy) throws HardeningException { for (Map.Entry e : classesByInternalName.entrySet()) { - StringWriter sw = new StringWriter(); - PrintWriter pw = new PrintWriter(sw); + // Structural checks first (visit order, access flags, names/descriptors); these load no types, + // so a structurally-invalid transform output is always caught. + verifyStructureOnly(e.getKey(), e.getValue()); + // Then data-flow, PER METHOD, rather than through CheckClassAdapter.verify's whole-class report. + // A method that fails only because an absent target-only type cannot be resolved is tolerated, + // but WITHOUT masking a genuine data-flow error in another method of the same class: ASM can + // append both diagnostics to one report, where a substring check would misclassify the whole + // report as an unresolved-type failure and discard the real error. + verifyDataFlow(e.getKey(), e.getValue(), hierarchy); + } + } + + /** + * Data-flow verification, one method at a time, so the missing-type tolerance is scoped to the method + * that actually references the absent type. A method whose analysis fails only because a type is absent + * from the supplied jars is accepted (FrameClassWriter computed its frames from the bytes and the + * target JVM verifies it on-device); any other analyzer failure fails the build, naming the method. + */ + private static void verifyDataFlow(String name, byte[] classBytes, ClassLoader hierarchy) + throws HardeningException { + ClassNode cn = new ClassNode(); + new ClassReader(classBytes).accept(cn, 0); + Type currentClass = Type.getObjectType(cn.name); + Type superClass = cn.superName == null ? null : Type.getObjectType(cn.superName); + List interfaces = new ArrayList(); + if (cn.interfaces != null) { + for (String i : cn.interfaces) { + interfaces.add(Type.getObjectType(i)); + } + } + boolean isInterface = (cn.access & Opcodes.ACC_INTERFACE) != 0; + if (cn.methods == null) { + return; + } + for (MethodNode m : cn.methods) { + if (m.instructions == null || m.instructions.size() == 0) { + continue; // abstract or native: no body to analyze + } + SimpleVerifier verifier = new SimpleVerifier(currentClass, superClass, interfaces, isInterface); + verifier.setClassLoader(hierarchy); try { - CheckClassAdapter.verify(new ClassReader(e.getValue()), hierarchy, false, pw); + new Analyzer(verifier).analyze(cn.name, m); } catch (Throwable t) { if (isUnresolvedTypeFailure(t)) { - // ASM's data-flow SimpleVerifier LOADS types to resolve the hierarchy and threw - // because one is absent from the supplied jars -- typically an application class whose - // superclass is supplied only by the target platform. That is not a bytecode defect: - // FrameClassWriter already computed this class's frames from the bytes, and the target - // JVM verifies it on-device. Fall back to structural verification here, which needs no - // hierarchy, so a transform that emitted structurally invalid bytecode is still caught. - verifyStructureOnly(e.getKey(), e.getValue()); continue; } - throw new HardeningException("Hardened class '" + e.getKey() + throw new HardeningException("Hardened class '" + name + "' method '" + m.name + m.desc + "' failed bytecode verification: " + t.getMessage(), t); } - pw.flush(); - String report = sw.toString(); - if (report.length() > 0) { - if (isUnresolvedTypeReport(report)) { - // Depending on where the load fails, CheckClassAdapter.verify does NOT throw but - // catches the missing-type failure internally and prints its stack trace to the report. - // That is the same absent-target-type case as the catch above (a superclass supplied - // only by the target platform), not a bytecode defect, so take the structural fallback - // instead of rejecting a valid class. Mirrors BytecodeComplianceMojo's report-text check. - verifyStructureOnly(e.getKey(), e.getValue()); - continue; - } - throw new HardeningException("Hardened class '" + e.getKey() - + "' failed bytecode verification:\n" + report); - } } } - /** True when a verifier report's TEXT names only a missing type (ASM printed it instead of throwing). */ - private static boolean isUnresolvedTypeReport(String report) { - return report.contains("ClassNotFoundException") - || report.contains("TypeNotPresentException") - || report.contains("NoClassDefFoundError") - || report.contains(" not present"); - } - /** * Structural verification only (no data-flow, so no type loading): checks the class-file structure -- * visit order, valid access flags, names and descriptors. Used as the fallback when the data-flow diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.java index 333aa4051cb..710cadf00d9 100644 --- a/maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.java +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.java @@ -75,6 +75,54 @@ public void unresolvedTypeInsideAMethodBodyIsAcceptedNotRejected() throws Except OutputVerifier.verify(classes, new BytesLoader(resources)); // app/Missing absent; must not throw } + @Test + public void genuineErrorIsNotMaskedByAMissingTypeInAnotherMethod() throws Exception { + // One method references an absent type (tolerable); another has a genuine data-flow error (returns + // an int where a reference is required). Per-method verification must still REJECT the class -- the + // missing-type tolerance is scoped to the offending method and must not swallow the real error. + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "app/Mixed", null, "java/lang/Object", null); + MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + ctor.visitCode(); + ctor.visitVarInsn(Opcodes.ALOAD, 0); + ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + ctor.visitInsn(Opcodes.RETURN); + ctor.visitMaxs(1, 1); + ctor.visitEnd(); + // Tolerable: references an absent type. + MethodVisitor good = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "make", + "()Lapp/Missing;", null, null); + good.visitCode(); + good.visitTypeInsn(Opcodes.NEW, "app/Missing"); + good.visitInsn(Opcodes.DUP); + good.visitMethodInsn(Opcodes.INVOKESPECIAL, "app/Missing", "", "()V", false); + good.visitInsn(Opcodes.ARETURN); + good.visitMaxs(2, 0); + good.visitEnd(); + // Genuine defect: returns an int where an object reference is required. + MethodVisitor bad = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "bad", + "()Ljava/lang/Object;", null, null); + bad.visitCode(); + bad.visitInsn(Opcodes.ICONST_1); + bad.visitInsn(Opcodes.ARETURN); + bad.visitMaxs(1, 0); + bad.visitEnd(); + cw.visitEnd(); + byte[] mixed = cw.toByteArray(); + + Map classes = new LinkedHashMap(); + classes.put("app/Mixed", mixed); + Map resources = new HashMap(); + resources.put("app/Mixed.class", mixed); + boolean rejected = false; + try { + OutputVerifier.verify(classes, new BytesLoader(resources)); + } catch (HardeningException expected) { + rejected = true; + } + org.junit.Assert.assertTrue("a genuine data-flow error must still be rejected", rejected); + } + /** A class with a method {@code static make()} that returns {@code new ()}. */ private static byte[] classReturningNewInstanceOf(String internal, String absentType) { ClassWriter cw = new ClassWriter(0); From 760804329f50cbcb0301b8ac41147c75b0150053 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:10:01 +0700 Subject: [PATCH 108/110] Bypass the Android up-to-date cache when hardening will actually run Moving the pre-flight ahead of the cache check validated the hardening request but did not invalidate the cache, so an off->standard change made through a -D hint (which getSourcesModificationTime cannot see) still returned the older, possibly unhardened APK. Record whether the pre-flight resolved that hardening will run (non-off level and not force-off) and skip the source-timestamp cache short-circuit in that case, forcing a rebuild so the request is honored. When hardening is off / force-off / opted out, the cache still applies. Co-Authored-By: Claude Opus 4.8 --- .../java/com/codename1/maven/CN1BuildMojo.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index b28481b2677..3d408839ab1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -160,7 +160,12 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException String apkName = project.getBuild().getFinalName() + ".apk"; File apkFile = new File(project.getBuild().getDirectory() + File.separator + apkName); try { - if (apkFile.exists() && apkFile.lastModified() >= getSourcesModificationTime()) { + // The up-to-date check is source-timestamp only, so it cannot see a hardening request + // that arrived via a build hint (e.g. -Dcodename1.arg.harden.level=standard). When the + // pre-flight above resolved that hardening WILL run, never reuse a cached APK -- it may + // have been built unhardened -- and rebuild so the request is honored. + if (!hardeningWillRun && apkFile.exists() + && apkFile.lastModified() >= getSourcesModificationTime()) { getLog().info("Sources have not been modified since APK at " + apkFile + " was created. Skipping Android build"); return; } @@ -199,6 +204,10 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException */ private boolean hardeningForceOff; private String hardeningLibraryJars; + /** True once the pre-flight has resolved that hardening will ACTUALLY run for this build (non-off, + * not force-off): such a build must not be served the timestamp-only up-to-date cache, whose staleness + * check ignores build hints, or an off->standard request could reuse a previously-built unhardened APK. */ + private boolean hardeningWillRun; /** Injects the pre-flight hardening decisions into this build's request (per-build, not global). */ private void applyHardeningRequestArgs(BuildRequest r) { @@ -288,10 +297,11 @@ private void applyHardeningPreflight(Properties settings) throws MojoFailureExce } else { hardeningForceOff = false; } + hardeningWillRun = !"off".equalsIgnoreCase(level.trim()) && !r.isForceOff(); // Publish the compile classpath so the hardening engine can hand it to ProGuard as library // jars (so an application method that overrides a framework method is not renamed apart from // its superclass). Only needed when hardening will actually run. - if (!"off".equalsIgnoreCase(level.trim()) && !r.isForceOff()) { + if (hardeningWillRun) { try { List cp = project.getCompileClasspathElements(); StringBuilder sb = new StringBuilder(); From 4684e09cf504cd662a7d9de02037bd05753443ec Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:22:36 +0700 Subject: [PATCH 109/110] Invalidate the Android APK cache on any hardening-outcome change The previous guard only bypassed the up-to-date cache for off->on; disabling hardening (on->off, opting Android out, or turning every transform off) left hardeningWillRun false, so the cache returned the old HARDENED APK -- an artifact contradicting the current config. Record the hardening OUTCOME the APK was built with ("unhardened" or "hardened:") in a marker beside the APK, and treat the cache as up-to-date only when that marker still matches. A change in either direction now rebuilds, while two unhardened (or identical-level) invocations still hit the cache. Co-Authored-By: Claude Opus 4.8 --- .../com/codename1/maven/CN1BuildMojo.java | 75 ++++++++++++++++--- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 3d408839ab1..4f262a2b918 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -157,14 +157,16 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException if (platform.contains("android")) { if (!BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { - String apkName = project.getBuild().getFinalName() + ".apk"; - File apkFile = new File(project.getBuild().getDirectory() + File.separator + apkName); + File apkFile = androidApkFile(); try { - // The up-to-date check is source-timestamp only, so it cannot see a hardening request - // that arrived via a build hint (e.g. -Dcodename1.arg.harden.level=standard). When the - // pre-flight above resolved that hardening WILL run, never reuse a cached APK -- it may - // have been built unhardened -- and rebuild so the request is honored. - if (!hardeningWillRun && apkFile.exists() + // Up-to-date only when the APK is newer than the sources AND was built with the SAME + // hardening outcome, recorded in the marker beside it. The source-timestamp check cannot + // see a hardening change made through a build hint, so a change in EITHER direction -- + // enabling hardening (a stale unhardened APK) or disabling it (a stale hardened APK) -- + // must invalidate the cache and rebuild, rather than publish an APK that contradicts the + // current configuration. + if (apkFile.exists() + && hardeningCacheKey.equals(readTextFileOrNull(androidHardeningCacheMarker())) && apkFile.lastModified() >= getSourcesModificationTime()) { getLog().info("Sources have not been modified since APK at " + apkFile + " was created. Skipping Android build"); return; @@ -184,6 +186,53 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException getLog().error("Failed to merge properties from library "+ex.libName+". " + ex.getMessage()); throw new MojoExecutionException("Failed to merge properties from library "+ex.libName+". " + ex.getMessage(), ex); } + + // Record the hardening outcome this APK was built with, so a later invocation that changes + // hardening (in either direction) invalidates the timestamp-only up-to-date cache above. + if (platform.contains("android") && !BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { + File marker = androidHardeningCacheMarker(); + if (androidApkFile().exists()) { + try { + writeStringToFile(marker, hardeningCacheKey); + } catch (IOException ex) { + getLog().debug("Could not record the hardening cache key at " + marker, ex); + } + } + } + } + + /** The Android APK this build produces (also the cache key's anchor). */ + private File androidApkFile() { + String apkName = project.getBuild().getFinalName() + ".apk"; + return new File(project.getBuild().getDirectory() + File.separator + apkName); + } + + /** The marker recording the hardening outcome the cached APK was built with, beside the APK. */ + private File androidHardeningCacheMarker() { + File apk = androidApkFile(); + return new File(apk.getParentFile(), apk.getName() + ".cn1hardenkey"); + } + + /** Reads a small text file's trimmed content, or {@code null} if it is absent or unreadable. */ + private static String readTextFileOrNull(File f) { + if (f == null || !f.isFile()) { + return null; + } + try { + return new String(java.nio.file.Files.readAllBytes(f.toPath()), + java.nio.charset.Charset.forName("UTF-8")).trim(); + } catch (IOException ex) { + return null; + } + } + + /** Writes {@code content} to {@code f} (UTF-8), creating parent directories as needed. */ + private static void writeStringToFile(File f, String content) throws IOException { + if (f.getParentFile() != null) { + f.getParentFile().mkdirs(); + } + java.nio.file.Files.write(f.toPath(), + content.getBytes(java.nio.charset.Charset.forName("UTF-8"))); } /** @@ -205,9 +254,13 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException private boolean hardeningForceOff; private String hardeningLibraryJars; /** True once the pre-flight has resolved that hardening will ACTUALLY run for this build (non-off, - * not force-off): such a build must not be served the timestamp-only up-to-date cache, whose staleness - * check ignores build hints, or an off->standard request could reuse a previously-built unhardened APK. */ + * not force-off). Used to publish the library classpath the engine needs. */ private boolean hardeningWillRun; + /** A fingerprint of the hardening OUTCOME for this build ("unhardened", or "hardened:<level>"), + * recorded next to the Android APK so the timestamp-only up-to-date cache -- which cannot see a + * hardening change made through a build hint -- is invalidated when the outcome changes in EITHER + * direction (enabling or disabling hardening), not just off->on. */ + private String hardeningCacheKey = "unhardened"; /** Injects the pre-flight hardening decisions into this build's request (per-build, not global). */ private void applyHardeningRequestArgs(BuildRequest r) { @@ -298,6 +351,10 @@ private void applyHardeningPreflight(Properties settings) throws MojoFailureExce hardeningForceOff = false; } hardeningWillRun = !"off".equalsIgnoreCase(level.trim()) && !r.isForceOff(); + // The APK's hardening OUTCOME: an unhardened build has one key regardless of the nominal level, so + // two off/force-off invocations still hit the cache; a hardened build keys on the level so a + // standard->paranoid change also rebuilds. Compared against the marker recorded beside the APK. + hardeningCacheKey = hardeningWillRun ? "hardened:" + level.trim().toLowerCase() : "unhardened"; // Publish the compile classpath so the hardening engine can hand it to ProGuard as library // jars (so an application method that overrides a framework method is not renamed apart from // its superclass). Only needed when hardening will actually run. From bf5ea648372771289f22d1bfeabd17f58fb3b212 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:34:58 +0700 Subject: [PATCH 110/110] Fingerprint all effective harden.* settings in the APK cache key The marker keyed only on harden.level, so changing harden.strings, harden.rename, harden.controlFlow, harden.keep or harden.seed without changing the level left the key identical (both aggressive builds were "hardened:aggressive") and the timestamp cache reused an APK built with the previous transforms and mapping. Fingerprint every effective codename1.arg.harden.* setting (sorted, SHA-256) into the hardened key so any transform/keep/seed change invalidates the cache; an unhardened build keeps its single key. Co-Authored-By: Claude Opus 4.8 --- .../com/codename1/maven/CN1BuildMojo.java | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 4f262a2b918..b25bc9cd08a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -213,6 +213,41 @@ private File androidHardeningCacheMarker() { return new File(apk.getParentFile(), apk.getName() + ".cn1hardenkey"); } + /** + * A stable fingerprint of every effective {@code codename1.arg.harden.*} setting, so a transform, + * keep-rule or seed change -- not only a level change -- invalidates the APK cache. Keys are sorted so + * the fingerprint is order-independent. + */ + private static String hardeningSettingsFingerprint(Properties settings) { + java.util.TreeMap hints = new java.util.TreeMap(); + for (String key : settings.stringPropertyNames()) { + if (key.startsWith("codename1.arg.harden.")) { + hints.put(key, settings.getProperty(key)); + } + } + StringBuilder sb = new StringBuilder(); + for (java.util.Map.Entry e : hints.entrySet()) { + sb.append(e.getKey()).append('=').append(e.getValue()).append('\n'); + } + return sha256Hex(sb.toString()); + } + + /** SHA-256 of {@code s} as lowercase hex (falls back to the string hash if SHA-256 is somehow absent). */ + private static String sha256Hex(String s) { + try { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); + byte[] d = md.digest(s.getBytes(java.nio.charset.Charset.forName("UTF-8"))); + StringBuilder sb = new StringBuilder(d.length * 2); + for (byte b : d) { + sb.append(Character.forDigit((b >> 4) & 0xf, 16)); + sb.append(Character.forDigit(b & 0xf, 16)); + } + return sb.toString(); + } catch (java.security.NoSuchAlgorithmException ex) { + return Integer.toHexString(s.hashCode()); + } + } + /** Reads a small text file's trimmed content, or {@code null} if it is absent or unreadable. */ private static String readTextFileOrNull(File f) { if (f == null || !f.isFile()) { @@ -351,10 +386,14 @@ private void applyHardeningPreflight(Properties settings) throws MojoFailureExce hardeningForceOff = false; } hardeningWillRun = !"off".equalsIgnoreCase(level.trim()) && !r.isForceOff(); - // The APK's hardening OUTCOME: an unhardened build has one key regardless of the nominal level, so - // two off/force-off invocations still hit the cache; a hardened build keys on the level so a - // standard->paranoid change also rebuilds. Compared against the marker recorded beside the APK. - hardeningCacheKey = hardeningWillRun ? "hardened:" + level.trim().toLowerCase() : "unhardened"; + // The APK's hardening OUTCOME. An unhardened build has one key regardless of the nominal level, so + // two off/force-off invocations still hit the cache. A hardened build fingerprints EVERY effective + // harden.* setting -- not just the level -- because harden.strings/rename/controlFlow/keep/seed all + // change the produced transforms and mapping, so two hardened:aggressive builds with different seeds + // or keep rules must still invalidate the cache. Compared against the marker recorded beside the APK. + hardeningCacheKey = hardeningWillRun + ? "hardened:" + hardeningSettingsFingerprint(settings) + : "unhardened"; // Publish the compile classpath so the hardening engine can hand it to ProGuard as library // jars (so an application method that overrides a framework method is not renamed apart from // its superclass). Only needed when hardening will actually run.