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 01/26] 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 02/26] 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 03/26] 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 04/26] 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 05/26] 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 06/26] 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 07/26] 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 08/26] 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 09/26] 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 10/26] 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 11/26] 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 12/26] 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 13/26] 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 15/26] 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 16/26] 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 17/26] 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 18/26] 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 19/26] 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 20/26] 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 21/26] 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 22/26] 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 23/26] 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 24/26] 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 25/26] 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 26/26] 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",