diff --git a/CodenameOne/src/com/codename1/crash/CrashProtection.java b/CodenameOne/src/com/codename1/crash/CrashProtection.java index 9cd58e18991..1220018fef6 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,36 @@ 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 { + // 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(); + // Encode explicitly as UTF-8 on both ends rather than relying on the platform default + // (which SpotBugs flags and which would garble a non-ASCII exception message differently + // per device); the pair must agree, so the PrintStream and the readback share the charset. + java.io.PrintStream ps = new java.io.PrintStream(bout, true, "UTF-8"); + t.printStackTrace(ps); + ps.flush(); + String s = bout.toString("UTF-8"); + return s.length() == 0 ? null : s; + } 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..b575375209b 100644 --- a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java +++ b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java @@ -45,6 +45,20 @@ 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 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 + /// {@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 +70,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 +102,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 +118,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 +184,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..d1be8376a28 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/hardening/Hardening.java @@ -0,0 +1,68 @@ +/* + * 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 `false` / `"off"`, because those are never +/// obfuscated. +/// +/// @author Shai Almog +public final class Hardening { + + private Hardening() { + } + + /// 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 `"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 + 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..cad7c75ee00 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/hardening/package-info.java @@ -0,0 +1,32 @@ +/* + * 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/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!! 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/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintEditor.java b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintEditor.java index ff556f3d86d..861a9ace953 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.*; @@ -164,14 +186,34 @@ 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); + // 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/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java index 6b35cb182ae..acd45bd516a 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; @@ -54,7 +66,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/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 new file mode 100644 index 00000000000..56955b684b9 --- /dev/null +++ b/docs/developer-guide/App-Hardening.asciidoc @@ -0,0 +1,127 @@ +[[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'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 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'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"] +|=== +|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, 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` +|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 +|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. + +=== 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 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 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 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 doesn't protect against + +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 44c892a9440..7bcdd15bfef 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. @@ -82,9 +83,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 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 - `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. 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 `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/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 0be2b48f143..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 @@ -634,3 +635,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)? 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..5d9451a7136 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.java @@ -0,0 +1,132 @@ +/* + * 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() { + 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"); + 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"); + 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. + r.add("-keepattributes Exceptions,InnerClasses,Signature,EnclosingMethod,*Annotation*," + + "SourceFile,LineNumberTable"); + 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. + */ + 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..43c5193a78a --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java @@ -0,0 +1,116 @@ +/* + * 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. 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, 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(offset + 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..d7e2f128838 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java @@ -0,0 +1,202 @@ +/* + * 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 final ClassLoader hierarchy; + private final int intensity; + private int guardedMethods; + + public ControlFlowTransform() { + 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, int intensity) { + this.hierarchy = hierarchy; + this.intensity = Math.max(1, intensity); + } + + 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; + } + for (int i = 0; i < intensity; i++) { + prependGuard(cn, mn); + } + guardedMethods++; + changed = true; + } + } + if (!changed) { + return classBytes; + } + + addGuardField(cn); + initGuardField(cn); + + ClassWriter cw = new FrameClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, hierarchy); + 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 = 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; + 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/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/HardeningConfig.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java new file mode 100644 index 00000000000..c44c8f0d50d --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java @@ -0,0 +1,244 @@ +/* + * 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 renameRequested; + 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 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; + 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); + + // 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; + boolean encAll; + if (strings == null) { + encConst = level.encryptsConstantStringsByDefault(); + encAll = level.encryptsAllStringsByDefault(); + } else { + String v = strings.trim().toLowerCase(); + if ("off".equals(v)) { + encConst = false; + encAll = false; + } else if ("constants".equals(v)) { + encConst = true; + encAll = false; + } 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(); + } + } + + 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) { + // 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); + } + } + } + + return new HardeningConfig(level, renameRequested, renameEnabled, 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; + } + + /** + * 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; + } + + public boolean isEncryptAllStrings() { + return encryptAllStrings; + } + + public boolean isAnyStringEncryption() { + return encryptConstantStrings || encryptAllStrings; + } + + 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; + } + + 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..dd90b70ffc6 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -0,0 +1,484 @@ +/* + * 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"; + /** 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; + } + + 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()); + } + // 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) { + 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()); + // 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()); + + // 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(); + File hierarchyJar; + + 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"); + // 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, cfg.getPlatform()); + 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) { + // 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, constantValues); + 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(hierarchy, cfg.getControlFlowIntensity()); + byte[] out = t.transform(e.getValue()); + if (out != e.getValue()) { + e.setValue(out); + } + guardedMethods += t.getGuardedMethods(); + } + } + + // 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. + if (translatesThroughParparVMC(cfg.getPlatform())) { + MangleCollisionCheck.check(renamed.keySet()); + } + OutputVerifier.verify(renamed, hierarchy); + + // 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); + + // 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 && cfg.isRenameEnabled()) { + 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"); + } 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"); + } + if (controlFlowApplied && guardedMethods > 0) { + result.getTransformsApplied().add(cfg.getControlFlowIntensity() >= 2 + ? "controlFlow:intense" : "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). + */ + /** 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()) { + 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); + } + + /** + * 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); + } + + /** + * 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 + * 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()); + } + + /** + * 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); + 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..fd2370661da --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningRequest.java @@ -0,0 +1,138 @@ +/* + * 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 r8KeepFile; + 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 getR8KeepFile() { + return r8KeepFile; + } + + public HardeningRequest r8KeepFile(File f) { + this.r8KeepFile = 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..0375de38087 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java @@ -0,0 +1,117 @@ +/* + * 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); + } + } + }; + } + + @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/JarDemuxer.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java new file mode 100644 index 00000000000..559bed7b22e --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java @@ -0,0 +1,188 @@ +/* + * 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 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); + } + } + 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; + } + + /** 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]; + 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..e78d609d304 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java @@ -0,0 +1,198 @@ +/* + * 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 [--r8keep ] --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 r8Keep = opts.containsKey("r8keep") ? new File(opts.get("r8keep")) : 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)); + } + } + + // 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; + } + // 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); + + // 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; + } + + HardeningRequest req = new HardeningRequest() + .inputJar(in) + .outputJar(out) + .mappingFile(mapping) + .reportFile(report) + .r8KeepFile(r8Keep) + .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..1d7c87de74c --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java @@ -0,0 +1,66 @@ +/* + * 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() { + } + + /** + * @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()), hierarchy, 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..2707f7f422c --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.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.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, String platform) throws HardeningException { + File config = new File(workDir, "cn1-hardening.pro"); + try { + writeConfig(config, classesJar, outJar, mappingFile, libraryJars, keepRules, dictionary, platform); + } 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, + String platform) + 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(platform)) { + 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..c8c5afce0c2 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -0,0 +1,426 @@ +/* + * 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 final ClassLoader hierarchy; + private final java.util.Set constantValues; + private int encryptedCount; + + public StringEncryptTransform(boolean encryptAllStrings, int seed) { + 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 + * @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, + 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() { + 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); + + // 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 + // 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; + + // 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) { + continue; + } + if (DECODER_NAME.equals(mn.name)) { + continue; + } + changed |= encryptMethodLiterals(cn, mn, base, isInterface); + } + } + + // 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; + } + + addDecoder(cn, base, isInterface); + + 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) { + 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 && shouldEncryptLiteral((String) ldc.cst)) { + String plain = (String) ldc.cst; + String cipher = encode(plain, base); + // The XOR key spans 0..0xFFFF, so an ASCII literal can encrypt into mostly + // 3-byte (modified) UTF-8 characters; a large-but-valid literal could then exceed + // the 65535-byte constant-pool limit and make ASM throw while writing the class. + // Leave such a literal in plaintext rather than fail the whole build. + if (fitsConstantPool(cipher)) { + ldc.cst = cipher; + // The itf flag must be true when the decoder lives in an interface, or the JVM + // writes a Methodref instead of an InterfaceMethodref and throws + // IncompatibleClassChangeError at run time. + mn.instructions.insert(ldc, new MethodInsnNode( + Opcodes.INVOKESTATIC, cn.name, DECODER_NAME, DECODER_DESC, isInterface)); + encryptedCount++; + changed = true; + } + } + } + insn = next; + } + return changed; + } + + private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInterface) { + 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; + 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(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; + } + } + if (changed) { + prependToClinit(cn, init); + } + 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) { + 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, 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)); + 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).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(); + } + 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; + } + + /** + * 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(); + 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/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-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/ControlFlowTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java new file mode 100644 index 00000000000..17e5b17b418 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java @@ -0,0 +1,95 @@ +/* + * 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")); + } + + @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-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/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 00000000000..28d8df708e2 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -0,0 +1,421 @@ +/* + * 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.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** End-to-end pipeline test: demux, ProGuard rename, string encryption, repackage, mapping. */ +public class HardeningEngineTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String SECRETS = "com/codename1/hardening/fixture/Secrets"; + private static final String HELPER = "com/codename1/hardening/fixture/Helper"; + // Deliberately includes NUL and high bytes to prove byte-for-byte resource preservation, + // written explicitly so the source stays pure ASCII. + private static final byte[] RES_BYTES = new byte[]{ + 'C', 'N', '1', '-', 'B', 'L', 'O', 'B', 0x00, (byte) 0xFF, (byte) 0x80, 0x7F, 'z'}; + + private File buildInputJar() throws Exception { + File jar = tmp.newFile("app.jar"); + FileOutputStream fo = new FileOutputStream(jar); + ZipOutputStream zos = new ZipOutputStream(fo); + putClass(zos, SECRETS); + putClass(zos, HELPER); + zos.putNextEntry(new ZipEntry("theme.res")); + zos.write(RES_BYTES); + zos.closeEntry(); + zos.finish(); + fo.close(); + return jar; + } + + 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]; + int r; + while ((r = in.read(buf)) >= 0) { + b.write(buf, 0, r); + } + in.close(); + 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) + throws Exception { + File in = buildInputJar(); + File out = tmp.newFile("app-hardened.jar"); + File mapping = tmp.newFile("mapping.txt"); + File report = tmp.newFile("report.json"); + Map hints = new HashMap(); + hints.put("harden.level", profile.name().toLowerCase()); + HardeningConfig cfg = HardeningConfig.from(hints, platform, renameSupported); + HardeningRequest req = new HardeningRequest() + .inputJar(in).outputJar(out).mappingFile(mapping).reportFile(report) + .workDir(tmp.newFolder("work")).config(cfg) + // Keep Secrets so the test can load it by name; Helper still gets renamed. + .mainClass("com.codename1.hardening.fixture.Secrets") + .buildKey("TESTKEY"); + return HardeningEngine.harden(req); + } + + @Test + public void standardHardenRenamesEncryptsAndPreservesResources() throws Exception { + // ProGuard 7.3.2 can't read JDK 21+ class files; the renamer runs on JDK <=20 in production. + org.junit.Assume.assumeTrue("ProGuard renamer needs JDK <=20", HardeningEngine.proguardCanRunHere()); + HardeningResult r = harden(HardeningProfile.STANDARD, "ios", true); + assertTrue(r.isHardened()); + + Map outEntries = readAll(r.getHardenedJar()); + + // Non-class resource carried across byte-for-byte. + assertArrayEquals(RES_BYTES, outEntries.get("theme.res")); + + // Helper (not kept) was renamed away; Secrets (kept as main) remains. + assertFalse("Helper should have been renamed", outEntries.containsKey(HELPER + ".class")); + assertTrue("kept main class should remain", outEntries.containsKey(SECRETS + ".class")); + assertTrue("a zq-prefixed renamed class should exist", hasZqClass(outEntries.keySet())); + + // Mapping records the rename and Helper is present in it. + String mapping = new String(Files.readAllBytes(r.getMappingFile().toPath()), Charset.forName("UTF-8")); + assertTrue(mapping.contains("com.codename1.hardening.fixture.Helper ->")); + assertTrue(mapping.contains("# mappingId:")); + assertEquals(64, r.getMappingId().length()); + + // Standard = constants mode: the static-final API constant is encrypted (including its + // inlined read in api()); a plain method literal like the greeting is left alone. + // Behaviour is intact when loaded either way. + byte[] secrets = outEntries.get(SECRETS + ".class"); + assertFalse(StringEncryptTransform.containsStringLiteral(secrets, + "https://api.example.com/secret-endpoint")); + assertTrue(StringEncryptTransform.containsStringLiteral(secrets, "hello secret world")); + + URLClassLoader cl = new URLClassLoader(new URL[]{r.getHardenedJar().toURI().toURL()}, + getClass().getClassLoader().getParent()); + Class c = Class.forName("com.codename1.hardening.fixture.Secrets", true, cl); + assertEquals("hello secret world", c.getMethod("greet").invoke(null)); + assertEquals("https://api.example.com/secret-endpoint", c.getMethod("api").invoke(null)); + 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); + assertFalse(r.isHardened()); + 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, + // 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 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. + 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. + HardeningResult r = harden(HardeningProfile.STANDARD, "and", false); + assertTrue(r.isHardened()); + Map outEntries = readAll(r.getHardenedJar()); + // Nothing renamed: both classes keep their names. + assertTrue(outEntries.containsKey(HELPER + ".class")); + assertTrue(outEntries.containsKey(SECRETS + ".class")); + assertEquals(0, r.getRenamedClasses()); + // Standard = constants mode: the static-final API constant is encrypted (including its + // inlined copy in api()), but a plain method literal like the greeting is left alone. + assertTrue(r.getEncryptedStrings() >= 1); + byte[] secrets = outEntries.get(SECRETS + ".class"); + assertFalse("declared constant must be encrypted", + StringEncryptTransform.containsStringLiteral(secrets, "https://api.example.com/secret-endpoint")); + assertTrue("a plain (non-constant) literal is left alone in constants mode", + StringEncryptTransform.containsStringLiteral(secrets, "hello secret world")); + } + + @Test + public void javascriptSkipsStringEncryption() throws Exception { + org.junit.Assume.assumeTrue("ProGuard renamer needs JDK <=20", HardeningEngine.proguardCanRunHere()); + HardeningResult r = harden(HardeningProfile.AGGRESSIVE, "javascript", true); + assertTrue(r.isHardened()); + // On JS the bridge could break, so string encryption is off; renaming still happens. + assertEquals(0, r.getEncryptedStrings()); + 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)) { + return true; + } + } + return false; + } + + private Map readAll(File jar) throws Exception { + Map out = new HashMap(); + ZipInputStream zis = new ZipInputStream(Files.newInputStream(jar.toPath())); + ZipEntry e; + while ((e = zis.getNextEntry()) != null) { + if (e.isDirectory()) { + continue; + } + ByteArrayOutputStream b = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int r; + while ((r = zis.read(buf)) >= 0) { + b.write(buf, 0, r); + } + out.put(e.getName(), b.toByteArray()); + } + zis.close(); + return out; + } +} 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 new file mode 100644 index 00000000000..c01935a95d1 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -0,0 +1,209 @@ +/* + * 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.ByteArrayOutputStream; +import java.io.InputStream; +import org.junit.Test; +import org.objectweb.asm.util.CheckClassAdapter; + +/** + * Verifies string encryption on a real compiled class: the transform must produce + * bytecode that (a) verifies, (b) computes exactly what the original did, and + * (c) no longer contains any plaintext secret -- neither as an LDC nor as a field + * {@code ConstantValue}. + */ +public class StringEncryptTransformTest { + + private static final String CLASS = "com.codename1.hardening.fixture.Secrets"; + private static final String GREETING = "hello secret world"; + private static final String API = "https://api.example.com/secret-endpoint"; + + 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(); + } + + 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 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 + // 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)); + } + + @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)); + } + + @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) { + 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/Iface.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Iface.java new file mode 100644 index 00000000000..447f708c4a4 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/fixture/Iface.java @@ -0,0 +1,37 @@ +/* + * 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 { + /** An implicitly-constant String field whose plaintext lives in a ConstantValue attribute. */ + String TOKEN = "interface constant secret"; + + default String secret() { + return "interface default secret"; + } + + static String staticSecret() { + return "interface static secret"; + } +} 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..4711be83ab2 --- /dev/null +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingChain.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.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; + } + + /** + * 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 new file mode 100644 index 00000000000..ee26b03f250 --- /dev/null +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -0,0 +1,244 @@ +/* + * 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; // 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, 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. 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 observed; + } + if (originalEndLine <= originalStartLine) { + return originalStartLine; + } + int mapped = originalStartLine + (observed - startLine); + return mapped > originalEndLine ? originalEndLine : mapped; + } + } + + 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)" 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); + if (afterParen.startsWith(":")) { + String[] parts = afterParen.substring(1).split(":"); + if (parts.length >= 1) { + originalStartLine = parseIntSafe(parts[0]); + } + originalEndLine = parts.length >= 2 ? parseIntSafe(parts[1]) : originalStartLine; + } + 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(' '); + 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, originalStartLine, originalEndLine)); + } + + /** + * 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) { + // 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 java.util.Collections.singletonList(obfuscated); + } + int observed = obfuscated.getLineNumber(); + String originalClass = cm.originalName; + String file = simpleSourceFile(originalClass); + 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 out; + } + + 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..afc1b3b864f --- /dev/null +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/RetraceMain.java @@ -0,0 +1,92 @@ +/* + * 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) { + 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); + } + } + } + } + + 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..d1c51f1374e --- /dev/null +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -0,0 +1,120 @@ +/* + * 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 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 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 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); + 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..aff7d0c4312 100644 --- a/maven/codenameone-maven-plugin/pom.xml +++ b/maven/codenameone-maven-plugin/pom.xml @@ -257,6 +257,32 @@ runtime + + + com.codenameone + cn1-hardening + ${project.version} + + provided + true + + + * + * + + + + @@ -363,6 +389,44 @@ 3.2.5 + + org.apache.maven.plugins + maven-dependency-plugin + + + + embed-hardening-engine + + prepare-package + + copy + + + + + com.codenameone + cn1-hardening + ${project.version} + standalone + jar + ${project.build.outputDirectory} + cn1-hardening.jar + + + true + true + + + + org.apache.maven.plugins maven-antrun-plugin 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..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 @@ -753,6 +753,55 @@ private static String escape(String str, String chars) { return str; } + @Override + protected String hardeningPlatform(BuildRequest request) { + 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 ""; + } + // 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"); + 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 + // 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; @@ -787,6 +836,23 @@ 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"); + // 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 + && 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 " + + "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; @@ -4730,7 +4796,10 @@ && 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 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" @@ -4791,6 +4860,9 @@ && 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(\"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 @@ -5106,7 +5178,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" @@ -5507,6 +5579,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 147fab3cc07..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 @@ -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,394 @@ 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(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. + */ + protected boolean hardeningRenameSupported() { + return true; + } + + /** + * 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) { + 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 + // 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.contains(f)) { + jars.add(f); + } + } + } + } + return jars; + } + + 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; + } + + /** + * 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); + } + + /** + * 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 + * 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 r8Keep = new File(workDir, "cn1-r8-keep.pro"); + 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("--r8keep"); + cmd.add(r8Keep.getAbsolutePath()); + cmd.add("--config"); + cmd.add(config.getAbsolutePath()); + + int exit = runForked(cmd, workDir); + if (exit == 0) { + 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). + 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; + } + 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(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. + 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. + 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(); + } + } + + /** 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); + 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", ""); + } + + /** + * 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/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 2cd3bdff39f..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 @@ -477,6 +477,17 @@ private String podVersionRequirement(String hint, String fallback) { + @Override + protected String hardeningPlatform(BuildRequest request) { + // 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"; + } + @Override public boolean build(File sourceZip, BuildRequest request) throws BuildException { // Builder instances are normally single-use, but keep scan-derived @@ -2025,6 +2036,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 36bfe743fc9..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 @@ -89,6 +89,11 @@ public File getJavaScriptDeployableArtifact() { return jsDeployableArtifact; } + @Override + protected String hardeningPlatform(BuildRequest request) { + return "javascript"; + } + @Override public boolean build(File sourceZip, BuildRequest request) throws BuildException { debug("Request Args: "); @@ -132,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(); @@ -387,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 @@ -399,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 + " {"); @@ -416,7 +422,17 @@ private File writeLauncher(File workDir, String launcherName, String packageName + ifaceName + ".class, " + ifaceName + "Impl.class);"); } } - pw.println(" ParparVMBootstrap.bootstrap(new " + mainClass + "());"); + // 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 { 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..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 @@ -181,6 +181,11 @@ static String detectHostArch() { return ARCH_X64; } + @Override + protected String hardeningPlatform(BuildRequest request) { + return "linux"; + } + @Override public boolean build(File sourceZip, BuildRequest request) throws BuildException { String arch = normalizeArch(request.getArg("linux.arch", ARCH_X64)); @@ -637,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 e4ee216b7a3..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 @@ -163,6 +163,11 @@ static String detectHostArch() { return ARCH_X64; } + @Override + protected String hardeningPlatform(BuildRequest request) { + return "win"; + } + @Override public boolean build(File sourceZip, BuildRequest request) throws BuildException { String arch = normalizeArch(request.getArg("windows.arch", ARCH_X64)); @@ -1193,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/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 7ed13995086..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 @@ -162,6 +162,8 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException } } + applyHardeningPreflight(); + try { createAntProject(); } catch (IOException ex) { @@ -173,6 +175,122 @@ 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"); + // A per-platform opt-out (harden..enabled=false) means hardening won't run for + // this target, so the pre-flight must not reject it -- treat the level as off. The native-Mac + // 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"; + } + 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"); + } + // 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"); + } + } + + /** + * 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) { + 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. @@ -1318,7 +1436,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 +1640,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 +1771,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 +1782,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 +1854,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 +1865,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 +1949,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/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)"); + } +} 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..4592577bc9a --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.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. + * + * 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 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..3269aeabb4f --- /dev/null +++ b/tests/core/test/com/codename1/crash/CrashReportPayloadTest.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.crash; + +import 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/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; } /** diff --git a/vm/JavaAPI/src/java/lang/Throwable.java b/vm/JavaAPI/src/java/lang/Throwable.java index d87b9e11171..1591ecaa7ed 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; /** @@ -104,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) { @@ -114,11 +124,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; } /**