diff --git a/CodenameOne/src/com/codename1/crash/CrashProtection.java b/CodenameOne/src/com/codename1/crash/CrashProtection.java index 9cd58e18991..6f1cf882f42 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,91 @@ 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. + // + // Render into a BOUNDED buffer that discards bytes past the cap, rather than an unbounded + // ByteArrayOutputStream truncated afterwards: a throwable with a huge message or a deep + // cause chain could otherwise allocate many times the 16 KiB cap during crash handling and + // trigger a second OutOfMemoryError, losing the report. The cap sits a little above + // MAX_RAW_STACK_LEN so scrubbing (which only shrinks -- digit runs and emails collapse) and + // the final trim still yield a full-length raw stack. + BoundedOutputStream bout = new BoundedOutputStream( + CrashReportPayload.MAX_RAW_STACK_LEN + 8192); + try { + // Encode explicitly as UTF-8 on both ends rather than relying on the platform default + // (which SpotBugs flags and which would garble a non-ASCII exception message differently + // per device); the PrintStream and the readback share the charset. + java.io.PrintStream ps = new java.io.PrintStream(bout, true, "UTF-8"); + try { + t.printStackTrace(ps); + ps.flush(); + } finally { + ps.close(); + } + String s = bout.toUtf8(); + return s.length() == 0 ? null : s; + } finally { + bout.close(); + } + } catch (Throwable ignored) { + return null; + } + } + + /// A fixed-capacity {@link java.io.OutputStream} that keeps the first {@code cap} bytes and + /// silently discards the rest, so rendering a pathologically large stack cannot grow the buffer + /// without bound (and cannot itself OOM during crash handling). Not thread-safe; used by a single + /// capturing thread. + static final class BoundedOutputStream extends OutputStream { + private final byte[] buf; + private int count; + + BoundedOutputStream(int cap) { + buf = new byte[cap]; + } + + @Override + public void write(int b) { + if (count < buf.length) { + buf[count++] = (byte) b; + } + } + + @Override + public void write(byte[] b, int off, int len) { + int room = buf.length - count; + if (room <= 0) { + return; + } + int n = len < room ? len : room; + System.arraycopy(b, off, buf, count, n); + count += n; + } + + String toUtf8() throws java.io.UnsupportedEncodingException { + // Explicit UTF-8 (never the platform default, which SpotBugs flags and which would garble a + // non-ASCII message per device). UTF-8 is always available, so this never actually throws; + // the checked exception is handled by the single caller's catch-all rather than swallowed + // here into a default-encoding String. + return new String(buf, 0, count, "UTF-8"); + } } /// Pulls the platform log snapshot, swallowing any exception the diff --git a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java index d9903e91afb..dc82ac170a6 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,25 +102,103 @@ 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); Display d = Display.getInstance(); + this.platform = d.getPlatformName(); + this.traceFormat = deriveTraceFormat(this.frames, this.rawStack, this.platform); this.buildKey = d.getProperty("build_key", ""); this.packageName = d.getProperty("package_name", ""); this.appName = d.getProperty("AppName", ""); this.appVersion = d.getProperty("AppVersion", ""); - this.platform = d.getPlatformName(); this.osVersion = d.getProperty("OSVer", ""); + this.mappingId = d.getProperty("cn1.mappingId", ""); + this.hardenLevel = d.getProperty("cn1.hardenLevel", ""); Locale loc = Locale.getDefault(); this.locale = loc == null ? "" : loc.toString(); this.clientTs = System.currentTimeMillis(); } + /// Derives the trace format from what we actually have, so the server picks the right parser -- + /// never a guess. Structured frames win. Otherwise: the JavaScript port's raw stack is a JS engine + /// stack; a ParparVM C target's is the " at .:" text; a JVM target + /// (Android/desktop) reaching here has an ordinary JVM printStackTrace (e.g. a stackless throwable + /// whose only frames are in its cause) that the JS parser must NOT touch. The old heuristic looked + /// only for the 4-space ParparVM shape and labeled everything else -- including the tab-indented + /// JVM trace -- as JavaScript; derive from the platform so that never happens. + static String deriveTraceFormat(List frames, String rawStack, String platform) { + // A real JVM sets java.vm.name; ParparVM's System.getProperty always returns null. This is the + // only reliable way to tell a native ParparVM-C target from a JavaSE desktop app or the simulator, + // because JavaSEPort.getPlatformName() returns the SAME mac/win names a native build reports. + return deriveTraceFormat(frames, rawStack, platform, System.getProperty("java.vm.name") != null); + } + + /// The testable core of {@link #deriveTraceFormat(List, String, String)}: {@code runningOnJvm} is + /// supplied explicitly (production derives it from {@code java.vm.name}) so a JVM-hosted unit test can + /// exercise both the native ParparVM-C path and the JavaSE-on-mac/win path. + static String deriveTraceFormat(List frames, String rawStack, String platform, + boolean runningOnJvm) { + if (frames != null && !frames.isEmpty()) { + return TRACE_STRUCTURED; + } + if (rawStack == null || rawStack.length() == 0) { + return TRACE_NONE; + } + if (isJavaScriptPlatform(platform)) { + return TRACE_JS; + } + // The " at .:" text is only produced by ParparVM's own printStackTrace on a + // native C target. Gate on the runtime: an Android/desktop/simulator (real-JVM) throwable whose + // MESSAGE happens to contain such a line -- the message is echoed into the raw stack by + // printStackTrace -- must NOT be labeled parparvm-text, or the server would fabricate a frame from + // message text or drop a real cause trace. The shape check still guards an unexpected stack on a + // ParparVM-C target. + if (isParparVmTextPlatform(platform, runningOnJvm)) { + // A ParparVM frame line is exactly " at .:" -- no '(', URL or '@'. + int at = rawStack.indexOf(" at "); + if (at >= 0) { + int lineEnd = rawStack.indexOf('\n', at); + String body = lineEnd < 0 ? rawStack.substring(at + 7) : rawStack.substring(at + 7, lineEnd); + if (body.indexOf('(') < 0 && body.indexOf('/') < 0 && body.indexOf('@') < 0) { + return TRACE_PARPARVM; + } + } + } + // Not JavaScript and not a native ParparVM-C target's text shape: an ordinary JVM printStackTrace + // body. There is no JVM raw parser, so report NONE and let the server keep the text verbatim rather + // than misparsing it. + return TRACE_NONE; + } + + /// True only on a native ParparVM-to-C runtime, whose {@code printStackTrace} emits the + /// " at <fqcn>.<method>:<line>" text. The displayed platform name is NOT enough: + /// a skinless JavaSE desktop app returns {@code mac}/{@code win} (Linux falls through to {@code win}) + /// and the simulator can return {@code ios}, all colliding with the native names. So anything running + /// on a real JVM ({@code runningOnJvm}: JavaSE desktop, the simulator, or Android) is excluded, and of + /// the remaining native runtimes only the C-target names qualify. + private static boolean isParparVmTextPlatform(String platform, boolean runningOnJvm) { + if (runningOnJvm || platform == null) { + return false; + } + String p = platform.toLowerCase(); + return "ios".equals(p) || "mac".equals(p) || "linux".equals(p) || "win".equals(p); + } + + /// The JavaScript port's platform name; its raw stack is a JS engine {@code Error().stack}. + private static boolean isJavaScriptPlatform(String platform) { + if (platform == null) { + return false; + } + String p = platform.toLowerCase(); + return p.indexOf("html") >= 0 || p.indexOf("javascript") >= 0 || "js".equals(p); + } + static final class Frame { final String className; final String methodName; @@ -124,6 +233,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..3636c7666b3 100644 --- a/CodenameOne/src/com/codename1/crash/PiiScrubber.java +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -78,6 +78,145 @@ 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, and on + /// the JavaScript port it is the engine's `Error().stack`. A stricter + /// application can override this to redact aggressively. + /// + /// The default scrubs emails everywhere and applies long-digit-run masking + /// UNIFORMLY to every line, frame-shaped or not. It does not try to preserve + /// a frame's line/column: `printStackTrace` writes the exception message + /// verbatim, and a message can embed an indented, frame-shaped line that is + /// indistinguishable from a real frame -- preserving a "coordinate" from such + /// a line would let a crafted `:line:column` tail smuggle a long id past the + /// digit masking. `scrubMessage` masks only 6-or-more-digit runs, so ordinary + /// short line numbers survive, but a large minified-JavaScript column such as + /// `app.js:1:123456` is masked to `app.js:1:[num]`. That loses the column for + /// this text form; precise coordinates for symbolication come from the + /// structured frames (real `StackTraceElement`s), not this scrubbed string. + /// + /// #### Parameters + /// + /// - `rawStack`: the pre-rendered stack string; may be `null`. + /// + /// #### Returns + /// + /// the scrubbed stack string, or `null` if `rawStack` is `null`. + /// + /// EVERY line is routed through {@link #scrubMessage(String)} -- the overridable method -- so an app + /// that redacts app-specific tokens there redacts them in `rawStack` too. No line is treated as a + /// "frame" whose coordinate is preserved: `printStackTrace` writes the exception MESSAGE verbatim, and + /// a message can contain an embedded, indented, frame-shaped line (e.g. code that folds another stack + /// trace into a message), which is indistinguishable from a real frame by any shape or indentation + /// check. Preserving a "coordinate" from such a line would let a crafted `:line:column` tail bypass + /// digit masking. So the raw stack is scrubbed uniformly; `scrubMessage` masks only 6+ digit runs, so + /// ordinary short line numbers survive and stay readable, while a large minified-bundle column (or a + /// long id planted as a fake column) is masked. Precise coordinates for symbolication come from the + /// structured frames, which are real `StackTraceElement`s, not parsed text. The app's + /// {@link #scrubFrame(String, String)} override is still applied to a `at .` line so a + /// synthetic method name redacted from the structured frames does not resurface here. + public String scrubRawStack(String rawStack) { + if (rawStack == null) { + return null; + } + int len = rawStack.length(); + StringBuilder out = new StringBuilder(len); + int i = 0; + while (i < len) { + int nl = rawStack.indexOf('\n', i); + int lineEnd = nl < 0 ? len : nl; + String line = rawStack.substring(i, lineEnd); + out.append(scrubMessage(applyFrameOverride(line))); + if (nl < 0) { + break; + } + out.append('\n'); + i = nl + 1; + } + return out.toString(); + } + + /// Applies {@link #scrubFrame(String, String)} to the method name of a JVM/ParparVM + /// `at .()` / `at .:` frame, so an app that redacts a + /// synthetic method name in its structured frames redacts it in the raw stack too. Only these + /// dotted-identity forms carry a {@code class.method} the override addresses; other forms (a bare + /// V8 function, a URL frame) are returned unchanged. The default {@code scrubFrame} returns the + /// method unchanged, so a build that does not override it sees no difference. + private String applyFrameOverride(String line) { + int at = line.indexOf("at "); + if (at < 0 || line.substring(0, at).trim().length() != 0) { + return line; + } + String rest = line.substring(at + 3).trim(); + int paren = rest.indexOf('('); + int idEnd; + if (paren >= 0) { + idEnd = paren; + } else { + int locStart = trailingLocationStart(rest); + idEnd = locStart > 0 ? locStart : rest.length(); + } + String identity = rest.substring(0, idEnd).trim(); + int lastDot = identity.lastIndexOf('.'); + if (lastDot <= 0 || lastDot == identity.length() - 1 || !isFrameIdentity(identity)) { + return line; + } + String cls = identity.substring(0, lastDot); + String method = identity.substring(lastDot + 1); + String scrubbed = scrubFrame(cls, method); + if (scrubbed == null) { + // The app removed the method name (its structured frame renders an empty method, + // CrashReportPayload.Frame); render it empty here too rather than restoring the original. + scrubbed = ""; + } else if (scrubbed.equals(method)) { + return line; + } + return line.substring(0, at + 3) + cls + "." + scrubbed + rest.substring(idEnd); + } + + /// A frame's identity is a single token: non-empty and free of whitespace. A free-form message + /// continuation (`account 123456 failed`) has spaces, so it is rejected. + private static boolean isFrameIdentity(String id) { + if (id.length() == 0) { + return false; + } + for (int i = 0; i < id.length(); i++) { + char c = id.charAt(i); + if (c == ' ' || c == '\t') { + return false; + } + } + return true; + } + + /// Index at which a trailing `:` (optionally `::`) location begins, or -1 + /// when the string does not end in one. A single trailing `)` is allowed. Consumes AT MOST two + /// numeric groups -- a real location is `:line` or `:line:column` -- so colon-delimited data before + /// the coordinate (a URL like `host/account:123456:1:42`) stays in the scrubbable head rather than + /// being preserved as if it were part of the coordinate. + private static int trailingLocationStart(String t) { + int i = t.length() - 1; + if (i >= 0 && t.charAt(i) == ')') { + i--; + } + int start = -1; + for (int groups = 0; groups < 2; groups++) { + int j = i; + int digits = 0; + while (j >= 0 && t.charAt(j) >= '0' && t.charAt(j) <= '9') { + j--; + digits++; + } + if (digits > 0 && j >= 0 && t.charAt(j) == ':') { + start = j; + i = j - 1; + } else { + break; + } + } + return start; + } + /// 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..42c9899de6a 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,58 @@ 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 = + stronger " + + "control-flow obfuscation."); + + 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..d768cf63403 --- /dev/null +++ b/docs/developer-guide/App-Hardening.asciidoc @@ -0,0 +1,134 @@ +[[app-hardening]] +== App Hardening + +Every Codename One app is already obfuscated by default. On Android the release build runs through R8, which renames the Java names. On iOS and the other native ports your code is compiled to native machine code through ParparVM, which is hard to reverse engineer on its own -- but the class names, method names and string literals still travel into the binary as readable text, so a reader who can't follow the machine code can still read the labels and the constants. + +App Hardening is the Enterprise layer that closes that remaining gap across *all* the ports from one place. It renames classes, methods and fields; encrypts string constants so they're not present as plaintext in the binary; and obfuscates control flow -- and it does this to the merged application before each platform build, so Android, iOS, JavaScript and the native desktop targets are all covered by one transform and one mapping. + +This is a tool for apps that face serious security scrutiny -- banking, payments, government and other high-risk targets. It raises the cost of static analysis and tampering; it's one layer, so pair it with <> so the statement your backend trusts is made by hardware the attacker doesn't control. + +App Hardening is an *Enterprise* feature. A build that asks for it without an Enterprise subscription *fails with an explanation* rather than producing an unhardened binary that would look protected without being so. + +=== What it changes, per port + +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. Each distinct literal is decoded once (per class) and interned, so equal encrypted literals stay reference-equal to each other. The decoder is synthesized into each class with a per-class key, so there is no single framework method to hook. Not applied on the JavaScript port, where a string literal can be a live reference into the native bridge. + +One caveat: an encrypted app literal is a different object from an equal *un-encrypted* literal elsewhere -- for example a literal of the same value returned by the framework, which isn't hardened. Reference (`==`) comparison of string literals across that boundary can therefore change. Compare strings with `.equals()`, which is unaffected; `==` on distinct string values was never a guarantee to rely on. + +One exclusion: a string used as an *annotation value* (or an annotation-method default) is stored by javac in the annotation metadata, not as an `LDC` or a field constant, so it's not encrypted and remains readable in the binary. Codename One has no runtime reflection, so your app can't read that value back anyway -- but don't place a secret in an annotation and expect it hidden. + +Another exclusion: string concatenation such as `"prefix=" + value`, compiled by a JDK 9 or newer javac, becomes an `invokedynamic` whose literal fragments live in the concatenation recipe rather than an `LDC` or a field constant, so the encryption can't reach them and they stay readable. The engine counts these sites and the build report lists them, so a build that ships such literals says so rather than claiming complete string encryption. Compiling with `-XDstringConcat=inline` (or an older `-target`) makes javac emit `StringBuilder` calls whose literals the engine does encrypt. + +|Control-flow obfuscation +|Android, desktop +|An opaque predicate guarded by a value the decompiler can't fold. Left off the ParparVM native ports, where it fights the translator's optimizer and the arithmetic reducer, and off JavaScript, where it inflates the bundle. Never applied to constructors. +|=== + +=== 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 +|Control-flow obfuscation |-- |-- |yes |yes + opaque predicates +|Local-variable debug stripping |-- |yes |yes |yes +|Symbol/mapping upload |-- |required |required |required +|=== + +On a renamed build the source file name (`SourceFile`) is stripped, matching DexGuard: the engine-renamed ports drop it, and on a minified Android release R8 drops it too. `LineNumberTable` is kept so a hardened crash still retraces to a line number against the mapping; the retrace reconstructs the file name from the (retraced) class name. Renaming also removes the local-variable and parameter names. + +An explicitly *unminified* Android build (`android.enableProguard=false`, which `android.onDeviceDebug` also forces) doesn't rename at all -- only string encryption and control-flow obfuscation apply -- so its class names, and `SourceFile` along with them, remain. That's a debug configuration, not the release artifact hardening targets. + +=== Keeping what must not be renamed + +Renaming is safe for code the compiler and runtime resolve by symbol. Codename One has no runtime reflection -- `Class.forName` never resolved an obfuscated application class -- so there is no reflective seam to protect, and there is no serialization to keep members for. What must survive is the small set the build resolves by *name*: the main class and its generated stub, the generated router and annotation bootstraps, and each native interface with its generated `Impl`/`Stub` peer. The engine finds the native interfaces in the input and keeps exactly those peers, and keeps the rest of that set automatically. + +`PropertyBusinessObject` properties are safe without any special handling: a property's JSON key and database column come from the *string* passed to its `Property`, not from the field name, so renaming the field doesn't change the on-disk schema or the wire format. String encryption decodes those strings back to the same value at runtime. + +If you have a class the build resolves by a name the automatic analysis can't see, add a `harden.keep` rule for it. + +Package names are obfuscated along with class names. Codename One's own resource loading uses absolute paths (`Display.getResourceAsStream("/name")`, the theme `.res`), which are unaffected. The one thing renaming can break is a *package-relative* resource lookup -- `getClass().getResourceAsStream("config.properties")`, without a leading slash -- because the runtime resolves it under the class's now-obfuscated package while the resource stays at its original path. This is uncommon in application code, but a bundled third-party dependency might do it; if one loads a resource relative to its own package, add a `harden.keep` rule for that class (or its package) so its package path stays put. + +=== Crash reports from a hardened build + +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, and only there. The engine itself is open source -- it lives in this repository and ships inside the Maven plugin -- so its protection isn't the secrecy of the algorithm. What the server boundary provides is enforcement and custody: the Enterprise entitlement is checked where the account lives (a client can't grant itself the feature), the renaming dictionary and decoder keys are seeded per build so the exact obfuscation differs each time and can't be predicted without the build key, and the obfuscation mapping is retained server-side for symbolication rather than handed to the client. + +A local or source-project target (`*-source`, `local-*`) never reaches the server, so its output isn't hardened; the build fails the pre-flight rather than mislead you, unless you set `harden.allowUnhardenedLocalBuild=true`. The simulator is never obfuscated either -- it runs your `target/classes` directly. App code can read `com.codename1.security.hardening.Hardening.isHardened()` to tell a hardened build apart from one of these. + +=== 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..7a62f5d2767 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 a specific 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..c7cf46d0fc4 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -49,6 +49,7 @@ iapdemo ParparVM RoboVM TeaVM +DexGuard teavm teavmdbg LWUIT @@ -560,6 +561,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 +636,13 @@ 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) +[Uu]nminif(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..c76aeb8a292 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/BuiltinKeepRules.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.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 types, {@code native} + * members, and {@code enum} {@code values()}/{@code valueOf()}. Codename One has no + * reflection and does not support serialization, so no {@code Class.forName}, + * {@code Serializable}/{@code Externalizable} or property-name keeps are needed. + */ +public final class BuiltinKeepRules { + + /** 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 { *; }"); + // The transcoded-SVG registry: the platform builders probe for this class by its exact name to + // decide whether to emit its installGlobal() call, so renaming it would make them conclude no + // SVGs were generated and silently drop SVG rendering from the hardened app. + r.add("-keep class com.codename1.generated.svg.SVGRegistry { *; }"); + for (String b : BOOTSTRAPS) { + r.add("-keep class cn1app." + b + " { *; }"); + } + // Native interfaces are bound to their platform implementation by name. Keep the interface + // itself here; the specific Impl / Stub are found by scanning the input + // (InputJarKeepScanner) and kept individually, rather than the over-broad **Impl / **Stub. + r.add("-keep class * implements com.codename1.system.NativeInterface { *; }"); + // Background callbacks the OS restarts by the app's PERSISTED class name resolve the listener + // via Class.forName + newInstance after a process restart (GeofenceManager persists it to + // Storage; background location and background fetch register a class the platform reconstructs). + // These are genuine reflective, name-bound seams -- unlike CN1's string-keyed property + // persistence -- so the class name must stay stable across an app update, or the default + // per-build mapping renames it and the background callback silently stops. Keep the implementors. + r.add("-keep class * implements com.codename1.location.GeofenceListener { *; }"); + r.add("-keep class * implements com.codename1.location.LocationListener { *; }"); + r.add("-keep class * implements com.codename1.background.BackgroundFetch { *; }"); + r.add("-keep class * implements com.codename1.background.BackgroundWorker { *; }"); + // A HealthBackgroundListener is reconstructed after a process restart by its PERSISTED class name: + // HealthStore writes getClass().getName() to Preferences (PREF_LISTENER), and the platform builder + // scans the (hardened) jar to generate a HealthBackgroundListenerFactory that maps that name back + // to a constructor. The default per-build mapping seed changes the renamed name between builds, so + // after an app update the persisted name no longer matches the regenerated factory and + // resolveBackgroundListener() silently returns nothing -- background health delivery stops. Keep + // the implementors so the name stays stable, as the equivalent location/background rules do. + r.add("-keep class * implements com.codename1.health.HealthBackgroundListener { *; }"); + // A com.codename1.social.Login subclass persists its OAuth access/refresh tokens under keys + // derived from getClass().getName() (Login.getAccessToken/setAccessToken/validateToken). Renaming + // an app's Login subclass would change the key after an app update, so the stored session becomes + // unreadable and the user is silently logged out. Keep the subclasses so the name stays stable. + r.add("-keep class * extends com.codename1.social.Login { *; }"); + // JNI/native method names must not move. + r.add("-keepclasseswithmembernames,includedescriptorclasses class * { native ; }"); + // enum values()/valueOf(String) resolve constants by name, so they are kept -- this is + // ordinary language behaviour, not reflection. + r.add("-keepclassmembers enum * { public static **[] values(); public static ** valueOf(java.lang.String); }"); + 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 LineNumberTable so a hardened crash still reports its true line (the crash retrace + // passes device line numbers through, and ParparVM turns the table into on-device debug-line + // info). SourceFile is NOT kept -- the retrace synthesizes the file name from the class name, + // so the original .java name is stripped, matching DexGuard. + r.add("-keepattributes Exceptions,InnerClasses,Signature,EnclosingMethod,*Annotation*," + + "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..7a86bba3cf3 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java @@ -0,0 +1,124 @@ +/* + * 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 and a largest member naming + * scope of {@code maxMemberNamingScope} (the caller computes this: a class's field count, or the + * number of same-descriptor methods across an inheritance hierarchy, both of which need collision-free + * names). The one dictionary feeds ProGuard's class, member AND package obfuscation, so it must exceed + * the LARGEST naming scope: the global class count, or that member scope (a generated hierarchy can + * have tens of thousands of same-descriptor methods, all needing distinct names). Sizing to only the + * class count would let a member-heavy hierarchy exhaust the dictionary and drop ProGuard back to its + * {@code a}/{@code b} short names, reintroducing the ParparVM native-scan pathology this class exists + * to avoid. Kept comfortably above that maximum with a floor for small apps. + */ + public static int dictionarySizeFor(int classCount, int maxMemberNamingScope) { + int largestScope = Math.max(classCount, maxMemberNamingScope); + return Math.max(50000, largestScope * 4); + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java new file mode 100644 index 00000000000..e75dfd0fd35 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java @@ -0,0 +1,292 @@ +/* + * 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"; + + /** Widest encoding of one entry guard (GETSTATIC, IFGT, NEW, DUP, INVOKESPECIAL, ATHROW). */ + private static final int GUARD_BYTES = 16; + /** Widest encoding of the guard-field setup prepended to {@code } (2 calls + PUTSTATIC). */ + private static final int GUARD_INIT_BYTES = 16; + /** + * Conservative constant-pool entries the guard adds: the guard field (Utf8/Fieldref/NameAndType), + * the {@code Runtime}/{@code getRuntime}/{@code availableProcessors} references, and the + * {@code RuntimeException} constructor reference. Fixed per class, so a class whose pool is already + * near the 65535-entry limit cannot be guarded at all. + */ + private static final int GUARD_POOL_OVERHEAD = 32; + + private final ClassLoader hierarchy; + private final int intensity; + private int guardedMethods; + private int oversizedMethods; + + 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; + } + + /** + * Methods left unguarded because they are already so close to the 65,535-byte method limit that + * prepending the guard(s) would overflow them. Skipped rather than aborting the build; reported so + * a paranoid build knows a large generated method kept its plain control flow. + */ + public int getOversizedMethods() { + return oversizedMethods; + } + + public byte[] transform(byte[] classBytes) { + ClassNode cn = new ClassNode(); + ClassReader reader = new ClassReader(classBytes); + reader.accept(cn, ClassReader.SKIP_FRAMES); + + if ((cn.access & Opcodes.ACC_INTERFACE) != 0) { + return classBytes; + } + // The guard adds a field and references (Runtime, RuntimeException, the guard field) at a fixed + // constant-pool cost. If the class's pool is already so full it cannot fit that overhead, the + // class cannot be guarded without ClassTooLargeException. Skip it and report the methods that + // stay plain rather than aborting the entire hardened build. + if (reader.getItemCount() + GUARD_POOL_OVERHEAD > MethodSize.SAFE_POOL_ITEMS) { + oversizedMethods += countGuardable(cn); + return classBytes; + } + // Pick a guard field name that collides with no existing member, so a class that happens to + // declare a zq$cf field (reachable on Android, where the engine doesn't rename first, or in a + // pre-obfuscated dependency) is still guarded instead of being returned unchanged on the false + // assumption that the collision means it was already transformed. + String guardField = resolveGuardField(cn); + + // The guards read a field initialized in ; if is already near the method limit + // the setup cannot be added, and an uninitialized field is 0 -- which makes every guard take its + // dead (throwing) arm. So the whole class cannot be guarded then: skip it (reporting the methods + // that stay plain) rather than corrupt behaviour or abort the build with MethodTooLargeException. + if (MethodSize.estimateBytes(findClinit(cn)) + GUARD_INIT_BYTES > MethodSize.SAFE_LIMIT) { + oversizedMethods += countGuardable(cn); + return classBytes; + } + + boolean changed = false; + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if (!isGuardable(mn)) { + continue; + } + // A generated method can already be near the 65,535-byte limit; prepending guards would + // overflow it and make ASM abort the whole build. Skip (and report) such a method rather + // than fail on a valid input class. + if (MethodSize.estimateBytes(mn) + GUARD_BYTES * intensity > MethodSize.SAFE_LIMIT) { + oversizedMethods++; + continue; + } + for (int i = 0; i < intensity; i++) { + prependGuard(cn, mn, guardField); + } + guardedMethods++; + changed = true; + } + } + if (!changed) { + return classBytes; + } + + addGuardField(cn, guardField); + initGuardField(cn, guardField); + + FrameClassWriter cw = new FrameClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, hierarchy); + cn.accept(cw); + if (cw.isHierarchyIncomplete()) { + // A frame merge collapsed to Object because a supertype is absent from the supplied jars, so + // the recomputed StackMapTable may be too weak and fail on-device verification. Ship this class + // UNHARDENED (original valid frames) rather than a possibly-invalid one. Control flow leaves + // string literals untouched, so no jar-wide literal exclusion is needed here. Reset the guard + // counts: no guard is actually emitted for a discarded class, and a stale count would let the + // engine advertise controlFlow and stamp cn1.hardened=true for byte-unmodified output. + guardedMethods = 0; + oversizedMethods = 0; + return classBytes; + } + return cw.toByteArray(); + } + + private static MethodNode findClinit(ClassNode cn) { + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if ("".equals(mn.name) && "()V".equals(mn.desc)) { + return mn; + } + } + } + return null; + } + + private int countGuardable(ClassNode cn) { + int n = 0; + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if (isGuardable(mn)) { + n++; + } + } + } + return n; + } + + private boolean isGuardable(MethodNode mn) { + if (mn.instructions == null || mn.instructions.size() == 0) { + return false; + } + 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, String guardField) { + InsnList pre = new InsnList(); + LabelNode ok = new LabelNode(); + pre.add(new FieldInsnNode(Opcodes.GETSTATIC, cn.name, guardField, GUARD_DESC)); + // if (zq$cf > 0) goto ok; -- always taken at runtime, unprovable statically. + pre.add(new JumpInsnNode(Opcodes.IFGT, ok)); + // dead arm: throw new RuntimeException(); -- never reached. + 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); + } + + /** A guard field name not already declared by {@code cn} (lengthens the suffix until free). */ + private String resolveGuardField(ClassNode cn) { + String name = GUARD_FIELD; + if (cn.fields != null) { + boolean clash = true; + while (clash) { + clash = false; + for (FieldNode fn : cn.fields) { + if (name.equals(fn.name)) { + name = name + "$"; + clash = true; + break; + } + } + } + } + return name; + } + + private void addGuardField(ClassNode cn, String guardField) { + if (cn.fields == null) { + cn.fields = new java.util.ArrayList(); + } + cn.fields.add(new FieldNode(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC, + guardField, GUARD_DESC, null, null)); + } + + private void initGuardField(ClassNode cn, String guardField) { + InsnList init = new InsnList(); + // zq$cf = Runtime.getRuntime().availableProcessors(); -- contractually >= 1 on every JVM, + // and a runtime call the optimizer/decompiler cannot fold, so the guard is always taken and + // 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, guardField, 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..a1d6242c252 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java @@ -0,0 +1,270 @@ +/* + * 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.InputStream; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Set; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; + +/** + * A {@link ClassWriter} that resolves the class hierarchy from the application and + * 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. + * + *

Resolution has two stages. First it tries to LOAD the two types (precise when + * both, and their supertypes, are available). If loading throws -- typically because + * a shared supertype is supplied only by the target platform and is absent from the + * supplied jars -- it resolves the hierarchy from the class BYTES instead: reading the + * {@code super_class} name of each type does not require loading the (absent) supertype, + * so a real common supertype such as {@code Base} is still found. Only when the bytes + * cannot be read either does it fall back to {@code java/lang/Object}. Collapsing to + * {@code Object} too eagerly is not merely imprecise: if the merged value is then used + * where {@code Base} is expected, {@code Object} is not assignable to {@code Base} and + * the generated {@code StackMapTable} fails verification, rejecting a valid application. + */ +public final class FrameClassWriter extends ClassWriter { + + private final ClassLoader hierarchy; + private boolean hierarchyIncomplete; + + public FrameClassWriter(int flags, ClassLoader hierarchy) { + super(flags); + this.hierarchy = hierarchy; + } + + /** + * True when a frame merge had to collapse to {@code java/lang/Object} because the type hierarchy was + * INCOMPLETE -- a supertype on the path to the real common base could not be read (absent from the + * supplied jars), not because the two types genuinely share only {@code Object}. In that case the + * recomputed {@code Object} stack-map type may be weaker than the type the target expects (e.g. a + * shared {@code Base} reachable only through two missing intermediates), so the caller must ship the + * class UNHARDENED -- with its original, javac-computed frames -- rather than a possibly-invalid one. + */ + public boolean isHierarchyIncomplete() { + return hierarchyIncomplete; + } + + @Override + protected String getCommonSuperClass(String type1, String type2) { + if (type1.equals(type2)) { + return type1; + } + if (hierarchy == null) { + return commonSuperFromBytes(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 (or a supertype of it) can't be LOADED -- e.g. a shared Base supplied only by the + // target platform. Resolve from the class bytes so a real common supertype is still found + // rather than collapsing to Object, which would fail StackMapTable verification. + return commonSuperFromBytes(type1, type2); + } + } + + /** + * Finds a common supertype from the class bytes, which does not require the (possibly absent) + * supertypes to be loadable. Mirrors the load-based logic exactly, using byte-based assignability + * that walks BOTH {@code super_class} and {@code interfaces[]}: if one type is a supertype (a + * superclass OR an implemented/extended interface) of the other it is returned; a merge involving an + * unrelated interface is {@code java/lang/Object} (the verifier treats an interface as Object); two + * unrelated classes resolve to the first shared {@code super_class}. Falls back to + * {@code java/lang/Object} only when the bytes cannot be read. + */ + private String commonSuperFromBytes(String type1, String type2) { + // These are always non-array reference types (internal names like "pkg/A"), never array + // descriptors like "[Lpkg/A;", so readerFor()'s ".class" lookup is correct. That is ASM's + // contract for getCommonSuperClass, verified empirically against ASM 9.8 (the shaded version): + // for a merge of same-dimension reference arrays A[] and B[], ASM strips the array dimension and + // hands us the ELEMENT names "pkg/A" and "pkg/B", then re-wraps OUR result back into "[Lpkg/Base;" + // itself -- so a shared but unloadable Base still resolves to Base[] via the scalar path below. + // A merge of array-vs-scalar, or of arrays of different dimension, is resolved to Object by ASM + // internally and getCommonSuperClass is never called at all. So an array-descriptor branch here + // would be unreachable dead code; the scalar resolution is the whole contract. + if (type1.equals(type2)) { + return type1; + } + if (isAssignableFromBytes(type1, type2)) { + return type1; + } + if (isAssignableFromBytes(type2, type1)) { + return type2; + } + if (isInterfaceFromBytes(type1) || isInterfaceFromBytes(type2)) { + // A merge that involves an interface is java/lang/Object, and -- unlike the class fall-through + // below -- this is SOUND even when the interface hierarchy is only partly readable, so it is + // deliberately NOT flagged incomplete. The bytecode verifier treats every class as assignable + // to every (loadable) interface (JVMS 4.10.1.2 isJavaAssignable: a class is assignable to To + // when To is an interface, deferring the real check to the runtime invokeinterface). So an + // Object here still verifies wherever the merged value is later used as some interface I -- + // including a later invokeinterface I -- regardless of whether an intermediate interface such + // as a missing target-only J (with J extends I) was readable. Discarding the class here would + // only un-harden a class the target verifies fine. + return "java/lang/Object"; + } + String c = superNameFromBytes(type1); + Set seen = new LinkedHashSet(); + // Stop before Object: isAssignableFromBytes(Object, type2) is unconditionally true, so testing it + // here would return Object as a "found" common super and skip the incompleteness check below -- + // masking the case where type2's chain is broken by a missing intermediate that actually extends a + // nearer base (type1=A extends Base, type2=B extends an absent Missing extends Base). Reaching + // Object in this walk means no CLOSER readable ancestor was found, which is exactly the fall-through. + while (c != null && !"java/lang/Object".equals(c) && seen.add(c)) { + if (isAssignableFromBytes(c, type2)) { + return c; + } + c = superNameFromBytes(c); + } + // Falling through to Object. That is only SOUND when both chains are fully readable up to Object + // and genuinely share no closer ancestor. If either chain is broken by a missing intermediate, the + // real common base (reachable only through the absent class) is invisible here, so Object is a + // guess that may be too weak -- flag it so the caller ships the class unhardened with its original + // frames instead of emitting a possibly-invalid recomputed one. + if (!chainReachesObject(type1) || !chainReachesObject(type2)) { + hierarchyIncomplete = true; + } + return "java/lang/Object"; + } + + /** + * True when {@code type}'s superclass chain can be walked entirely to {@code java/lang/Object} (or a + * readable root) through readable bytes. False when a link in the chain is absent from the supplied + * jars, which means the byte-based resolver cannot see a shared base that lies beyond that gap. + */ + private boolean chainReachesObject(String type) { + String c = type; + Set seen = new HashSet(); + while (c != null && seen.add(c)) { + if ("java/lang/Object".equals(c)) { + return true; + } + ClassReader cr = readerFor(c); + if (cr == null) { + return false; + } + c = cr.getSuperName(); + } + return true; + } + + /** True when {@code sub} is {@code sup}, or extends/implements it (transitively) per the class bytes. */ + private boolean isAssignableFromBytes(String sup, String sub) { + if (sup.equals(sub) || "java/lang/Object".equals(sup)) { + return true; + } + Set visited = new HashSet(); + Deque stack = new ArrayDeque(); + stack.push(sub); + while (!stack.isEmpty()) { + String cur = stack.pop(); + if (!visited.add(cur)) { + continue; + } + if (cur.equals(sup)) { + return true; + } + ClassReader cr = readerFor(cur); + if (cr == null) { + continue; + } + if (cr.getSuperName() != null) { + stack.push(cr.getSuperName()); + } + String[] interfaces = cr.getInterfaces(); + if (interfaces != null) { + for (int i = 0; i < interfaces.length; i++) { + stack.push(interfaces[i]); + } + } + } + return false; + } + + private boolean isInterfaceFromBytes(String type) { + ClassReader cr = readerFor(type); + return cr != null && (cr.getAccess() & Opcodes.ACC_INTERFACE) != 0; + } + + /** The {@code super_class} name from {@code type}'s bytes, or null when Object or unreadable/absent. */ + private String superNameFromBytes(String type) { + if (type == null || "java/lang/Object".equals(type)) { + return null; + } + ClassReader cr = readerFor(type); + return cr == null ? null : cr.getSuperName(); + } + + /** A {@link ClassReader} over {@code type}'s bytes from the hierarchy loader, or null if unreadable. */ + private ClassReader readerFor(String type) { + if (type == null || hierarchy == null) { + return null; + } + InputStream in = hierarchy.getResourceAsStream(type + ".class"); + if (in == null) { + return null; + } + try { + return new ClassReader(in); + } catch (Throwable t) { + return null; + } finally { + try { + in.close(); + } catch (Throwable ignore) { + // best effort + } + } + } +} 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..d767f5fbf6c --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java @@ -0,0 +1,901 @@ +/* + * 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, maxMemberNamingScope(inClasses)), + 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; + int concatLiterals = 0; + int legacyInterfaceConstants = 0; + int oversizedLiterals = 0; + int condyLiterals = 0; + int indyLiterals = 0; + int shortLiterals = 0; + int hierarchyIncompleteSkips = 0; + int externallyReadConstants = 0; + int clinitFullLiterals = 0; + int annotationLiterals = 0; + int jarExcludedLiterals = 0; + int libraryExcludedLiterals = 0; + int sourcePreservedConstants = 0; + boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform()); + if (stringsApplied) { + // In "constants" mode, first collect the values declared as static-final String + // 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); + } + } + // On a ParparVM-C target a compile-time literal is a never-interned constant-pool object, + // while an encrypted app copy is intern()ed, so encrypting an app value that ALSO appears as + // a literal in an UNHARDENED library class (core, java-runtime, dependencies -- never in + // 'renamed') would break a valid literal == against the library copy that held before + // hardening. Collect those library literals and exclude them from encryption. On a real-JVM + // or Android target every compile-time literal is interned to the same pool intern() uses, so + // the constraint does not apply and the scan is skipped (full coverage). + java.util.Set libraryLiterals = null; + if (translatesThroughParparVMC(cfg.getPlatform()) && req.getLibraryJars() != null) { + libraryLiterals = new java.util.HashSet(); + for (File lib : req.getLibraryJars()) { + collectJarLiterals(lib, libraryLiterals); + } + } + final java.util.Set libLiterals = + libraryLiterals != null && !libraryLiterals.isEmpty() ? libraryLiterals : null; + java.util.Set libraryExcluded = new java.util.HashSet(); + // On a target that compiles the .java/.kt source bundled in the app jar against the + // transformed classes (Android places those sources in src/main/java and the hardened classes + // in libs/userClasses.jar), stripping a static-final String ConstantValue would break a + // case-label/annotation/const-initializer reference to it in that source. Collect the + // identifiers such sources reference so those constants keep their ConstantValue. + java.util.Set srcNames = null; + if (compilesCarriedSource(cfg.getPlatform())) { + java.util.Set ids = new java.util.HashSet(); + for (Map.Entry e : nonClass.asMap().entrySet()) { + String name = e.getKey().toLowerCase(); + if (name.endsWith(".java") || name.endsWith(".kt")) { + collectSourceIdentifiers(new String(e.getValue(), + java.nio.charset.Charset.forName("UTF-8")), ids); + } + } + if (!ids.isEmpty()) { + srcNames = ids; + } + } + final java.util.Set sourceReferencedNames = srcNames; + // Fields read by a GETSTATIC anywhere in the jar (a non-inlined constant read from generated + // bytecode): their ConstantValue must not be moved to , which would change reentrant + // initialization ordering. Scanned once over the renamed classes so owner/name match the + // renamed field names the transform checks. + final java.util.Set externallyReadStaticFields = new java.util.HashSet(); + for (byte[] cls : renamed.values()) { + StringEncryptTransform.collectGetStaticStringReads(cls, externallyReadStaticFields, hierarchy); + } + // Pass 1 (from a snapshot of the input bytes): transform every class, tally the counts, and + // collect the values any class could NOT encrypt (a method too full for the decode call, or a + // class whose pool cannot fit the decoder). A value encrypted+interned in one class but left + // plaintext in another would fail a valid literal == on ParparVM's deduplicated pool, so + // those values must be excluded jar-wide -- encrypted everywhere or nowhere. + java.util.Map original = new java.util.HashMap(renamed); + java.util.Set jarExcluded = new java.util.HashSet(); + for (Map.Entry e : renamed.entrySet()) { + StringEncryptTransform t = new StringEncryptTransform( + cfg.isEncryptAllStrings(), seed, hierarchy, constantValues, null); + t.setLibraryLiterals(libLiterals); + t.setSourceReferencedNames(sourceReferencedNames); + t.setExternallyReadStaticFields(externallyReadStaticFields); + e.setValue(t.transform(e.getValue())); + jarExcluded.addAll(t.getNewlyExcluded()); + libraryExcluded.addAll(t.getLibraryExcludedValues()); + sourcePreservedConstants += t.getSourcePreservedConstantCount(); + externallyReadConstants += t.getExternallyReadConstantCount(); + encryptedStrings += t.getEncryptedCount(); + concatLiterals += t.getConcatLiteralCount(); + legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); + oversizedLiterals += t.getOversizedLiteralCount(); + condyLiterals += t.getCondyLiteralCount(); + indyLiterals += t.getIndyLiteralCount(); + shortLiterals += t.getShortLiteralCount(); + hierarchyIncompleteSkips += t.isHierarchyIncompleteSkipped() ? 1 : 0; + clinitFullLiterals += t.getClinitFullLiteralCount(); + annotationLiterals += t.getAnnotationLiteralCount(); + } + // Pass 2 only when pass 1 found values it could not consistently encrypt: re-transform every + // class from the original bytes with those values excluded (so a value is plaintext in all + // classes or none), replacing pass 1's outputs and re-tallying. In the common case the + // exclusion set is empty and pass 1's result stands -- one transform per class. + if (!jarExcluded.isEmpty()) { + encryptedStrings = 0; + concatLiterals = 0; + legacyInterfaceConstants = 0; + oversizedLiterals = 0; + condyLiterals = 0; + indyLiterals = 0; + shortLiterals = 0; + hierarchyIncompleteSkips = 0; + externallyReadConstants = 0; + clinitFullLiterals = 0; + annotationLiterals = 0; + sourcePreservedConstants = 0; + libraryExcluded.clear(); + for (Map.Entry e : renamed.entrySet()) { + StringEncryptTransform t = new StringEncryptTransform( + cfg.isEncryptAllStrings(), seed, hierarchy, constantValues, jarExcluded); + t.setLibraryLiterals(libLiterals); + t.setSourceReferencedNames(sourceReferencedNames); + t.setExternallyReadStaticFields(externallyReadStaticFields); + e.setValue(t.transform(original.get(e.getKey()))); + libraryExcluded.addAll(t.getLibraryExcludedValues()); + sourcePreservedConstants += t.getSourcePreservedConstantCount(); + externallyReadConstants += t.getExternallyReadConstantCount(); + encryptedStrings += t.getEncryptedCount(); + concatLiterals += t.getConcatLiteralCount(); + legacyInterfaceConstants += t.getLegacyInterfaceConstantCount(); + oversizedLiterals += t.getOversizedLiteralCount(); + condyLiterals += t.getCondyLiteralCount(); + indyLiterals += t.getIndyLiteralCount(); + shortLiterals += t.getShortLiteralCount(); + hierarchyIncompleteSkips += t.isHierarchyIncompleteSkipped() ? 1 : 0; + clinitFullLiterals += t.getClinitFullLiteralCount(); + annotationLiterals += t.getAnnotationLiteralCount(); + } + } + jarExcludedLiterals = jarExcluded.size(); + libraryExcludedLiterals = libraryExcluded.size(); + } + + int guardedMethods = 0; + int oversizedGuardMethods = 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); + } + oversizedGuardMethods += t.getOversizedMethods(); + 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()) { + // Record the real source filename for classes whose SourceFile the engine just stripped and + // whose name cannot reconstruct it (Kotlin Screen.kt, a package-private class in Main.java), + // captured from the INPUT classes before the strip. Done before finalize so the id covers it. + MappingWriter.injectSourceFiles(mappingFile, collectSourceFiles(inClasses)); + mappingId = MappingWriter.finalizeMapping(mappingFile, ENGINE_VERSION, PROGUARD_VERSION, + cfg.getPlatform(), req.getBuildKey()); + } + + 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 (stringsApplied && concatLiterals > 0) { + // javac from JDK 9 compiles string concatenation to an invokedynamic whose literal + // fragments live in the StringConcatFactory recipe, not in an LDC or a ConstantValue, so + // the string channels cannot reach them. Report it rather than let a build believe it is + // fully string-encrypted; -XDstringConcat=inline (or an older -target) emits StringBuilder + // the engine does encrypt. + result.getWarnings().add(concatLiterals + " string-concatenation literal group(s) compiled " + + "to invokedynamic (JDK 9+ javac) were not encrypted; compile with " + + "-XDstringConcat=inline or an older -target to encrypt concatenation literals"); + } + if (stringsApplied && legacyInterfaceConstants > 0) { + // A pre-Java-8 interface cannot host a /decoder, so its own static-final String + // constants stay plaintext in that class file. Reads elsewhere were inlined and are + // encrypted; report the declaring-interface leak rather than ship it unremarked. + result.getWarnings().add(legacyInterfaceConstants + " static-final String constant(s) on " + + "pre-Java-8 interface(s) were not encrypted (such interfaces cannot host the " + + "decoder); recompile the interface at -target 8+ to encrypt its constant pool"); + } + if (stringsApplied && condyLiterals > 0) { + // Java 11+ can carry plaintext through an LDC constant-dynamic whose bootstrap arguments + // hold the string; it is resolved at link time, so it cannot be rewritten to a decode call. + result.getWarnings().add(condyLiterals + " constant-dynamic (LDC ConstantDynamic) site(s) " + + "carrying string bootstrap arguments were not encrypted; such constants are " + + "resolved at link time and remain in plaintext"); + } + if (stringsApplied && indyLiterals > 0) { + // A custom invokedynamic (not a StringConcatFactory concat) can carry a plaintext string in + // its bootstrap arguments, resolved at link time, which no LDC/ConstantValue pass reaches. + result.getWarnings().add(indyLiterals + " invokedynamic site(s) carrying string bootstrap " + + "arguments (a non-concat bootstrap emitted by a bytecode generator) were not " + + "encrypted; such constants are resolved at link time and remain in plaintext"); + } + if (stringsApplied && cfg.isEncryptAllStrings() && shortLiterals > 0) { + // One- and two-character literals are left plaintext (the decoder overhead dwarfs them, and a + // two-char value is trivially brute-forced even encrypted). Disclose so strings:all is honest. + result.getWarnings().add(shortLiterals + " distinct one- or two-character string literal(s) " + + "were left in plaintext (too short to be worth encrypting); a short value is " + + "trivially recovered even when encrypted, so this is a disclosure note"); + } + if (hierarchyIncompleteSkips > 0) { + // A class whose frame merge could not be resolved past a supertype absent from the supplied + // jars is shipped UNHARDENED (original frames) rather than risk an Object-widened frame that + // fails on-device verification. Disclose it so the coverage claim is honest. + result.getWarnings().add(hierarchyIncompleteSkips + " class(es) were left unhardened because a " + + "supertype needed to compute their stack-map frames was absent from the supplied " + + "library jars; supplying that platform's jars lets them be hardened"); + } + if (stringsApplied && externallyReadConstants > 0) { + // A constant read by a GETSTATIC (non-inlined, from generated bytecode) keeps its ConstantValue + // -- moving it into would change reentrant-initialization ordering -- so it stays + // plaintext. Disclose it rather than let strings:all imply it was encrypted. + result.getWarnings().add(externallyReadConstants + " static-final String constant(s) are read by " + + "a non-inlined GETSTATIC and were left in plaintext to preserve class-initialization " + + "ordering; move such a secret out of a constant to hide it"); + } + if (stringsApplied && oversizedLiterals > 0) { + // A literal longer than ~21,845 chars can widen to a 3-byte-per-char constant whose + // ciphertext overflows the 65535-byte constant pool, so it is left plaintext. Report it + // rather than let an strings:all build claim it encrypted everything. + result.getWarnings().add(oversizedLiterals + " string literal(s) were too large to encrypt " + + "(their ciphertext would overflow the 65535-byte constant pool) and remain in " + + "plaintext; move a large embedded secret/blob out of a string constant to hide it"); + } + if (stringsApplied && clinitFullLiterals > 0) { + result.getWarnings().add(clinitFullLiterals + " static-final String constant(s) were left in " + + "plaintext because the class is near a JVM limit (its static initializer's size or " + + "the constant pool) and could not hold the decode step; those field values are " + + "dead once javac inlines their reads, so this is a disclosure note"); + } + if (stringsApplied && jarExcludedLiterals > 0) { + // Values that at least one class could not encrypt (a method already near the 65535-byte + // limit, or a class whose constant pool cannot fit the decoder) are left plaintext in EVERY + // class, so a decoded+interned copy never compares != to a plaintext copy on ParparVM. + result.getWarnings().add(jarExcludedLiterals + " distinct string value(s) were left in " + + "plaintext in every class because at least one class could not encrypt them (a " + + "method or constant pool near the JVM limit); those literals stay readable"); + } + if (stringsApplied && sourcePreservedConstants > 0) { + // A static-final String referenced by a bundled .java/.kt source in a constant-expression + // context keeps its ConstantValue so that source still compiles; disclose the plaintext. + result.getWarnings().add(sourcePreservedConstants + " static-final String constant(s) kept " + + "their plaintext ConstantValue because a bundled .java/.kt source may reference them " + + "as a compile-time constant (a case label or annotation value), which requires the " + + "attribute to compile; their inlined reads are still encrypted"); + } + if (stringsApplied && libraryExcludedLiterals > 0) { + // Values that also appear as a literal in an unhardened library class are left plaintext so a + // literal == against the library's (never interned) constant-pool copy still holds on ParparVM. + result.getWarnings().add(libraryExcludedLiterals + " distinct string value(s) were left in " + + "plaintext because an unhardened framework/dependency class also holds them as a " + + "literal (encrypting only the app copy would break a valid reference-equality check " + + "against the library copy on the ParparVM native targets); those literals stay readable"); + } + if (stringsApplied && annotationLiterals > 0) { + // Annotation element values live in the annotation metadata, not an LDC or a ConstantValue, + // so no encryption channel reaches them. CN1 has no reflection to read them back, so this is + // a disclosure note; don't put a secret in an annotation and expect it hidden. + result.getWarnings().add(annotationLiterals + " string(s) in annotation values/defaults were " + + "not encrypted (they live in annotation metadata, not code); do not place a secret " + + "in an annotation"); + } + if (controlFlowApplied && oversizedGuardMethods > 0) { + result.getWarnings().add(oversizedGuardMethods + " method(s) were left with plain control " + + "flow because their class is already near a JVM limit (a method near the " + + "65535-byte limit, or a constant pool near the 65535-entry limit) and adding the " + + "guard would overflow it"); + } + if (req.getReportFile() != null) { + writeReport(req.getReportFile(), cfg, result); + } + 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) { + // An off profile skips everything: harden() returns SKIPPED for OFF (before any transform), + // even when a stale individual override such as harden.rename=true is still present. Match + // that here so a non-entitled build that explicitly set harden.level=off is skipped rather + // than rejected as not-entitled just because an override was left behind. + if (cfg.getProfile() == HardeningProfile.OFF) { + return false; + } + // A per-platform opt-out means nothing runs for this target -- so a non-entitled build with + // harden..enabled=false is skipped, not rejected as not-entitled. + if (!cfg.isPlatformEnabled()) { + 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); + } + + /** + * True for a target that javac/kotlinc-compiles the {@code .java}/{@code .kt} sources bundled in the + * app jar against the transformed classes. Android does (the sources land in {@code src/main/java} + * and the hardened classes in {@code libs/userClasses.jar}); iOS routes carried source into the + * resource tree and never compiles it, win/linux compile only the ParparVM translator's generated + * {@code .java}, and JavaSE runs the bytecode directly. On such a target a stripped + * {@code ConstantValue} would break a constant-expression reference from the carried source. + */ + static boolean compilesCarriedSource(String platform) { + return "and".equals(platform) || "android".equals(platform); + } + + /** + * Maps the original (dotted) class name to its {@code SourceFile} attribute, for the classes worth + * recording in the mapping: those whose filename differs from the synthesized {@code .java} + * default, i.e. Kotlin sources and package-private classes declared in a differently named file. Read + * from the INPUT classes, before the engine strips the attribute. + */ + private static java.util.Map collectSourceFiles(java.util.Map classes) { + java.util.Map out = new java.util.HashMap(); + for (java.util.Map.Entry e : classes.entrySet()) { + final String[] sf = new String[1]; + new org.objectweb.asm.ClassReader(e.getValue()).accept( + new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) { + @Override + public void visitSource(String source, String debug) { + sf[0] = source; + } + // NOT SKIP_DEBUG -- that flag skips the SourceFile attribute, which is exactly what + // visitSource reports and what we are here to capture. + }, org.objectweb.asm.ClassReader.SKIP_CODE | org.objectweb.asm.ClassReader.SKIP_FRAMES); + if (sf[0] != null && sf[0].length() > 0 && !sf[0].equals(defaultSourceFile(e.getKey()))) { + out.put(e.getKey().replace('/', '.'), sf[0]); + } + } + return out; + } + + /** + * The largest member NAMING scope in {@code classes}, which the obfuscation dictionary must exceed so + * ProGuard never exhausts it and falls back to short names. Two scopes matter, both beyond the class + * count: + *

    + *
  • Fields: a class cannot declare two fields with the same name, so a class's fields all need + * distinct names -- the per-class field count.
  • + *
  • Methods: ProGuard cannot give two same-descriptor methods in one inheritance hierarchy the + * same obfuscated name without creating an accidental override, so same-descriptor methods + * accumulate ACROSS a hierarchy, not just within one class. The exact per-hierarchy count needs + * a full hierarchy walk; the count of methods sharing a descriptor across the WHOLE jar is a + * safe (and, for a generated deep hierarchy, tight) upper bound.
  • + *
+ */ + static int maxMemberNamingScope(java.util.Map classes) { + final int[] maxFields = new int[1]; + final java.util.Map methodsByDescriptor = new java.util.HashMap(); + for (byte[] bytes : classes.values()) { + final int[] fields = new int[1]; + new org.objectweb.asm.ClassReader(bytes).accept( + new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) { + @Override + public org.objectweb.asm.FieldVisitor visitField(int a, String n, String d, + String s, Object v) { + fields[0]++; + return null; + } + + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int a, String n, String desc, + String s, String[] e) { + Integer c = methodsByDescriptor.get(desc); + methodsByDescriptor.put(desc, c == null ? 1 : c + 1); + return null; + } + }, org.objectweb.asm.ClassReader.SKIP_CODE | org.objectweb.asm.ClassReader.SKIP_DEBUG + | org.objectweb.asm.ClassReader.SKIP_FRAMES); + if (fields[0] > maxFields[0]) { + maxFields[0] = fields[0]; + } + } + int maxMethodsPerDescriptor = 0; + for (int c : methodsByDescriptor.values()) { + if (c > maxMethodsPerDescriptor) { + maxMethodsPerDescriptor = c; + } + } + return Math.max(maxFields[0], maxMethodsPerDescriptor); + } + + /** The {@code .java} a retrace synthesizes from an internal class name (its default). */ + private static String defaultSourceFile(String internalName) { + String simple = internalName; + int slash = simple.lastIndexOf('/'); + if (slash >= 0) { + simple = simple.substring(slash + 1); + } + int dollar = simple.indexOf('$'); + if (dollar > 0) { + simple = simple.substring(0, dollar); + } + return simple + ".java"; + } + + /** Adds every Java/Kotlin identifier token in {@code source} to {@code out} (a safe over-set). */ + private static void collectSourceIdentifiers(String source, java.util.Set out) { + int n = source.length(); + int i = 0; + while (i < n) { + char c = source.charAt(i); + if (Character.isJavaIdentifierStart(c)) { + int start = i; + i++; + while (i < n && Character.isJavaIdentifierPart(source.charAt(i))) { + i++; + } + out.add(source.substring(start, i)); + } else { + i++; + } + } + } + + /** + * Collects every string literal ({@code LDC} operand or {@code static final String} + * {@code ConstantValue}) in every class of {@code jar} into {@code out}. Used to gather the + * literals of the unhardened library jars so an app value shared with a framework/dependency class + * is not encrypted (which would break a literal {@code ==} against the un-interned library copy on + * ParparVM). A jar that cannot be read is skipped -- a library the engine cannot scan simply + * contributes no exclusions rather than aborting the build. + */ + private static void collectJarLiterals(File jar, java.util.Set out) { + if (jar == null || !jar.isFile()) { + return; + } + try { + java.io.FileInputStream fi = new java.io.FileInputStream(jar); + try { + java.util.zip.ZipInputStream zis = new java.util.zip.ZipInputStream(fi); + java.util.zip.ZipEntry entry; + byte[] buf = new byte[8192]; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory() || !entry.getName().endsWith(".class")) { + continue; + } + java.io.ByteArrayOutputStream bout = new java.io.ByteArrayOutputStream(); + int r; + while ((r = zis.read(buf)) >= 0) { + bout.write(buf, 0, r); + } + try { + StringEncryptTransform.collectAllLiterals(bout.toByteArray(), out); + } catch (RuntimeException ignored) { + // A class ASM cannot parse (a newer format than this ASM, a malformed entry) + // contributes no exclusions; it must not fail the scan of the rest of the jar. + } + } + } finally { + fi.close(); + } + } catch (IOException ex) { + // An unreadable library jar simply contributes no exclusions. + } + } + + /** + * A classloader over the (renamed) application classes plus the library jars, for stack-map + * frame computation. JDK library classes resolve through the parent (bootstrap) loader, so the + * 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"); + // Serialize the warnings too: a warning records a known limitation (e.g. plaintext left in a + // JDK 9+ concat recipe, or a transform skipped as unsafe for the platform). A consumer reading + // the report -- not the forked-process log -- would otherwise see "transforms":["strings:all"] + // with no hint that some plaintext was excluded, which the doc promises the report surfaces. + sb.append(" \"warnings\": ["); + List w = r.getWarnings(); + for (int i = 0; i < w.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append('"').append(json(w.get(i))).append('"'); + } + sb.append("]\n"); + sb.append("}\n"); + writeText(reportFile, sb.toString()); + } + + 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..0541a9c34a7 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningProfile.java @@ -0,0 +1,83 @@ +/* + * 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 (opaque predicates). */ + AGGRESSIVE, + /** + * Raises control-flow obfuscation intensity on top of aggressive: two opaque-predicate + * guards per eligible method instead of one ({@link HardeningConfig#getControlFlowIntensity()}). + * It adds no new kind of transform -- in particular there is no reflective-name hiding, since + * Codename One has no runtime reflection for such names to feed. + */ + PARANOID; + + /** Parses a level name case-insensitively; returns {@code null} for an unknown value. */ + 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..1cac10a8cd0 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.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.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.Opcodes; + +/** + * Tier 2 keep rules, derived from the input classes with ASM. Codename One has no reflection -- + * {@code Class.forName} never resolved an obfuscated app class -- so there is nothing to keep for a + * class named only by a string. What ProGuard cannot infer declaratively is the naming + * convention that binds a native interface to its generated peer: for a native interface + * {@code com.foo.Bar} the build produces {@code com.foo.BarImpl} / {@code com.foo.BarStub} and + * resolves them by name. This scanner finds the native interfaces (phase 1) and keeps exactly those + * peers (phase 2), which is far narrower than the previous {@code **Impl} / {@code **Stub}. + */ +public final class InputJarKeepScanner { + + private static final String NATIVE_INTERFACE = "com/codename1/system/NativeInterface"; + + /** internal name -> its direct super-interfaces (from the class's interfaces[]). */ + private final java.util.Map interfacesOf = + new java.util.HashMap(); + private final Set nativeInterfaceTypes = new LinkedHashSet(); + + /** Scans every class in {@code classesByInternalName} (keyed {@code a/b/C}). */ + public void scan(Map classesByInternalName) { + for (byte[] classBytes : classesByInternalName.values()) { + ClassReader cr = new ClassReader(classBytes); + cr.accept(new HierarchyCollector(), ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG + | ClassReader.SKIP_FRAMES); + } + // A type is a native interface if NativeInterface is in its transitive super-interface + // closure. Resolve transitively across the classes we saw (an interface may extend another + // native interface rather than NativeInterface directly). + for (String type : interfacesOf.keySet()) { + if (extendsNativeInterface(type, new LinkedHashSet())) { + nativeInterfaceTypes.add(type); + } + } + } + + private boolean extendsNativeInterface(String type, Set visiting) { + if (!visiting.add(type)) { + return false; + } + String[] ifaces = interfacesOf.get(type); + if (ifaces == null) { + return false; + } + for (String i : ifaces) { + if (NATIVE_INTERFACE.equals(i) || extendsNativeInterface(i, visiting)) { + return true; + } + } + return false; + } + + /** The derived keep rules: the generated {@code Impl}/{@code Stub} peer of each native interface. */ + public List keepRules() { + List rules = new ArrayList(); + for (String type : nativeInterfaceTypes) { + String dotted = type.replace('/', '.'); + rules.add("-keep class " + dotted + "Impl { *; }"); + rules.add("-keep class " + dotted + "Stub { *; }"); + } + return rules; + } + + /** Visible for testing: the native interface types found in the input (dotted names). */ + List nativeInterfaces() { + List out = new ArrayList(); + for (String type : nativeInterfaceTypes) { + out.add(type.replace('/', '.')); + } + return out; + } + + private final class HierarchyCollector extends ClassVisitor { + HierarchyCollector() { + super(Opcodes.ASM9); + } + + @Override + public void visit(int version, int access, String name, String signature, + String superName, String[] interfaces) { + interfacesOf.put(name, interfaces == null ? new String[0] : interfaces); + } + } +} diff --git a/maven/cn1-hardening/src/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..79993aa46da --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java @@ -0,0 +1,221 @@ +/* + * 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 { + // Every non-class entry -- resources, native bundles, AND any .java/.kt/.swift/ + // .m/.h/.cs source a CN1Lib bundles -- is carried byte-for-byte. Carrying a + // .java/.kt source unchanged while the engine renames classes is SAFE and cannot + // produce a "cannot find symbol" against the renamed jar, because no builder ever + // javac-compiles an unzip'd source against an engine-renamed classpath: + // - Android: the engine does NOT rename (AndroidGradleBuilder.hardeningRename- + // Supported()==false -> renameEnabled=false). R8 is the sole renamer and runs + // AFTER javac has compiled the app classes together with any bundled native + // sources, so R8 renames the whole set consistently -- imports still resolve. + // - iOS/mac/watch/tv: unzip routes these sources into the RESOURCE tree + // (IPhoneBuilder passes resDir as unzip's sourceDir); the only javac compiles + // the generated stub dir, never the carried sources. Native impls are Obj-C. + // - win/linux: javac compiles only the ParparVM translator-GENERATED .java, + // emitted from already-renamed bytecode, so it is internally consistent. + // - javascript: javac targets the JS port's own sources against staged classes; + // bundled app native sources are .js, and string encryption is off on JS. + // - javase/desktop: runs the bytecode directly and recompiles no sources. + // So there is no rename-vs-source mismatch to guard; a source-text keep scanner + // would be dead code. Revisit only if a builder starts compiling unzip's sourceDir + // against an engine-renamed classpath. + // + // A resource entry is copied under its ORIGINAL path, so a PACKAGE-RELATIVE + // Class.getResourceAsStream("x.properties") from an engine-renamed class -- which the + // runtime resolves under the class's now-obfuscated package -- would miss it. This is + // a DELIBERATE design choice, not an oversight: Codename One's own resource model + // uses absolute paths (Display.getResourceAsStream("/x"), the theme .res), which are + // unaffected by package renaming, so package names are obfuscated on purpose (see the + // BuiltinKeepRules packageNamesAreNotKept test). The only exposure is a bundled + // third-party dependency that loads a package-relative resource; adapting resource + // paths by the class mapping cannot run here (the mapping does not exist until after + // ProGuard) and would forfeit package obfuscation. Such a dependency uses + // harden.keep to keep its package-relative-resource classes (which keeps their + // package path); this limitation is documented in App-Hardening.asciidoc. + nonClass.put(name, data); + } + } + 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..a370bbe3f53 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java @@ -0,0 +1,172 @@ +/* + * 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); + } + } + + /** + * Injects R8-style {@code sourceFile} metadata comments into the mapping so a retrace can report the + * real source filename. The engine strips the {@code SourceFile} attribute and ProGuard's mapping + * records no filename, so without this a Kotlin class ({@code Screen.kt}) or a package-private class + * declared in a differently named file retraces to a synthesized {@code .java} that does not + * exist. Only classes whose recorded filename differs from that synthesized default are written (the + * ordinary {@code Foo}/{@code Foo.java} case needs no comment). The comment is INDENTED so the retrace + * parser attaches it to the preceding class line, matching R8's own emission. + * + * @param sourceFileByFqcn original dotted class name to its {@code SourceFile}, for the classes worth + * recording; captured before the attribute was stripped. + */ + static void injectSourceFiles(File mappingFile, java.util.Map sourceFileByFqcn) + throws HardeningException { + if (sourceFileByFqcn == null || sourceFileByFqcn.isEmpty() || mappingFile == null + || !mappingFile.isFile()) { + return; + } + try { + java.util.List lines = Files.readAllLines(mappingFile.toPath(), + Charset.forName("UTF-8")); + StringBuilder out = new StringBuilder(); + for (String line : lines) { + out.append(line).append('\n'); + // A class line is unindented, contains " -> ", and ends with ':'. Its members follow + // indented, so inject the metadata comment right after it (also indented). + if (!line.isEmpty() && !Character.isWhitespace(line.charAt(0)) && line.endsWith(":")) { + int arrow = line.indexOf(" -> "); + if (arrow > 0) { + String original = line.substring(0, arrow).trim(); + String sf = sourceFileByFqcn.get(original); + if (sf != null) { + out.append(" # {\"id\":\"sourceFile\",\"fileName\":\"") + .append(jsonEscape(sf)).append("\"}\n"); + } + } + } + } + Files.write(mappingFile.toPath(), out.toString().getBytes(Charset.forName("UTF-8"))); + } catch (IOException e) { + throw new HardeningException("Could not inject source-file metadata into the mapping", e); + } + } + + /** + * Escapes a string for a JSON value. Besides {@code \} and {@code "}, a control character (newline, + * tab, ...) must be escaped too: the metadata is a single mapping-comment line, so a raw newline would + * split it and a raw control char is invalid JSON, either of which stops the reader from recovering the + * filename. Reachable for a Kotlin or package-private Java class stored in an unusually named file. + */ + private static String jsonEscape(String s) { + StringBuilder b = new StringBuilder(s.length() + 8); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '\\': b.append("\\\\"); break; + case '"': b.append("\\\""); break; + case '\b': b.append("\\b"); break; + case '\f': b.append("\\f"); break; + case '\n': b.append("\\n"); break; + case '\r': b.append("\\r"); break; + case '\t': b.append("\\t"); break; + default: + if (c < 0x20) { + b.append("\\u"); + String hex = Integer.toHexString(c); + for (int p = hex.length(); p < 4; p++) { + b.append('0'); + } + b.append(hex); + } else { + b.append(c); + } + } + } + return b.toString(); + } + + static String sha256Hex(byte[] data) throws HardeningException { + 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/MethodSize.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java new file mode 100644 index 00000000000..1df4ba72011 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.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 org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.LookupSwitchInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.TableSwitchInsnNode; + +/** + * Conservative UPPER-BOUND estimate of a method's encoded {@code Code} size in bytes, so a transform + * can refuse to grow a method past the JVM's 65,535-byte limit (which makes ASM throw + * {@code MethodTooLargeException} at write time and abort an otherwise-valid build). Every instruction + * is charged its widest legal encoding, so the estimate never undercounts -- a transform that stops + * short of {@link #SAFE_LIMIT} is safe even though ASM might have squeezed a little more in. + */ +final class MethodSize { + + /** The hard JVM limit on a method's bytecode array. */ + static final int LIMIT = 65535; + /** + * The constant-pool item count a transform must stay under. The hard JVM limit is 65535 entries; + * this sits below it by a margin that absorbs the per-item estimates a transform makes before it + * knows the exact pool growth. Shared by the string and control-flow transforms. + */ + static final int SAFE_POOL_ITEMS = 60000; + /** + * The size a transform must stay under. Below {@link #LIMIT} by a margin that absorbs both the + * upper-bound estimate's slack and the fact that the real limit is on the emitted bytes, which + * COMPUTE_MAXS/FRAMES can shift slightly. + */ + static final int SAFE_LIMIT = 60000; + + private MethodSize() { + } + + /** Upper-bound encoded byte size of {@code m}'s instructions, or 0 when it has none. */ + static int estimateBytes(MethodNode m) { + return m == null || m.instructions == null ? 0 : estimateBytes(m.instructions); + } + + /** Upper-bound encoded byte size of an instruction list. */ + static int estimateBytes(InsnList insns) { + int total = 0; + for (AbstractInsnNode n = insns.getFirst(); n != null; n = n.getNext()) { + total += estimateBytes(n); + } + return total; + } + + /** Upper-bound encoded byte size of a single instruction node (0 for labels/line/frame metadata). */ + static int estimateBytes(AbstractInsnNode n) { + switch (n.getType()) { + case AbstractInsnNode.LABEL: + case AbstractInsnNode.LINE: + case AbstractInsnNode.FRAME: + return 0; + case AbstractInsnNode.INSN: + return 1; + case AbstractInsnNode.INT_INSN: + return 3; + case AbstractInsnNode.VAR_INSN: + return 4; + case AbstractInsnNode.TYPE_INSN: + return 3; + case AbstractInsnNode.FIELD_INSN: + return 3; + case AbstractInsnNode.METHOD_INSN: + return 5; + case AbstractInsnNode.INVOKE_DYNAMIC_INSN: + return 5; + case AbstractInsnNode.JUMP_INSN: + // GOTO/JSR have a 5-byte wide form (GOTO_W/JSR_W). A conditional branch (IF*) has NO wide + // form, so when its target is out of the +-32KB range ASM widens it to an inverted 3-byte + // conditional over a 5-byte GOTO_W == 8 bytes. Charge the widest form of each so the + // upper-bound estimate never undercounts a method dense with long-range conditionals. + return n.getOpcode() == org.objectweb.asm.Opcodes.GOTO + || n.getOpcode() == org.objectweb.asm.Opcodes.JSR ? 5 : 8; + case AbstractInsnNode.LDC_INSN: + return 3; + case AbstractInsnNode.IINC_INSN: + return 6; + case AbstractInsnNode.TABLESWITCH_INSN: + // opcode + up to 3 pad + default/low/high (12) + one 4-byte offset per case. + return 16 + ((TableSwitchInsnNode) n).labels.size() * 4; + case AbstractInsnNode.LOOKUPSWITCH_INSN: + // opcode + up to 3 pad + default/npairs (8) + one 8-byte (match,offset) per case. + return 12 + ((LookupSwitchInsnNode) n).keys.size() * 8; + case AbstractInsnNode.MULTIANEWARRAY_INSN: + return 4; + default: + return 4; + } + } +} diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java new file mode 100644 index 00000000000..9ca890eff48 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java @@ -0,0 +1,144 @@ +/* + * 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; +import java.util.Map; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.analysis.Analyzer; +import org.objectweb.asm.tree.analysis.BasicValue; +import org.objectweb.asm.tree.analysis.SimpleVerifier; +import org.objectweb.asm.util.CheckClassAdapter; + +/** + * Verifies every class the engine is about to ship. A transform bug that produces + * invalid bytecode must fail the build here, not at first launch on a device. + * Each class gets a structural check ({@code CheckClassAdapter}, no type loading) + * plus per-method data-flow analysis ({@code SimpleVerifier}); the data-flow pass + * runs method-by-method so a method that references a target-only type absent from + * the supplied jars can be tolerated without masking a genuine error elsewhere in + * the same class. + */ +public final class OutputVerifier { + + 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()) { + // Structural checks first (visit order, access flags, names/descriptors); these load no types, + // so a structurally-invalid transform output is always caught. + verifyStructureOnly(e.getKey(), e.getValue()); + // Then data-flow, PER METHOD, rather than through CheckClassAdapter.verify's whole-class report. + // A method that fails only because an absent target-only type cannot be resolved is tolerated, + // but WITHOUT masking a genuine data-flow error in another method of the same class: ASM can + // append both diagnostics to one report, where a substring check would misclassify the whole + // report as an unresolved-type failure and discard the real error. + verifyDataFlow(e.getKey(), e.getValue(), hierarchy); + } + } + + /** + * Data-flow verification, one method at a time, so the missing-type tolerance is scoped to the method + * that actually references the absent type. A method whose analysis fails only because a type is absent + * from the supplied jars is accepted (FrameClassWriter computed its frames from the bytes and the + * target JVM verifies it on-device); any other analyzer failure fails the build, naming the method. + */ + private static void verifyDataFlow(String name, byte[] classBytes, ClassLoader hierarchy) + throws HardeningException { + ClassNode cn = new ClassNode(); + new ClassReader(classBytes).accept(cn, 0); + Type currentClass = Type.getObjectType(cn.name); + Type superClass = cn.superName == null ? null : Type.getObjectType(cn.superName); + List interfaces = new ArrayList(); + if (cn.interfaces != null) { + for (String i : cn.interfaces) { + interfaces.add(Type.getObjectType(i)); + } + } + boolean isInterface = (cn.access & Opcodes.ACC_INTERFACE) != 0; + if (cn.methods == null) { + return; + } + for (MethodNode m : cn.methods) { + if (m.instructions == null || m.instructions.size() == 0) { + continue; // abstract or native: no body to analyze + } + SimpleVerifier verifier = new SimpleVerifier(currentClass, superClass, interfaces, isInterface); + verifier.setClassLoader(hierarchy); + try { + new Analyzer(verifier).analyze(cn.name, m); + } catch (Throwable t) { + if (isUnresolvedTypeFailure(t)) { + continue; + } + throw new HardeningException("Hardened class '" + name + "' method '" + m.name + m.desc + + "' failed bytecode verification: " + t.getMessage(), t); + } + } + } + + /** + * Structural verification only (no data-flow, so no type loading): checks the class-file structure -- + * visit order, valid access flags, names and descriptors. Used as the fallback when the data-flow + * verifier cannot resolve an absent type. + */ + private static void verifyStructureOnly(String name, byte[] classBytes) throws HardeningException { + try { + new ClassReader(classBytes).accept(new CheckClassAdapter(new ClassWriter(0), false), 0); + } catch (Throwable t) { + throw new HardeningException("Hardened class '" + name + + "' failed structural bytecode verification: " + t.getMessage(), t); + } + } + + /** True when a verification failure is only a missing type (absent from the supplied jars), not a defect. */ + private static boolean isUnresolvedTypeFailure(Throwable t) { + Throwable c = t; + for (int guard = 0; c != null && guard < 32; guard++) { + if (c instanceof TypeNotPresentException || c instanceof ClassNotFoundException + || c instanceof NoClassDefFoundError) { + return true; + } + if (c == c.getCause()) { + break; + } + c = c.getCause(); + } + return false; + } +} diff --git a/maven/cn1-hardening/src/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..8657301283c --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java @@ -0,0 +1,182 @@ +/* + * 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; + } + + static String quote(File f) { + String path = f.getAbsolutePath(); + // ProGuard reads a quoted file name until the matching close quote and does not support escaping + // inside it, so a path containing the quote character cannot use that quote. Pick the quote the + // path does not contain -- a single quote/apostrophe (e.g. /home/o'brien/app.jar) forces double + // quotes. This is exactly what ProGuard's own ConfigurationParser accepts for such names. + if (path.indexOf('\'') < 0) { + return "'" + path + "'"; + } + if (path.indexOf('"') < 0) { + return "\"" + path + "\""; + } + // A path containing BOTH a single and a double quote is unrepresentable to the ProGuard parser + // (essentially never a real filesystem path); fail clearly rather than emit invalid config. + throw new IllegalArgumentException("A hardening classpath entry cannot be passed to ProGuard " + + "because its path contains both a single and a double quote: " + path); + } + + private static void close(ConfigurationParser parser) { + 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..c6c246402d6 --- /dev/null +++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java @@ -0,0 +1,1637 @@ +/* + * 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.ConstantDynamic; +import org.objectweb.asm.Handle; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.AnnotationNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.tree.FieldNode; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.InsnNode; +import org.objectweb.asm.tree.IntInsnNode; +import org.objectweb.asm.tree.InvokeDynamicInsnNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +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$"; + private static final String HOISTED_FIELD_PREFIX = "zqL$"; + static final String DECODER_DESC = "(Ljava/lang/String;)Ljava/lang/String;"; + + /** {@code StringConcatFactory} recipe markers: an ordinary argument slot and a constant slot. */ + private static final char TAG_ARG = '\u0001'; + private static final char TAG_CONST = '\u0002'; + + /** Synthesized initializer-chunk helpers, kept clear of any real member. */ + private static final String INIT_HELPER_PREFIX = "zqCI$"; + /** + * Cut a generated into helper methods once a chunk grows past this many ENCODED bytes, so + * a class with thousands of hoisted/encrypted constants never exceeds the JVM's 65535-byte method + * limit. Kept well under MethodSize.SAFE_LIMIT so each helper fits. + */ + private static final int MAX_CLINIT_CHUNK_BYTES = 48000; + /** Widest encoding of one INVOKESTATIC helper call prepended to . */ + private static final int CLINIT_CALL_BYTES = 5; + /** Widest encoding of the per-access decoder INVOKESTATIC inserted after an LDC. */ + private static final int DECODER_CALL_BYTES = 5; + /** + * Conservative constant-pool entries each hoisted literal adds -- a Utf8 for the field name, a + * NameAndType, a Fieldref, and the ciphertext Utf8 + String -- so the total can be bounded before + * hoisting rather than discovering the overflow only when ASM writes the class. + */ + private static final int POOL_ITEMS_PER_HOIST = 6; + /** + * Conservative constant-pool entries encrypting one {@code static final String} adds -- the field's + * {@code Fieldref} and {@code NameAndType} for the new {@code PUTSTATIC} (the ciphertext replaces the + * stripped plaintext, so it is roughly net-zero). Budgeted with the hoisting growth against the pool. + */ + private static final int POOL_ITEMS_PER_STATIC = 4; + /** + * Conservative constant-pool entries one per-access ciphertext adds -- a Utf8 for the ciphertext + * plus its {@code String_info}. The per-access channel is NOT pool-neutral: when the plaintext value + * is retained elsewhere (an unstripped static-final {@code ConstantValue}, an annotation value, or + * another class in the jar), the original pair stays referenced, so the ciphertext pair is a net + * addition. Budgeted per distinct value against the pool so a class dense with retained plaintext + * cannot silently overflow the 65535-entry limit in {@code ClassWriter.toByteArray()}. + */ + private static final int POOL_ITEMS_PER_ACCESS = 2; + /** + * Conservative constant-pool entries the synthetic decoder itself adds (its name/descriptor, the + * {@code Methodref} the encrypt sites share, and the {@code String.toCharArray}/{@code intern} + * references). Reserved from the pool budget, and gated: a class whose pool cannot fit even this + * fixed overhead cannot be encrypted at all. + */ + private static final int DECODER_POOL_OVERHEAD = 32; + + private final boolean encryptAllStrings; + private final int seed; + private final ClassLoader hierarchy; + private final java.util.Set constantValues; + /** + * Values that must be left plaintext in EVERY class (a jar-wide exclusion). When a value cannot be + * encrypted in some class (a method too full for the per-access call, or a class whose pool cannot + * fit the decoder), encrypting it in the OTHER classes would break a valid literal {@code ==} on + * ParparVM (the decoded copy is interned, the plaintext copy is not). The engine collects these in a + * first pass and re-runs with them excluded so a value is encrypted everywhere or nowhere. + */ + private final java.util.Set jarExcluded; + /** Values this transform left plaintext for a size/pool reason, for the engine to exclude jar-wide. */ + private final java.util.Set newlyExcluded = new java.util.HashSet(); + private int encryptedCount; + private int concatLiteralCount; + private int legacyInterfaceConstantCount; + private int oversizedLiteralCount; + private int condyLiteralCount; + private int clinitFullLiteralCount; + private int methodFullLiteralCount; + private int annotationLiteralCount; + private int indyLiteralCount; + private int shortLiteralCount; + /** True when this class was left UNHARDENED because its frame hierarchy could not be resolved. */ + private boolean hierarchyIncompleteSkipped; + /** The input class's constant-pool item count, so hoisting can stay under the 65535-entry limit. */ + private int poolBaseItems; + /** + * Constant-pool items still available before the 65535-entry limit, after reserving the decoder's + * fixed overhead. Both channels (hoisted method literals and static-final constants) draw from it, + * so their combined growth is budgeted rather than each ignoring the other. + */ + private int poolItemsRemaining; + + public StringEncryptTransform(boolean encryptAllStrings, int seed) { + this(encryptAllStrings, seed, null, null, null); + } + + public StringEncryptTransform(boolean encryptAllStrings, int seed, ClassLoader hierarchy) { + this(encryptAllStrings, seed, hierarchy, null, 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, seed, hierarchy, constantValues, null); + } + + /** + * @param jarExcluded values to force-leave plaintext in this class (a jar-wide exclusion collected + * by the engine so a value is encrypted everywhere or nowhere); may be + * {@code null}. See {@link #getNewlyExcluded()}. + */ + public StringEncryptTransform(boolean encryptAllStrings, int seed, ClassLoader hierarchy, + java.util.Set constantValues, java.util.Set jarExcluded) { + this.encryptAllStrings = encryptAllStrings; + this.seed = seed; + this.hierarchy = hierarchy; + this.constantValues = constantValues; + this.jarExcluded = jarExcluded; + } + + /** + * Values this transform left plaintext for a method-size or constant-pool reason. The engine unions + * these across the jar and re-runs the transform with them excluded, so a value that cannot be + * encrypted in one class is left plaintext in ALL classes -- keeping a valid literal {@code ==} on + * ParparVM (where a decoded literal is interned but a compile-time literal is not). + */ + public java.util.Set getNewlyExcluded() { + return newlyExcluded; + } + + /** Collects the values of {@code static final String} fields in {@code classBytes} into {@code out}. */ + 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); + } + + /** + * Collects every string a class contributes to ParparVM's constant pool as an interned-free literal + * -- {@code LDC} operands and {@code static final String} {@code ConstantValue}s. Used to gather the + * literals of the UNHARDENED library jars: on a ParparVM-C target a compile-time literal is a + * constant-pool object that is never interned, while an encrypted app copy is {@code intern()}ed, so + * a value encrypted in the app but left plaintext in a library class would compare {@code !=} against + * the library copy even though the two equal literals were reference-equal before hardening. The + * engine excludes these from encryption so that identity is preserved across the library boundary. + */ + public static void collectAllLiterals(byte[] classBytes, final java.util.Set out) { + new ClassReader(classBytes).accept(new org.objectweb.asm.ClassVisitor(Opcodes.ASM9) { + @Override + public org.objectweb.asm.FieldVisitor visitField(int access, String name, String desc, + String sig, Object value) { + if (value instanceof String) { + out.add((String) value); + } + return null; + } + + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, String desc, + String sig, String[] exceptions) { + return new org.objectweb.asm.MethodVisitor(Opcodes.ASM9) { + @Override + public void visitLdcInsn(Object cst) { + if (cst instanceof String) { + out.add((String) cst); + } + } + }; + } + }, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } + + /** + * Values that appear as plaintext literals in the UNHARDENED library jars. On a ParparVM-C target + * encrypting an app copy of one of these would break a valid literal {@code ==} against the library's + * (never interned) constant-pool copy, so they are excluded from encryption. Null when there is no + * such constraint (a real-JVM/Android target, where every compile-time literal is interned anyway). + */ + private java.util.Set libraryLiterals; + /** Distinct values this transform left plaintext because a library class also holds them as a literal. */ + private final java.util.Set libraryExcludedValues = new java.util.HashSet(); + + /** Sets the unhardened-library literal set whose values must stay plaintext to preserve identity. */ + void setLibraryLiterals(java.util.Set values) { + this.libraryLiterals = values; + } + + /** The distinct values left plaintext because an unhardened library class also holds them as a literal. */ + java.util.Set getLibraryExcludedValues() { + return libraryExcludedValues; + } + + /** + * Simple field names a carried {@code .java}/{@code .kt} source (bundled in the app jar and compiled + * downstream against the transformed classes, e.g. an Android CN1Lib native source) may reference as a + * compile-time constant. A {@code static final String} whose name is in this set keeps its + * {@code ConstantValue} attribute: stripping it would make the field a non-constant and break a + * {@code case}/annotation/const-initializer reference in that source at javac/kotlinc time. Null on a + * target that does not compile carried source, where the attribute is stripped as usual. + */ + private java.util.Set sourceReferencedNames; + /** Count of static-final constants whose ConstantValue was preserved for a carried source reference. */ + private int sourcePreservedConstantCount; + + /** Sets the field names carried source may reference as constants, whose ConstantValue is preserved. */ + void setSourceReferencedNames(java.util.Set names) { + this.sourceReferencedNames = names; + } + + /** "owner/name" of every static String field read by a GETSTATIC across the jar (non-inlined reads). */ + private java.util.Set externallyReadStaticFields; + /** Count of static-final constants whose ConstantValue was preserved because a GETSTATIC reads them. */ + private int externallyReadConstantCount; + + /** + * Sets the fully-qualified ({@code owner/name}) static String fields that are read somewhere in the + * jar by a {@code GETSTATIC} rather than an inlined {@code LDC}; their {@code ConstantValue} must not + * be migrated to {@code } (it would change reentrant-initialization ordering). + */ + void setExternallyReadStaticFields(java.util.Set fields) { + this.externallyReadStaticFields = fields; + } + + /** + * Collects into {@code out} the {@code owner/name} of every static {@code String} field read by a + * {@code GETSTATIC} in {@code classBytes}. A compile-time String constant is normally inlined by + * javac/kotlinc, so a surviving {@code GETSTATIC} means a non-inlined read whose declaring field's + * {@code ConstantValue} must be preserved rather than moved into {@code }. + * + *

The symbolic owner of a {@code GETSTATIC} may be a SUBCLASS that inherits the field ({@code + * GETSTATIC C.X} for a field {@code X} declared by superclass {@code B} is valid). The transform keys + * on the DECLARING class, so the owner is resolved to it through {@code hierarchy}; if it cannot be + * resolved, the reference owner is recorded as a safe over-approximation. + */ + public static void collectGetStaticStringReads(byte[] classBytes, final java.util.Set out, + final ClassLoader hierarchy) { + new ClassReader(classBytes).accept(new org.objectweb.asm.ClassVisitor(Opcodes.ASM9) { + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, String desc, + String sig, String[] ex) { + return new org.objectweb.asm.MethodVisitor(Opcodes.ASM9) { + @Override + public void visitFieldInsn(int opcode, String owner, String fname, String fdesc) { + if (opcode == Opcodes.GETSTATIC && "Ljava/lang/String;".equals(fdesc)) { + out.add(resolveDeclaringClass(hierarchy, owner, fname) + "." + fname); + } + } + }; + } + }, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } + + /** + * Walks {@code owner}'s superclass chain through {@code hierarchy} to the class that actually declares + * a static {@code String} field named {@code fieldName} (JVM field resolution binds an inherited + * {@code GETSTATIC} to that class). Returns {@code owner} unchanged when the hierarchy is unavailable + * or the declaring class cannot be read. + */ + private static String resolveDeclaringClass(ClassLoader hierarchy, String owner, final String fieldName) { + if (hierarchy == null) { + return owner; + } + String c = owner; + java.util.Set seen = new java.util.HashSet(); + while (c != null && seen.add(c)) { + final boolean[] declaresHere = {false}; + final String[] superName = {null}; + java.io.InputStream in = hierarchy.getResourceAsStream(c + ".class"); + if (in == null) { + break; + } + try { + new ClassReader(in).accept(new org.objectweb.asm.ClassVisitor(Opcodes.ASM9) { + @Override + public void visit(int v, int a, String name, String sig, String sup, String[] itf) { + superName[0] = sup; + } + + @Override + public org.objectweb.asm.FieldVisitor visitField(int access, String name, String desc, + String sig, Object value) { + if ((access & Opcodes.ACC_STATIC) != 0 && fieldName.equals(name) + && "Ljava/lang/String;".equals(desc)) { + declaresHere[0] = true; + } + return null; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } catch (Throwable t) { + break; + } finally { + try { + in.close(); + } catch (Throwable ignore) { + // best effort + } + } + if (declaresHere[0]) { + return c; + } + c = superName[0]; + } + return owner; + } + + /** Count of static-final String constants left plaintext to keep a carried source's compilation valid. */ + int getSourcePreservedConstantCount() { + return sourcePreservedConstantCount; + } + + /** Count of static-final constants left plaintext because a GETSTATIC (non-inlined) read observes them. */ + int getExternallyReadConstantCount() { + return externallyReadConstantCount; + } + + public int getEncryptedCount() { + return encryptedCount; + } + + /** + * The number of {@code invokedynamic} string-concatenation sites carrying plaintext literal text + * that this transform did NOT encrypt. javac from JDK 9 on compiles {@code "a" + b} to an + * {@code invokedynamic} bound to {@link java.lang.invoke.StringConcatFactory}, storing the literal + * fragments in the bootstrap recipe/constant arguments rather than as {@code LDC} instructions. + * The engine encrypts {@code LDC} and {@code ConstantValue} channels; a recipe cannot be rewritten + * to a decode call because {@code StringConcatFactory} interprets it at link time, so those + * fragments would ship in cleartext. They are counted and reported rather than silently left, + * so a build compiled that way is not believed fully string-encrypted. Compiling with + * {@code -XDstringConcat=inline} (or an older {@code -target}) emits {@code StringBuilder} the + * engine encrypts. + */ + public int getConcatLiteralCount() { + return concatLiteralCount; + } + + /** + * The number of {@code static final String} constants on pre-Java-8 interfaces that this transform + * left in plaintext. Such an interface cannot host a {@code } or the decoder (both need + * class-file version 52), so its own {@code ConstantValue} attribute cannot be moved to a decode + * call. Every read of the constant elsewhere was inlined by javac to an {@code LDC} and is + * encrypted there, so the value is hidden at each use; only the declaring interface's constant pool + * still carries it. Counted and reported rather than shipped silently. + */ + public int getLegacyInterfaceConstantCount() { + return legacyInterfaceConstantCount; + } + + /** + * The number of distinct string literals this transform would have encrypted but left in plaintext + * because their ciphertext could overflow the 65535-byte constant-pool limit (a valid ASCII literal + * longer than 21,845 characters can widen to a 3-byte-per-char modified-UTF-8 constant). A large + * embedded credential, JSON document or encoded blob therefore stays readable; counted and reported + * so an {@code strings:all} build is not believed to have encrypted everything. + */ + public int getOversizedLiteralCount() { + return oversizedLiteralCount; + } + + /** + * The number of {@code LDC ConstantDynamic} sites carrying a String among their bootstrap arguments + * that this transform did NOT encrypt. Java 11+ bytecode can materialize a constant through a + * {@code constant-dynamic} whose bootstrap arguments hold plaintext; those live neither in a direct + * {@code LDC "..."} nor in a field {@code ConstantValue}, and the condy is resolved at link time, so + * rewriting it to a decode call is unsafe. Counted and reported rather than shipped unremarked. + */ + /** + * The number of {@code invokedynamic} sites -- other than the {@code StringConcatFactory} concat + * recipes counted by {@link #getConcatLiteralCount()} -- whose bootstrap arguments carry a plaintext + * String (directly or through a nested constant-dynamic). A custom {@code invokedynamic} emitted by a + * bytecode generator can hold a literal in its bootstrap arguments that no {@code LDC}/ + * {@code ConstantValue} pass reaches, so it stays readable; reported so an {@code strings:all} build + * is not believed to have encrypted every string. + */ + public int getIndyLiteralCount() { + return indyLiteralCount; + } + + /** + * The number of distinct one- and two-character string literals the current mode would have + * encrypted but left plaintext because they are too short to be worth the decoder overhead. A + * two-character value is trivially brute-forced even when encrypted, so this is a disclosure note + * (the {@code strings:all} claim does not silently omit them), not a correctness risk. + */ + public int getShortLiteralCount() { + return shortLiteralCount; + } + + /** + * True when this class was shipped UNHARDENED because a frame merge could not be resolved past a + * missing intermediate supertype (see {@link FrameClassWriter#isHierarchyIncomplete}). Reported so the + * coverage summary discloses the class rather than silently counting it as hardened. + */ + public boolean isHierarchyIncompleteSkipped() { + return hierarchyIncompleteSkipped; + } + + public int getCondyLiteralCount() { + return condyLiteralCount; + } + + /** + * The number of literals left plaintext because the class's {@code } is already so close to + * the 65,535-byte method limit that even the split path's helper calls would not fit. Extremely + * rare (a class whose static initializer is itself near the limit), but reported rather than + * aborting the build so a valid input class is never rejected. + */ + public int getClinitFullLiteralCount() { + return clinitFullLiteralCount; + } + + /** + * The number of literals left plaintext because encrypting them per access would have pushed the + * enclosing method past the 65,535-byte limit. Only the interface path decodes per access (a class's + * literals are hoisted to {@code }, which does not grow the method body), so this is rare; + * reported rather than aborting the build on a valid input class. + */ + public int getMethodFullLiteralCount() { + return methodFullLiteralCount; + } + + /** + * The number of distinct string values the current mode would encrypt that are stored in annotation + * element values or annotation defaults. javac keeps those in the annotation metadata, not as an + * {@code LDC} or a field {@code ConstantValue}, so no encryption channel reaches them; they stay + * readable. Counted and reported so an {@code strings:all} build is not believed to have encrypted + * every string. (Codename One has no runtime reflection to read an annotation value back, so this is + * a disclosure note, not a correctness risk.) + */ + public int getAnnotationLiteralCount() { + return annotationLiteralCount; + } + + + /** Encrypts {@code classBytes}, returning the transformed bytes (or the input if nothing changed). */ + public byte[] transform(byte[] classBytes) { + ClassNode cn = new ClassNode(); + ClassReader reader = new ClassReader(classBytes); + reader.accept(cn, ClassReader.SKIP_FRAMES); + // The input's current constant-pool item count; encryption must not grow the pool past 65535. + poolBaseItems = reader.getItemCount(); + poolItemsRemaining = MethodSize.SAFE_POOL_ITEMS - poolBaseItems - DECODER_POOL_OVERHEAD; + + boolean isInterface = (cn.access & Opcodes.ACC_INTERFACE) != 0; + // The decoder is a concrete static method, and (for interface constants) it is invoked from + // . 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) { + // A pre-Java-8 interface cannot host a /decoder, so its own static-final String + // ConstantValue attributes cannot be moved to a decode call and stay plaintext. Count the + // ones we would otherwise have encrypted so the engine reports the exclusion instead of + // silently shipping them; javac already inlined (and this pass encrypts) every read site. + if (cn.fields != null) { + for (FieldNode f : cn.fields) { + if ((f.access & Opcodes.ACC_STATIC) != 0 && (f.access & Opcodes.ACC_FINAL) != 0 + && f.value instanceof String && shouldEncryptLiteral((String) f.value)) { + legacyInterfaceConstantCount++; + // Exclude it jar-wide: this ConstantValue stays plaintext here, so an equal LDC in + // another class must NOT be encrypted+interned or a GETSTATIC read of this field + // would compare != to that interned copy on ParparVM (a broken literal ==). + newlyExcluded.add((String) f.value); + } + } + } + return classBytes; + } + + // Pick a decoder name that does not collide with an existing member, so a class is NEVER + // skipped for a name clash. Skipping would leave that class's literals in plaintext while an + // equal literal in another class was encrypted+interned; on ParparVM, whose intern pool does + // not contain the compile-time literals, the two would then fail a valid literal '==' compare. + // Never skipping keeps encryption applied by-value across the whole jar, so all occurrences of + // a value are decoded through the shared intern pool and stay reference-equal. + String decoderName = resolveDecoderName(cn); + + // Count invokedynamic string-concatenation literals we cannot encrypt (see + // getConcatLiteralCount). Done before the mutating passes so it is independent of them; the + // engine turns a non-zero total into a build warning so plaintext concat fragments are never + // silently shipped. + concatLiteralCount += countConcatLiterals(cn); + condyLiteralCount += countCondyLiterals(cn); + indyLiteralCount += countIndyLiterals(cn); + annotationLiteralCount += countAnnotationStrings(cn); + shortLiteralCount += countShortLiterals(cn); + // Count the distinct literals that would be encrypted but are too large to (their ciphertext + // could overflow the constant pool), so the engine can report the exclusion rather than let an + // strings:all build claim it encrypted everything. + oversizedLiteralCount += countOversizedLiterals(cn); + + int base = keyBase(cn.name); + boolean changed = false; + + // The decoder method and its references are added whenever anything is encrypted, at a fixed + // constant-pool cost. If the class's pool is already so full that even that overhead would not + // fit, nothing can be encrypted here without ClassTooLargeException. Skip the class and record + // the values it would have selected as jar-wide exclusions, so those values are left plaintext in + // every OTHER class too -- otherwise a value encrypted+interned elsewhere would compare != to the + // plaintext copy here on ParparVM. + if (poolItemsRemaining < 0) { + collectSelectedValues(cn, newlyExcluded); + return classBytes; + } + + // Channel 1: LDC string literals in method bodies. In "all" mode every literal is + // encrypted; in "constants" mode only literals whose value was declared as a + // static-final String constant somewhere in the jar -- which is exactly the set javac + // inlined at these read sites -- so ordinary incidental literals are left alone. + // + // For a normal class each distinct literal is hoisted to a synthetic static field decoded + // ONCE in , and its LDC sites become a GETSTATIC of that field, so a literal in a hot + // loop is not re-decoded (and re-interned -- an O(n) scan on ParparVM) on every iteration. An + // interface can't host that (its fields are public), so there the literal is decoded per + // access; interface method bodies are rare and not hot loops. + if (cn.methods != null) { + if (isInterface) { + changed |= encryptAllMethodsPerAccess(cn, base, decoderName, true); + } else { + changed |= hoistMethodLiterals(cn, base, decoderName); + } + } + + // 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, decoderName); + + if (!changed) { + return classBytes; + } + + addDecoder(cn, base, isInterface, decoderName); + + FrameClassWriter cw = new FrameClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, hierarchy); + cn.accept(cw); + if (cw.isHierarchyIncomplete()) { + // COMPUTE_FRAMES had to guess java/lang/Object for a merge whose real common base sits beyond a + // supertype absent from the supplied jars (e.g. A extends PlatformA, B extends PlatformB, both + // extend an absent Base). The recomputed frame may be too weak and fail on-device verification. + // Ship this class UNHARDENED -- its original javac-computed frames already encode the precise + // type -- rather than a possibly-invalid recomputed one. Its literals then stay plaintext, so + // exclude ALL of them jar-wide: a value encrypted+interned in another class must not compare != + // to this class's plaintext copy on ParparVM's deduplicated pool. + collectAllLiterals(classBytes, newlyExcluded); + // No literal is actually encrypted for a discarded class; clear the applied count so the engine + // does not count these toward "some transform ran" and stamp cn1.hardened=true for output whose + // bytes never changed. The literals are disclosed via the hierarchy-incomplete skip instead. + encryptedCount = 0; + hierarchyIncompleteSkipped = true; + return classBytes; + } + return cw.toByteArray(); + } + + /** + * Encrypts every method-body literal in {@code cn} PER ACCESS (an {@code LDC} ciphertext + a decoder + * {@code INVOKESTATIC}), rather than hoisting distinct values to fields. Used for interfaces (whose + * fields are public) and as the fallback when hoisting a class would overflow the constant pool or + * its {@code } is already full: per-access adds no per-value field, so it does not grow the + * pool, and it keeps EVERY occurrence of a value encrypted and interned -- preserving a valid + * cross-class literal {@code ==} on ParparVM instead of leaving some copies plaintext. + */ + private boolean encryptAllMethodsPerAccess(ClassNode cn, int base, String decoderName, + boolean isInterface) { + boolean changed = false; + if (cn.methods != null) { + // Distinct values already charged against the pool budget in this pass. ASM folds equal + // ciphertext strings to one constant, so a value re-encountered in another method adds no + // further pool entry and must not be charged twice. + java.util.Set pooledThisPass = new java.util.HashSet(); + for (MethodNode mn : cn.methods) { + if (mn.instructions == null || decoderName.equals(mn.name)) { + continue; + } + changed |= encryptMethodLiterals(cn, mn, base, isInterface, decoderName, pooledThisPass); + } + } + return changed; + } + + private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boolean isInterface, + String decoderName, java.util.Set pooledThisPass) { + boolean changed = false; + // Each rewrite inserts an INVOKESTATIC, growing the method. A method already near the limit + // cannot take unbounded rewrites, so track the running size and stop (leaving the remaining + // literals plaintext, reported) before the method would overflow, rather than aborting the build. + int currentBytes = MethodSize.estimateBytes(mn.instructions); + AbstractInsnNode insn = mn.instructions.getFirst(); + while (insn != null) { + AbstractInsnNode next = insn.getNext(); + if (insn instanceof LdcInsnNode) { + LdcInsnNode ldc = (LdcInsnNode) insn; + if (ldc.cst instanceof String && shouldEncryptLiteral((String) ldc.cst)) { + String plain = (String) ldc.cst; + if (currentBytes + DECODER_CALL_BYTES > MethodSize.SAFE_LIMIT) { + // This method cannot grow to hold the decode call. Record the value as a jar-wide + // exclusion so the engine leaves it plaintext in every class -- encrypting it + // elsewhere would break a valid literal == against this plaintext copy. + methodFullLiteralCount++; + newlyExcluded.add(plain); + } else if (!pooledThisPass.contains(plain) + && poolItemsRemaining < POOL_ITEMS_PER_ACCESS) { + // The ciphertext for a not-yet-charged value would push the class constant pool + // past the 65535-entry limit (per-access is not pool-neutral when the plaintext is + // retained elsewhere). Leaving some occurrences encrypted and others plaintext + // would break a valid literal ==, so exclude the value jar-wide (plaintext in + // every class) and let the engine's second pass re-run with it excluded, rather + // than let ClassWriter.toByteArray() throw ClassTooLargeException. + newlyExcluded.add(plain); + } else { + // Charge the pool budget the first time a distinct value is encrypted this pass; + // repeats of the same value reuse the folded ciphertext constant for free. + if (pooledThisPass.add(plain)) { + poolItemsRemaining -= POOL_ITEMS_PER_ACCESS; + } + // shouldEncrypt already rejected any value whose ciphertext could overflow the + // constant pool, using a class-independent bound, so the encode result fits. + ldc.cst = encode(plain, base); + // The itf flag must be true when the decoder lives in an interface, or the JVM + // writes a Methodref instead of an InterfaceMethodref and throws + // IncompatibleClassChangeError at run time. + mn.instructions.insert(ldc, new MethodInsnNode( + Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, isInterface)); + currentBytes += DECODER_CALL_BYTES; + encryptedCount++; + changed = true; + } + } + } + insn = next; + } + return changed; + } + + /** + * Counts the {@code invokedynamic} string-concatenation sites in {@code cn} that carry plaintext + * the engine cannot reach. A {@code makeConcatWithConstants} recipe embeds the literal fragments of + * {@code "a" + b} directly (any character other than the U+0001 argument marker and the + * U+0002 constant marker), and additional constant fragments arrive as String bootstrap + * arguments after the recipe. Either form leaves cleartext in the constant pool that no + * {@code LDC}/{@code ConstantValue} pass touches. Counts the site once when it bears any literal + * text, so the reported number tracks concat sites rather than characters. + */ + private static int countConcatLiterals(ClassNode cn) { + if (cn.methods == null) { + return 0; + } + int count = 0; + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (!(insn instanceof InvokeDynamicInsnNode)) { + continue; + } + InvokeDynamicInsnNode indy = (InvokeDynamicInsnNode) insn; + Handle bsm = indy.bsm; + if (bsm == null + || !"java/lang/invoke/StringConcatFactory".equals(bsm.getOwner()) + || !"makeConcatWithConstants".equals(bsm.getName())) { + continue; + } + if (concatSiteHasLiteral(indy.bsmArgs)) { + count++; + } + } + } + return count; + } + + /** + * True when a {@code makeConcatWithConstants} site carries any plaintext: a recipe (the first + * bootstrap argument) with a character that is neither the U+0001 argument marker nor the + * U+0002 constant marker, or any String constant among the later bootstrap arguments. + */ + private static boolean concatSiteHasLiteral(Object[] bsmArgs) { + if (bsmArgs == null || bsmArgs.length == 0) { + return false; + } + if (bsmArgs[0] instanceof String) { + String recipe = (String) bsmArgs[0]; + for (int i = 0; i < recipe.length(); i++) { + char c = recipe.charAt(i); + if (c != TAG_ARG && c != TAG_CONST) { + return true; + } + } + } + for (int i = 1; i < bsmArgs.length; i++) { + if (bsmArgs[i] instanceof String) { + return true; + } + } + return false; + } + + /** + * Counts the {@code LDC ConstantDynamic} sites in {@code cn} whose bootstrap arguments include a + * String -- Java 11+ can carry plaintext through a constant-dynamic (e.g. an enum switch map or an + * explicit-condy compiler), which no {@code LDC}/{@code ConstantValue} pass reaches. Counts the site + * once when it bears any String argument. + */ + private static int countCondyLiterals(ClassNode cn) { + if (cn.methods == null) { + return 0; + } + int count = 0; + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof ConstantDynamic) { + if (condyHasStringArgument((ConstantDynamic) ((LdcInsnNode) insn).cst)) { + count++; + } + } + } + } + return count; + } + + /** + * Counts the {@code invokedynamic} sites in {@code cn} that carry a plaintext String in their + * bootstrap arguments and are NOT the {@code StringConcatFactory.makeConcatWithConstants} concat + * recipes already tallied by {@link #countConcatLiterals(ClassNode)} (skipped here to avoid + * double-counting). A custom {@code invokedynamic} from a bytecode generator can hold a literal -- + * directly or in a nested constant-dynamic argument -- that no {@code LDC}/{@code ConstantValue} pass + * reaches. Counts the site once when any bootstrap argument bears a String. + */ + private static int countIndyLiterals(ClassNode cn) { + if (cn.methods == null) { + return 0; + } + int count = 0; + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (!(insn instanceof InvokeDynamicInsnNode)) { + continue; + } + InvokeDynamicInsnNode indy = (InvokeDynamicInsnNode) insn; + Handle bsm = indy.bsm; + if (bsm != null + && "java/lang/invoke/StringConcatFactory".equals(bsm.getOwner()) + && "makeConcatWithConstants".equals(bsm.getName())) { + continue; + } + if (indyBsmArgsHaveString(indy.bsmArgs)) { + count++; + } + } + } + return count; + } + + /** True when any bootstrap argument is a String, or a constant-dynamic that (nested) carries one. */ + private static boolean indyBsmArgsHaveString(Object[] bsmArgs) { + if (bsmArgs == null) { + return false; + } + for (Object arg : bsmArgs) { + if (arg instanceof String) { + return true; + } + if (arg instanceof ConstantDynamic && condyHasStringArgument((ConstantDynamic) arg)) { + return true; + } + } + return false; + } + + /** + * Counts the distinct one- and two-character string literals in {@code cn} that the current mode + * would encrypt (every {@code LDC} in "all" mode; a declared constant in "constants" mode) but + * {@link #shouldEncrypt(String)} leaves plaintext because they are too short to be worth the decoder + * overhead. Empty strings carry no information and are not counted; reported so the coverage claim + * discloses the short-literal exclusion rather than silently omitting it. + */ + private int countShortLiterals(ClassNode cn) { + java.util.Set found = new java.util.HashSet(); + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String) { + String v = (String) ((LdcInsnNode) insn).cst; + if (v.length() >= 1 && v.length() <= 2 && wouldSelectButForLength(v)) { + found.add(v); + } + } + } + } + } + // The static-final ConstantValue channel skips short values for the same reason: encryptStaticFinalStrings + // gates on shouldEncryptLiteral -> shouldEncrypt, which rejects length <= 2, so a short static-final + // String is left plaintext in its ConstantValue slot and leaks into ParparVM's C pool uncounted. Include + // it (distinct by value, dedup'd with the LDC channel above) exactly as countOversizedLiterals does, so + // an strings:all build still discloses the exclusion rather than silently advertising full coverage. + if (cn.fields != null) { + for (FieldNode fn : cn.fields) { + if ((fn.access & Opcodes.ACC_STATIC) != 0 && fn.value instanceof String) { + String v = (String) fn.value; + if (v.length() >= 1 && v.length() <= 2 && wouldSelectButForLength(v)) { + found.add(v); + } + } + } + } + return found.size(); + } + + /** True when a value would be an encryption candidate in the current mode if it were long enough. */ + private boolean wouldSelectButForLength(String v) { + return encryptAllStrings || (constantValues != null && constantValues.contains(v)); + } + + /** True when a constant-dynamic carries a String among its bootstrap arguments, nested ones too. */ + private static boolean condyHasStringArgument(ConstantDynamic condy) { + return condyHasStringArgument(condy, 0); + } + + private static boolean condyHasStringArgument(ConstantDynamic condy, int depth) { + // A constant-dynamic bootstrap argument can itself be a ConstantDynamic whose arguments hold the + // plaintext, so recurse. The depth guard is a backstop against a pathological/cyclic pool. + if (depth > 16) { + return false; + } + for (int i = 0, n = condy.getBootstrapMethodArgumentCount(); i < n; i++) { + Object arg = condy.getBootstrapMethodArgument(i); + if (arg instanceof String) { + return true; + } + if (arg instanceof ConstantDynamic + && condyHasStringArgument((ConstantDynamic) arg, depth + 1)) { + return true; + } + } + return false; + } + + /** + * Counts the distinct string values the current mode would encrypt that live in annotation element + * values or defaults (see {@link #getAnnotationLiteralCount()}). Walks EVERY annotation collection + * ASM exposes -- ordinary and type-use annotations on the class, its fields, methods, method + * parameters and local variables, plus record-component annotations and method annotation defaults + * -- recursing into nested annotations and array values; skips enum references ({@code String[]}) and + * {@code Type}, which are not string literals. + */ + private int countAnnotationStrings(ClassNode cn) { + java.util.Set found = new java.util.HashSet(); + collectAnnotations(cn.visibleAnnotations, found); + collectAnnotations(cn.invisibleAnnotations, found); + collectAnnotations(cn.visibleTypeAnnotations, found); + collectAnnotations(cn.invisibleTypeAnnotations, found); + if (cn.recordComponents != null) { + for (org.objectweb.asm.tree.RecordComponentNode rc : cn.recordComponents) { + collectAnnotations(rc.visibleAnnotations, found); + collectAnnotations(rc.invisibleAnnotations, found); + collectAnnotations(rc.visibleTypeAnnotations, found); + collectAnnotations(rc.invisibleTypeAnnotations, found); + } + } + if (cn.fields != null) { + for (FieldNode f : cn.fields) { + collectAnnotations(f.visibleAnnotations, found); + collectAnnotations(f.invisibleAnnotations, found); + collectAnnotations(f.visibleTypeAnnotations, found); + collectAnnotations(f.invisibleTypeAnnotations, found); + } + } + if (cn.methods != null) { + for (MethodNode m : cn.methods) { + collectAnnotations(m.visibleAnnotations, found); + collectAnnotations(m.invisibleAnnotations, found); + collectAnnotations(m.visibleTypeAnnotations, found); + collectAnnotations(m.invisibleTypeAnnotations, found); + collectAnnotations(m.visibleLocalVariableAnnotations, found); + collectAnnotations(m.invisibleLocalVariableAnnotations, found); + collectParameterAnnotations(m.visibleParameterAnnotations, found); + collectParameterAnnotations(m.invisibleParameterAnnotations, found); + collectAnnotationValue(m.annotationDefault, found); + } + } + return found.size(); + } + + private void collectAnnotations(java.util.List list, + java.util.Set out) { + if (list == null) { + return; + } + for (AnnotationNode an : list) { + collectAnnotationNode(an, out); + } + } + + private void collectParameterAnnotations(java.util.List[] params, + java.util.Set out) { + if (params == null) { + return; + } + for (java.util.List list : params) { + collectAnnotations(list, out); + } + } + + private void collectAnnotationNode(AnnotationNode an, java.util.Set out) { + if (an == null || an.values == null) { + return; + } + // values is a flat [name, value, name, value, ...] list; only the values can hold strings. + for (int i = 1; i < an.values.size(); i += 2) { + collectAnnotationValue(an.values.get(i), out); + } + } + + private void collectAnnotationValue(Object value, java.util.Set out) { + if (value instanceof String) { + if (modeSelectsLiteral((String) value)) { + out.add((String) value); + } + } else if (value instanceof AnnotationNode) { + collectAnnotationNode((AnnotationNode) value, out); + } else if (value instanceof java.util.List) { + for (Object e : (java.util.List) value) { + collectAnnotationValue(e, out); + } + } + // A String[] is an enum reference {descriptor, constant} and a Type is a class literal -- neither + // is a user string literal, so both are left uncounted. + } + + /** + * Counts the distinct literals in {@code cn} that this transform would encrypt but skips because + * their ciphertext could overflow the constant pool (see {@link #getOversizedLiteralCount()}). + * Covers both channels: method-body {@code LDC}s selected by the current mode, and + * {@code static final String} {@code ConstantValue}s (encrypted regardless of mode). Distinct by + * value within the class, mirroring how encryption dedups. + */ + private int countOversizedLiterals(ClassNode cn) { + java.util.Set skipped = new java.util.HashSet(); + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String) { + String v = (String) ((LdcInsnNode) insn).cst; + if (isOversized(v) && modeSelectsLiteral(v)) { + skipped.add(v); + } + } + } + } + } + if (cn.fields != null) { + for (FieldNode fn : cn.fields) { + if ((fn.access & Opcodes.ACC_STATIC) != 0 && fn.value instanceof String) { + String v = (String) fn.value; + // encryptStaticFinalStrings uses shouldEncrypt (mode-independent), so any oversized + // static-final String would be skipped. + if (v.length() > 2 && isOversized(v)) { + skipped.add(v); + } + } + } + } + return skipped.size(); + } + + /** Collects the distinct values the mode would encrypt (method LDCs + static-final constants). */ + private void collectSelectedValues(ClassNode cn, java.util.Set out) { + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String + && shouldEncryptLiteral((String) ((LdcInsnNode) insn).cst)) { + out.add((String) ((LdcInsnNode) insn).cst); + } + } + } + } + if (cn.fields != null) { + for (FieldNode fn : cn.fields) { + if ((fn.access & Opcodes.ACC_STATIC) != 0 && fn.value instanceof String + && shouldEncryptLiteral((String) fn.value)) { + out.add((String) fn.value); + } + } + } + } + + /** True when {@code s}'s worst-case ciphertext would overflow the 65535-byte constant-pool limit. */ + private static boolean isOversized(String s) { + return s != null && (long) s.length() * 3 > 65535; + } + + /** True when the current mode would select {@code s} for method-literal encryption (size aside). */ + private boolean modeSelectsLiteral(String s) { + if (s == null || s.length() <= 2) { + return false; + } + if (jarExcluded != null && jarExcluded.contains(s)) { + return false; + } + return encryptAllStrings || (constantValues != null && constantValues.contains(s)); + } + + /** + * Hoists each distinct encryptable method-body literal in {@code cn} to a synthetic static field + * decoded once in {@code }, and rewrites its LDC sites to a GETSTATIC of that field. So a + * literal read in a loop pays the decode + intern cost once at class load instead of on every + * access. Fields are private and synthetic; the decoded value is interned, so all sites of one + * value -- and equal values in other classes -- stay reference-equal. + */ + private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) { + // 1. Collect the distinct values, in first-seen order, each mapped to a field name that does + // NOT collide with an existing member (an input class may already declare a zqL$N field; + // on Android the engine doesn't rename first, so this is reachable and a duplicate field + // would make the class fail to load). + java.util.Set taken = new java.util.HashSet(); + if (cn.fields != null) { + for (FieldNode f : cn.fields) { + taken.add(f.name); + } + } + java.util.LinkedHashMap valueToField = new java.util.LinkedHashMap(); + int counter = 0; + for (MethodNode mn : cn.methods) { + if (mn.instructions == null || decoderName.equals(mn.name)) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String) { + String v = (String) ((LdcInsnNode) insn).cst; + if (shouldEncryptLiteral(v) && !valueToField.containsKey(v)) { + String fname; + do { + fname = HOISTED_FIELD_PREFIX + counter; + counter++; + } while (taken.contains(fname)); + taken.add(fname); + valueToField.put(v, fname); + } + } + } + } + if (valueToField.isEmpty()) { + return false; + } + // Bound hoisting by constant-pool growth: each hoisted literal adds several pool entries (its + // field name Utf8, a NameAndType, a Fieldref, the ciphertext Utf8 + String), so a class with + // tens of thousands of distinct literals could exceed the JVM's 65535-entry limit and make ASM + // throw ClassTooLargeException. When hoisting them all would overflow, fall back to per-access + // encryption for the whole class: it adds no per-value field, so it grows the pool far less (just + // the ciphertext Utf8 + String per distinct value, itself budgeted against poolItemsRemaining in + // encryptMethodLiterals), and -- crucially -- it keeps every affordable occurrence encrypted and + // interned rather than leaving some plaintext, so a value hoisted in one class still compares == + // to its per-access copy here. + int budget = poolItemsRemaining / POOL_ITEMS_PER_HOIST; + if (budget < 0) { + budget = 0; + } + if (valueToField.size() > budget) { + return encryptAllMethodsPerAccess(cn, base, decoderName, false); + } + // Preflight the LDC -> GETSTATIC growth: GETSTATIC is always 3 bytes, but an LDC whose + // constant-pool index is below 256 is only 2, so replacing hoisted LDCs can grow a method that + // is already near the 65535-byte limit. MethodSize charges an LDC as 3 (= GETSTATIC), so + // estimateBytes(mn) already equals the post-hoist size; if a method would exceed the safe bound, + // exclude its selected values jar-wide -- their LDCs then stay plaintext, so the method is + // unchanged -- rather than let ASM throw MethodTooLargeException. + for (MethodNode mn : cn.methods) { + if (mn.instructions == null || decoderName.equals(mn.name) + || MethodSize.estimateBytes(mn) <= MethodSize.SAFE_LIMIT) { + continue; + } + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String + && valueToField.containsKey((String) ((LdcInsnNode) insn).cst)) { + newlyExcluded.add((String) ((LdcInsnNode) insn).cst); + } + } + } + if (!newlyExcluded.isEmpty()) { + valueToField.keySet().removeAll(newlyExcluded); + if (valueToField.isEmpty()) { + return false; + } + } + // 2. Build the initializer BEFORE mutating anything. + InsnList init = new InsnList(); + for (java.util.Map.Entry e : valueToField.entrySet()) { + init.add(new LdcInsnNode(encode(e.getKey(), base))); + init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, false)); + init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, e.getValue(), "Ljava/lang/String;")); + } + // If the class's is already so full it cannot hold the decode init, fall back to + // per-access here too (no growth), for the same reason: keep every occurrence encrypted + // and interned rather than leaving these method-body literals plaintext. + if (!clinitCanAccept(cn, init)) { + return encryptAllMethodsPerAccess(cn, base, decoderName, false); + } + // 3. Commit. Replace each LDC of a hoisted value with a GETSTATIC of its field in the ORIGINAL + // method bodies; the standalone init is inserted into only afterwards, so its own + // ciphertext LDCs are never rescanned -- otherwise a ciphertext that happens to equal another + // hoisted plaintext (the XOR encoding is involutive) would be rewritten into a read of a + // not-yet-assigned field and pass null to the decoder. + for (MethodNode mn : cn.methods) { + if (mn.instructions == null || decoderName.equals(mn.name)) { + continue; + } + AbstractInsnNode insn = mn.instructions.getFirst(); + while (insn != null) { + AbstractInsnNode next = insn.getNext(); + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String) { + String field = valueToField.get((String) ((LdcInsnNode) insn).cst); + if (field != null) { + mn.instructions.set(insn, new FieldInsnNode(Opcodes.GETSTATIC, cn.name, field, + "Ljava/lang/String;")); + } + } + insn = next; + } + } + // Add a field per value; the init decodes each once in , prepended so it runs before + // the original body (a hoisted value used within reads the already-initialized field). + if (cn.fields == null) { + cn.fields = new java.util.ArrayList(); + } + for (String field : valueToField.values()) { + cn.fields.add(new FieldNode(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC, + field, "Ljava/lang/String;", null, null)); + encryptedCount++; + } + // Charge the hoisted fields against the shared pool budget so the static-final channel that runs + // next sees the reduced headroom. + poolItemsRemaining -= valueToField.size() * POOL_ITEMS_PER_HOIST; + // hoistMethodLiterals runs only for a non-interface (interfaces decode per access), so the + // helper split, if it triggers, emits ordinary private static helpers. + prependToClinit(cn, init, false); + return true; + } + + private boolean encryptStaticFinalStrings(ClassNode cn, int base, boolean isInterface, + String decoderName) { + if (cn.fields == null) { + return false; + } + // Encrypting one static-final grows the pool (a PUTSTATIC Fieldref + NameAndType), so a class + // with thousands of them could exceed the 65535-entry limit. Cap it at the shared pool budget + // (already reduced by any hoisting above); constants past the budget keep their plaintext. That + // is safe -- a static-final's ConstantValue is dead once javac inlines every read (those inlined + // LDCs are encrypted by the method channel), so it is never compared by ==. + int budget = poolItemsRemaining / POOL_ITEMS_PER_STATIC; + if (budget < 0) { + budget = 0; + } + // Build the initializer and remember which fields to strip WITHOUT mutating yet, so a class + // whose cannot accommodate the init keeps its constants (plaintext, reported) rather + // than being left with fields whose ConstantValue was stripped but never re-initialized. + InsnList init = new InsnList(); + java.util.List toStrip = new java.util.ArrayList(); + for (FieldNode fn : cn.fields) { + boolean isStatic = (fn.access & Opcodes.ACC_STATIC) != 0; + // shouldEncryptLiteral, not shouldEncrypt: a value excluded jar-wide (some method could not + // grow to encrypt it) or shared with an unhardened library must stay plaintext HERE too, or a + // GETSTATIC read of this decoded+interned field would compare != to the excluded plaintext + // literal on ParparVM, breaking Java's literal identity guarantee. + if (isStatic && fn.value instanceof String && shouldEncryptLiteral((String) fn.value)) { + if (sourceReferencedNames != null && sourceReferencedNames.contains(fn.name)) { + // A carried .java/.kt source may use this constant in a constant-expression context + // (a case label, an annotation value, another constant's initializer). Stripping its + // ConstantValue would make the field a non-constant and break that source's javac/ + // kotlinc compilation against the transformed jar, so preserve it (plaintext, + // disclosed). The inlined reads elsewhere in the app are still encrypted. + sourcePreservedConstantCount++; + continue; + } + if (externallyReadStaticFields != null + && externallyReadStaticFields.contains(cn.name + "." + fn.name)) { + // Some class reads this constant with a GETSTATIC rather than an inlined LDC (generated + // bytecode -- javac and kotlinc both inline compile-time String constant reads). Moving + // the assignment into would change initialization ORDER: the ConstantValue is + // otherwise assigned during preparation, before any runs, so a superclass whose + // reads this subclass constant during a REENTRANT initialization observes the + // value; a -assigned field would still be null at that reentrant point and the + // read would see null. Preserve the ConstantValue (plaintext, disclosed) and exclude the + // value jar-wide so an equal LDC elsewhere is not encrypted+interned and then compares + // != to this still-plaintext field on ParparVM. + externallyReadConstantCount++; + newlyExcluded.add((String) fn.value); + continue; + } + if (toStrip.size() >= budget) { + // Pool budget exhausted: leave this constant plaintext, and exclude it jar-wide so an + // equal LDC elsewhere is not encrypted+interned -- a GETSTATIC read of this still-plain + // field would otherwise compare != to that interned copy on ParparVM. + clinitFullLiteralCount++; + newlyExcluded.add((String) fn.value); + continue; + } + String plain = (String) fn.value; + // shouldEncrypt already rejected any value whose ciphertext could overflow the + // constant pool (class-independent bound), so the encode result fits. + init.add(new LdcInsnNode(encode(plain, base))); + // itf=true when the decoder lives in an interface, else the JVM emits a Methodref + // instead of an InterfaceMethodref and throws IncompatibleClassChangeError. + init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, isInterface)); + init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, fn.name, fn.desc)); + toStrip.add(fn); + } + } + if (toStrip.isEmpty()) { + return false; + } + if (!clinitCanAccept(cn, init)) { + // The cannot hold the decode steps, so none of these constants can be encrypted here. + // Exclude them jar-wide so an equal LDC in another class is not encrypted+interned while these + // fields stay plaintext, which a GETSTATIC read would see as a broken == on ParparVM. + clinitFullLiteralCount += toStrip.size(); + for (FieldNode fn : toStrip) { + newlyExcluded.add((String) fn.value); + } + return false; + } + // Commit: strip each ConstantValue so the plaintext leaves the class file entirely (the slot + // ParparVM would otherwise dump into the C constant pool), and decode it once in . + for (FieldNode fn : toStrip) { + fn.value = null; + encryptedCount++; + } + poolItemsRemaining -= toStrip.size() * POOL_ITEMS_PER_STATIC; + prependToClinit(cn, init, isInterface); + return true; + } + + /** + * 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, boolean isInterface) { + // A generated class can hoist/encrypt thousands of constants; emitting every init unit into a + // single can blow past the 65535-byte method limit (ASM throws MethodTooLargeException + // even though every input method was valid). When the initializer is large, split it across + // synthetic helper methods and have call them in order. Each init unit ends with + // PUTSTATIC (stack empty), so cutting after a PUTSTATIC keeps every chunk verifiable. + // + // Measure the COMBINED ENCODED bytes -- the class may already carry a large , so even a + // small new initializer inserted directly could push the existing method over the limit, and a + // node count cannot prove the direct-insert path fits. When the total is under the bound, insert + // directly; otherwise split so only gains a few calls. Callers pre-check clinitCanAccept + // before mutating, so the split is only reached when the calls themselves fit. + int existingBytes = MethodSize.estimateBytes(findClinit(cn)); + if (existingBytes + MethodSize.estimateBytes(init) <= MethodSize.SAFE_LIMIT) { + insertIntoClinit(cn, init); + return; + } + java.util.Set taken = new java.util.HashSet(); + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + taken.add(mn.name); + } + } + int access = (isInterface ? Opcodes.ACC_PUBLIC : Opcodes.ACC_PRIVATE) + | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC; + InsnList calls = new InsnList(); + InsnList chunk = new InsnList(); + int chunkBytes = 0; + int helperCounter = 0; + AbstractInsnNode insn = init.getFirst(); + while (insn != null) { + AbstractInsnNode next = insn.getNext(); + init.remove(insn); + chunkBytes += MethodSize.estimateBytes(insn); + chunk.add(insn); + boolean atBoundary = insn.getOpcode() == Opcodes.PUTSTATIC && chunkBytes >= MAX_CLINIT_CHUNK_BYTES; + if (atBoundary || next == null) { + String hname; + do { + hname = INIT_HELPER_PREFIX + helperCounter; + helperCounter++; + } while (taken.contains(hname)); + taken.add(hname); + MethodNode helper = new MethodNode(Opcodes.ASM9, access, hname, "()V", null, null); + helper.instructions = new InsnList(); + helper.instructions.add(chunk); + helper.instructions.add(new InsnNode(Opcodes.RETURN)); + cn.methods.add(helper); + // itf=true when the helper lives in an interface, or the JVM emits a Methodref instead + // of an InterfaceMethodref and throws IncompatibleClassChangeError at run time. + calls.add(new MethodInsnNode(Opcodes.INVOKESTATIC, cn.name, hname, "()V", isInterface)); + chunk = new InsnList(); + chunkBytes = 0; + } + insn = next; + } + insertIntoClinit(cn, calls); + } + + /** + * Whether {@code prependToClinit} can add {@code init} without pushing {@code } past the + * method limit. A direct insert must fit under {@link MethodSize#SAFE_LIMIT}; otherwise the split + * moves {@code init} into helper methods and {@code } only gains one call per chunk, so the + * check is whether the existing initializer plus those calls fits. Returns false only in the extreme + * case that even the calls do not fit -- then the caller leaves the literals plaintext rather than + * mutating a class it cannot finish. + */ + private boolean clinitCanAccept(ClassNode cn, InsnList init) { + int existingBytes = MethodSize.estimateBytes(findClinit(cn)); + int initBytes = MethodSize.estimateBytes(init); + if (existingBytes + initBytes <= MethodSize.SAFE_LIMIT) { + return true; + } + int chunks = (initBytes + MAX_CLINIT_CHUNK_BYTES - 1) / MAX_CLINIT_CHUNK_BYTES; + if (chunks < 1) { + chunks = 1; + } + return existingBytes + chunks * CLINIT_CALL_BYTES <= MethodSize.SAFE_LIMIT; + } + + /** The class's existing {@code }, or {@code null} if it has none. */ + private static MethodNode findClinit(ClassNode cn) { + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + if ("".equals(mn.name) && "()V".equals(mn.desc)) { + return mn; + } + } + } + return null; + } + + private void insertIntoClinit(ClassNode cn, InsnList init) { + MethodNode clinit = findClinit(cn); + if (clinit == null) { + clinit = new MethodNode(Opcodes.ASM9, + Opcodes.ACC_STATIC, "", "()V", null, null); + 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, String decoderName) { + // A Java 8 interface may only have public static methods (private statics are 9+), so the + // decoder is public there; in a class it stays private. + int access = (isInterface ? Opcodes.ACC_PUBLIC : Opcodes.ACC_PRIVATE) + | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC; + MethodNode m = new MethodNode(Opcodes.ASM9, access, decoderName, 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); + } + + /** + * A decoder method name for {@code cn} that collides with no existing member. Starts from the + * base name and lengthens the {@code $} suffix until unused, so a class is never skipped for a + * clash (which would leave its literals in plaintext and break cross-class literal {@code ==}). + */ + private String resolveDecoderName(ClassNode cn) { + String name = DECODER_NAME; + while (memberExists(cn, name)) { + name = name + "$"; + } + return name; + } + + private boolean memberExists(ClassNode cn, String name) { + if (cn.methods != null) { + for (MethodNode mn : cn.methods) { + // Same descriptor would be an outright clash; a differently-typed method of the same + // name is legal, but the decoder is also referenced by name from , so keep it + // simple and avoid the name entirely. + if (name.equals(mn.name)) { + return true; + } + } + } + if (cn.fields != null) { + for (FieldNode fn : cn.fields) { + if (name.equals(fn.name)) { + return true; + } + } + } + return false; + } + + /** 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; + } + // Skip a literal whose ciphertext could overflow the 65535-byte constant-pool limit and make + // ASM throw. The XOR key varies per class, so decide from the plaintext's WORST case -- every + // character widening to a 3-byte modified-UTF-8 character. That bound is class-INDEPENDENT, so + // the same value is skipped in every class rather than encrypted in one and left plaintext in + // another, which would break a valid cross-class literal == on ParparVM's deduplicated pool. + if ((long) s.length() * 3 > 65535) { + return false; + } + return true; + } + + /** + * 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 (jarExcluded != null && jarExcluded.contains(s)) { + return false; + } + if (libraryLiterals != null && libraryLiterals.contains(s)) { + // Left plaintext so the app copy stays the same (never-interned) constant-pool object the + // unhardened library class uses, preserving a valid literal == across the boundary on ParparVM. + libraryExcludedValues.add(s); + return false; + } + if (encryptAllStrings) { + return true; + } + 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); + } + + /** Package-visible so a test can construct a value whose ciphertext equals another value. */ + int keyBase(String internalName) { + int h = seed; + for (int i = 0; i < internalName.length(); i++) { + h = h * 31 + internalName.charAt(i); + } + 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..949b0774ca3 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/BuiltinKeepRulesTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.hardening; + +import 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 packageNamesAreNotKept() { + // Codename One has no getResource for nested packages, so package names are obfuscated too; + // -keeppackagenames must NOT be present. + for (String p : new String[] {"ios", "javase", "javascript", "win"}) { + assertFalse("packages must be obfuscated for " + p, + BuiltinKeepRules.flags(p).contains("-keeppackagenames")); + } + } + + @Test + public void lineNumbersKeptButSourceFileStripped() { + // Retracing needs LineNumberTable; SourceFile is stripped (the retrace synthesizes the file + // name from the class), matching DexGuard. + for (String p : new String[] {"ios", "javase", "and"}) { + boolean lineKept = false; + boolean sourceKept = false; + for (String f : BuiltinKeepRules.flags(p)) { + if (f.startsWith("-keepattributes")) { + lineKept = f.contains("LineNumberTable"); + sourceKept = f.contains("SourceFile"); + } + } + assertTrue("LineNumberTable kept for " + p, lineKept); + assertFalse("SourceFile stripped for " + p, sourceKept); + } + } + + @Test + public void keepsNameBoundBackgroundCallbacks() { + // Background callbacks the OS restarts by their persisted class name (Geofence, background + // location, background fetch) resolve the app's listener via Class.forName + newInstance, so + // renaming one silently stops the callback. Their implementors must be kept. + List rules = BuiltinKeepRules.rules("com.example.MyApp"); + assertTrue(rules.contains( + "-keep class * implements com.codename1.location.GeofenceListener { *; }")); + assertTrue(rules.contains( + "-keep class * implements com.codename1.location.LocationListener { *; }")); + assertTrue(rules.contains( + "-keep class * implements com.codename1.background.BackgroundFetch { *; }")); + assertTrue(rules.contains( + "-keep class * implements com.codename1.background.BackgroundWorker { *; }")); + // A Login subclass persists OAuth tokens under getClass().getName(), so its name must stay stable. + assertTrue(rules.contains( + "-keep class * extends com.codename1.social.Login { *; }")); + // A HealthBackgroundListener is persisted by class name and reconstructed by the generated factory + // after a process restart / app update, so its name must stay stable too. + assertTrue(rules.contains( + "-keep class * implements com.codename1.health.HealthBackgroundListener { *; }")); + // The same rules are exported to R8 on Android (where R8 does the renaming). + assertTrue(BuiltinKeepRules.forR8("com.example.MyApp").contains( + "-keep class * implements com.codename1.location.GeofenceListener { *; }")); + } +} 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..7a948861695 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/Cn1NameFactoryTest.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.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 dictionarySizeCoversTheLargestOfClassAndMemberScopes() { + // Small app, tiny class scope: the 50000 floor applies. + assertEquals(50000, Cn1NameFactory.dictionarySizeFor(100, 200)); + // Class-heavy jar: sized to the class scope with headroom. + assertEquals(80000, Cn1NameFactory.dictionarySizeFor(20000, 200)); + // A single member-heavy class (e.g. a generated interface with 55000 same-descriptor methods) + // must size the dictionary to the MEMBER scope, even though the class count is tiny -- otherwise + // ProGuard exhausts it and falls back to short names, reintroducing the ParparVM cull pathology. + assertTrue("dictionary must exceed the biggest class's member count", + Cn1NameFactory.dictionarySizeFor(50, 55000) >= 55000); + assertEquals(220000, Cn1NameFactory.dictionarySizeFor(50, 55000)); + } + + @Test + public void differentSeedsProduceDifferentDictionariesButSameSeedReproduces() throws Exception { + File a = tmp.newFile("a.txt"); + 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/ConcatLiteralDetectionTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java new file mode 100644 index 00000000000..7257cde7ecb --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ConcatLiteralDetectionTest.java @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.hardening; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.ConstantDynamic; +import org.objectweb.asm.Handle; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +/** + * JDK 9+ javac compiles {@code "a" + b} to an {@code invokedynamic} bound to + * {@code StringConcatFactory}, keeping the literal fragments in the bootstrap recipe rather than an + * {@code LDC}. The transform cannot encrypt those, so it must at least count them so the engine can + * warn. JDK 8 (this module's build JDK) never emits that shape, so the fixture is assembled directly. + * + *

Recipe markers: U+0001 is an ordinary argument slot, U+0002 a constant slot that + * pulls from the bootstrap arguments after the recipe. + */ +public class ConcatLiteralDetectionTest { + + private static final char ARG = '\u0001'; + private static final char CONST = '\u0002'; + + private static final Handle CONCAT_BSM = new Handle( + Opcodes.H_INVOKESTATIC, + "java/lang/invoke/StringConcatFactory", + "makeConcatWithConstants", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;" + + "Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)" + + "Ljava/lang/invoke/CallSite;", + false); + + /** A class with one literal-bearing concat, one constant-arg concat, and one pure-dynamic concat. */ + private static byte[] fixture() { + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "com/codename1/hardening/fixture/Concat", + null, "java/lang/Object", null); + + // "secret=" + x -> recipe "secret=" + ARG, the literal embedded directly in the recipe. + MethodVisitor m1 = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "withInlineLiteral", + "(Ljava/lang/String;)Ljava/lang/String;", null, null); + m1.visitCode(); + m1.visitVarInsn(Opcodes.ALOAD, 0); + m1.visitInvokeDynamicInsn("makeConcatWithConstants", "(Ljava/lang/String;)Ljava/lang/String;", + CONCAT_BSM, new Object[] {"secret=" + ARG}); + m1.visitInsn(Opcodes.ARETURN); + m1.visitMaxs(1, 1); + m1.visitEnd(); + + // x + "-sep-" + y -> recipe ARG + CONST + ARG with the constant passed as a bootstrap argument. + MethodVisitor m2 = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "withConstantArg", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", null, null); + m2.visitCode(); + m2.visitVarInsn(Opcodes.ALOAD, 0); + m2.visitVarInsn(Opcodes.ALOAD, 1); + m2.visitInvokeDynamicInsn("makeConcatWithConstants", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", + CONCAT_BSM, new Object[] {"" + ARG + CONST + ARG, "-sep-"}); + m2.visitInsn(Opcodes.ARETURN); + m2.visitMaxs(2, 2); + m2.visitEnd(); + + // x + y, no literal at all (recipe is just the two argument markers): must NOT be counted. + MethodVisitor m3 = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "pureDynamic", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", null, null); + m3.visitCode(); + m3.visitVarInsn(Opcodes.ALOAD, 0); + m3.visitVarInsn(Opcodes.ALOAD, 1); + m3.visitInvokeDynamicInsn("makeConcatWithConstants", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", + CONCAT_BSM, new Object[] {"" + ARG + ARG}); + m3.visitInsn(Opcodes.ARETURN); + m3.visitMaxs(2, 2); + m3.visitEnd(); + + cw.visitEnd(); + return cw.toByteArray(); + } + + @Test + public void countsLiteralBearingConcatSitesOnly() { + StringEncryptTransform t = new StringEncryptTransform(true, 42); + t.transform(fixture()); + // withInlineLiteral (recipe literal) + withConstantArg (String bootstrap arg) = 2; pureDynamic = 0. + assertEquals(2, t.getConcatLiteralCount()); + } + + private static final Handle CONDY_BSM = new Handle( + Opcodes.H_INVOKESTATIC, + "app/CondyBootstrap", + "make", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;" + + "Ljava/lang/Object;)Ljava/lang/Object;", + false); + + /** A class with one string-bearing constant-dynamic LDC and one with only a numeric argument. */ + private static byte[] condyFixture() { + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, "com/codename1/hardening/fixture/Condy", + null, "java/lang/Object", null); + + // LDC ConstantDynamic whose bootstrap argument carries plaintext. + ConstantDynamic withString = new ConstantDynamic("secretConst", "Ljava/lang/String;", + CONDY_BSM, "secret-plaintext-value"); + MethodVisitor m1 = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "withString", + "()Ljava/lang/String;", null, null); + m1.visitCode(); + m1.visitLdcInsn(withString); + m1.visitInsn(Opcodes.ARETURN); + m1.visitMaxs(1, 0); + m1.visitEnd(); + + // A constant-dynamic with only a numeric bootstrap argument: no plaintext, not counted. + ConstantDynamic numeric = new ConstantDynamic("numConst", "I", + CONDY_BSM, Integer.valueOf(7)); + MethodVisitor m2 = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "numeric", + "()I", null, null); + m2.visitCode(); + m2.visitLdcInsn(numeric); + m2.visitInsn(Opcodes.IRETURN); + m2.visitMaxs(1, 0); + m2.visitEnd(); + + cw.visitEnd(); + return cw.toByteArray(); + } + + @Test + public void countsConstantDynamicStringArgumentsOnly() { + StringEncryptTransform t = new StringEncryptTransform(true, 7); + t.transform(condyFixture()); + assertEquals(1, t.getCondyLiteralCount()); + } + + @Test + public void countsStringNestedInsideAnotherConstantDynamicArgument() { + // The outer condy has no direct String argument; its plaintext hides inside a NESTED condy. + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, "com/codename1/hardening/fixture/NestedCondy", + null, "java/lang/Object", null); + ConstantDynamic inner = new ConstantDynamic("inner", "Ljava/lang/String;", + CONDY_BSM, "nested-plaintext-value"); + ConstantDynamic outer = new ConstantDynamic("outer", "Ljava/lang/String;", + CONDY_BSM, inner); + MethodVisitor m = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "m", + "()Ljava/lang/String;", null, null); + m.visitCode(); + m.visitLdcInsn(outer); + m.visitInsn(Opcodes.ARETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + cw.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 7); + t.transform(cw.toByteArray()); + assertEquals(1, t.getCondyLiteralCount()); + } + + private static final Handle CUSTOM_BSM = new Handle( + Opcodes.H_INVOKESTATIC, + "app/CustomBootstrap", + "bootstrap", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;" + + "Ljava/lang/invoke/MethodType;Ljava/lang/String;)Ljava/lang/invoke/CallSite;", + false); + + private static byte[] customIndyFixture(Object[] bsmArgs) { + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, "app/CustomIndy", null, "java/lang/Object", null); + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "run", + "()Ljava/lang/Object;", null, null); + mv.visitCode(); + mv.visitInvokeDynamicInsn("compute", "()Ljava/lang/Object;", CUSTOM_BSM, bsmArgs); + mv.visitInsn(Opcodes.ARETURN); + mv.visitMaxs(1, 0); + mv.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + + @Test + public void countsCustomInvokeDynamicStringBootstrapArguments() { + // A non-concat invokedynamic whose bootstrap arguments carry a plaintext String is reported. + StringEncryptTransform t = new StringEncryptTransform(true, 7); + t.transform(customIndyFixture(new Object[] {"a plaintext secret in a custom indy bootstrap"})); + assertEquals(1, t.getIndyLiteralCount()); + } + + @Test + public void countsStringNestedInCustomInvokeDynamicCondyArgument() { + // The custom indy has no direct String argument; its plaintext hides in a NESTED constant-dynamic. + ConstantDynamic inner = new ConstantDynamic("inner", "Ljava/lang/String;", + CONDY_BSM, "nested-indy-plaintext"); + StringEncryptTransform t = new StringEncryptTransform(true, 7); + t.transform(customIndyFixture(new Object[] {inner})); + assertEquals(1, t.getIndyLiteralCount()); + } + + @Test + public void customInvokeDynamicWithoutStringArgsIsNotCounted() { + StringEncryptTransform t = new StringEncryptTransform(true, 7); + t.transform(customIndyFixture(new Object[] {Integer.valueOf(42)})); + assertEquals(0, t.getIndyLiteralCount()); + } + + @Test + public void concatSitesAreNotDoubleCountedAsGenericIndy() { + // The StringConcatFactory recipe sites are counted as concat exclusions, never again as indy. + StringEncryptTransform t = new StringEncryptTransform(true, 42); + t.transform(fixture()); + assertEquals(2, t.getConcatLiteralCount()); + assertEquals(0, t.getIndyLiteralCount()); + } +} diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java new file mode 100644 index 00000000000..aef3d4e4d08 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ControlFlowTransformTest.java @@ -0,0 +1,237 @@ +/* + * 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)); + } + + @Test + public void guardsAClassThatAlreadyDeclaresAGuardFieldName() throws Exception { + // A class already declares a field named exactly like the guard field. It must still be + // guarded (with a non-colliding field), not returned unchanged on a false "already + // transformed" assumption. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/GuardClash", null, "java/lang/Object", null); + w.visitField(org.objectweb.asm.Opcodes.ACC_PRIVATE | org.objectweb.asm.Opcodes.ACC_STATIC, + ControlFlowTransform.GUARD_FIELD, "I", null, null).visitEnd(); + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "add", "(II)I", null, null); + m.visitCode(); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 0); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 1); + m.visitInsn(org.objectweb.asm.Opcodes.IADD); + m.visitInsn(org.objectweb.asm.Opcodes.IRETURN); + m.visitMaxs(2, 2); + m.visitEnd(); + w.visitEnd(); + + ControlFlowTransform t = new ControlFlowTransform(); + byte[] out = t.transform(w.toByteArray()); + assertTrue("the clashing class must still be guarded, not skipped", t.getGuardedMethods() >= 1); + CheckClassAdapter.verify(new ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.GuardClash", out); + assertEquals(5, c.getMethod("add", int.class, int.class).invoke(null, 2, 3)); + } + + @Test + public void oversizedMethodIsSkippedNotOverflowed() throws Exception { + // A method already near the 65535-byte limit cannot take a guard without overflowing. It must + // be skipped (and reported), while a normal sibling method is still guarded -- the build must + // not abort on a valid input class. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/BigMethod", null, "java/lang/Object", null); + // ~62 KB of harmless ICONST_0/POP filler: too large to accept a guard. + org.objectweb.asm.MethodVisitor big = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "big", "()V", null, null); + big.visitCode(); + for (int i = 0; i < 31000; i++) { + big.visitInsn(org.objectweb.asm.Opcodes.ICONST_0); + big.visitInsn(org.objectweb.asm.Opcodes.POP); + } + big.visitInsn(org.objectweb.asm.Opcodes.RETURN); + big.visitMaxs(1, 0); + big.visitEnd(); + // A normal method that can and must still be guarded. + org.objectweb.asm.MethodVisitor add = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "add", "(II)I", null, null); + add.visitCode(); + add.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 0); + add.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 1); + add.visitInsn(org.objectweb.asm.Opcodes.IADD); + add.visitInsn(org.objectweb.asm.Opcodes.IRETURN); + add.visitMaxs(2, 2); + add.visitEnd(); + w.visitEnd(); + + ControlFlowTransform t = new ControlFlowTransform(); + byte[] out = t.transform(w.toByteArray()); + assertEquals("the near-limit method must be skipped", 1, t.getOversizedMethods()); + assertTrue("the normal method must still be guarded", t.getGuardedMethods() >= 1); + CheckClassAdapter.verify(new ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.BigMethod", out); + assertEquals(5, c.getMethod("add", int.class, int.class).invoke(null, 2, 3)); + } + + @Test + public void classWithNearFullClinitIsSkippedNotOverflowed() throws Exception { + // The guard reads a field initialized in . When is already near the limit the + // setup cannot be added, so guarding would leave the field 0 and every guard would throw. The + // whole class must be skipped (reported), not corrupted or aborted with MethodTooLargeException. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/FullClinitGuard", null, "java/lang/Object", null); + org.objectweb.asm.MethodVisitor clinit = w.visitMethod(org.objectweb.asm.Opcodes.ACC_STATIC, + "", "()V", null, null); + clinit.visitCode(); + for (int i = 0; i < 31500; i++) { // ~63 KB + clinit.visitInsn(org.objectweb.asm.Opcodes.ICONST_0); + clinit.visitInsn(org.objectweb.asm.Opcodes.POP); + } + clinit.visitInsn(org.objectweb.asm.Opcodes.RETURN); + clinit.visitMaxs(1, 0); + clinit.visitEnd(); + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "add", "(II)I", null, null); + m.visitCode(); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 0); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 1); + m.visitInsn(org.objectweb.asm.Opcodes.IADD); + m.visitInsn(org.objectweb.asm.Opcodes.IRETURN); + m.visitMaxs(2, 2); + m.visitEnd(); + w.visitEnd(); + + ControlFlowTransform t = new ControlFlowTransform(); + byte[] out = t.transform(w.toByteArray()); + assertEquals("no method can be guarded when cannot hold the setup", 0, t.getGuardedMethods()); + assertTrue("the skipped guardable method is reported", t.getOversizedMethods() >= 1); + CheckClassAdapter.verify(new ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.FullClinitGuard", out); + assertEquals(5, c.getMethod("add", int.class, int.class).invoke(null, 2, 3)); + } + + @Test + public void classWithNearFullPoolIsSkippedNotOverflowed() throws Exception { + // A class whose constant pool is already near the 65535-entry limit cannot fit the guard's added + // field and references. It must be skipped (reported), not aborted with ClassTooLargeException. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/PoolFullGuard", null, "java/lang/Object", null); + // ~20000 distinct string constants (~3 pool entries each) push the pool past the guard gate. + for (int i = 0; i < 20000; i++) { + w.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "F" + i, "Ljava/lang/String;", null, + "constant pool filler string number " + i).visitEnd(); + } + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "add", "(II)I", null, null); + m.visitCode(); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 0); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 1); + m.visitInsn(org.objectweb.asm.Opcodes.IADD); + m.visitInsn(org.objectweb.asm.Opcodes.IRETURN); + m.visitMaxs(2, 2); + m.visitEnd(); + w.visitEnd(); + + ControlFlowTransform t = new ControlFlowTransform(); + byte[] out = t.transform(w.toByteArray()); + assertEquals("no method is guarded when the pool cannot fit the guard", 0, t.getGuardedMethods()); + assertTrue("the skipped guardable method is reported", t.getOversizedMethods() >= 1); + CheckClassAdapter.verify(new ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + } + + // Renames the class internal name so the intense variant can load beside the plain one. + private static byte[] rename(byte[] bytes, String from, String to) { + org.objectweb.asm.ClassReader cr = new org.objectweb.asm.ClassReader(bytes); + 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..d34a11f8c25 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/FrameClassWriterTest.java @@ -0,0 +1,197 @@ +/* + * 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 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")); + } + + @Test + public void resolvesSharedBaseFromBytesWhenBaseCannotBeLoaded() { + // app/A and app/B both extend app/Base, but Base is supplied only by the target platform and is + // absent from the hierarchy. Loading A or B fails (their super can't be linked), so the resolver + // must read the super_class name from the bytes and return app/Base -- NOT Object, which is not + // assignable to Base and would fail StackMapTable verification for a merge used as a Base. + java.util.Map res = new java.util.HashMap(); + res.put("app/A.class", classExtending("app/A", "app/Base")); + res.put("app/B.class", classExtending("app/B", "app/Base")); + ClassLoader hierarchy = new BytesLoader(res); // app/Base intentionally absent + assertEquals("app/Base", common(hierarchy, "app/A", "app/B")); + } + + @Test + public void resolvesImplementedInterfaceFromBytesWhenSuperclassIsAbsent() { + // C implements app/I but extends an absent app/Base. Merging the interface-typed value with C must + // resolve to app/I (read from C's interfaces[] in the bytes), NOT Object -- else a subsequent + // invokeinterface on the merge gets an incompatible stack-map type. + java.util.Map res = new java.util.HashMap(); + res.put("app/I.class", interfaceClass("app/I")); + res.put("app/C.class", classExtendingImplementing("app/C", "app/Base", "app/I")); + ClassLoader hierarchy = new BytesLoader(res); // app/Base absent + assertEquals("app/I", common(hierarchy, "app/I", "app/C")); + assertEquals("app/I", common(hierarchy, "app/C", "app/I")); + } + + @Test + public void flagsIncompleteWhenIntermediateSupertypesAreAbsent() { + // app/A extends app/PlatformA, app/B extends app/PlatformB, and BOTH platform classes (which + // themselves share an absent Base) are absent from the hierarchy. The byte walk cannot see past the + // missing intermediates, so it collapses to Object -- but that guess may be too weak, so the writer + // must FLAG the merge incomplete so the caller ships the class unhardened with its original frames. + java.util.Map res = new java.util.HashMap(); + res.put("app/A.class", classExtending("app/A", "app/PlatformA")); + res.put("app/B.class", classExtending("app/B", "app/PlatformB")); + ClassLoader hierarchy = new BytesLoader(res); // PlatformA, PlatformB, Base all absent + FrameClassWriter w = new FrameClassWriter(0, hierarchy); + assertEquals("java/lang/Object", w.getCommonSuperClass("app/A", "app/B")); + assertTrue("a merge past a missing intermediate must be flagged incomplete", w.isHierarchyIncomplete()); + } + + @Test + public void flagsIncompleteWhenOneReadableChainHidesANearerBaseBehindAMissingIntermediate() { + // app/A extends app/Base (fully readable to Object); app/B extends an absent app/Missing that in + // reality extends Base. Walking A's chain reaches Object without finding a closer readable ancestor, + // but B's chain is broken at the missing intermediate, so the real common base (Base) is hidden. The + // merge must be flagged incomplete rather than silently returning the too-weak Object -- even though + // A's own chain is intact, the early loop return must not bypass the incomplete-chain check. + java.util.Map res = new java.util.HashMap(); + res.put("app/A.class", classExtending("app/A", "app/Base")); + res.put("app/B.class", classExtending("app/B", "app/Missing")); + res.put("app/Base.class", classExtending("app/Base", "java/lang/Object")); + FrameClassWriter w = new FrameClassWriter(0, new BytesLoader(res)); // app/Missing absent + assertEquals("java/lang/Object", w.getCommonSuperClass("app/A", "app/B")); + assertTrue("a nearer base hidden behind a missing intermediate must flag incomplete", + w.isHierarchyIncomplete()); + } + + @Test + public void resolvedSharedBaseIsNotFlaggedIncomplete() { + // When the byte walk DOES resolve the shared base (A and B both directly extend the absent Base), + // the result is Base, not a guess, so the merge must NOT be flagged incomplete -- the class can be + // hardened normally. + java.util.Map res = new java.util.HashMap(); + res.put("app/A.class", classExtending("app/A", "app/Base")); + res.put("app/B.class", classExtending("app/B", "app/Base")); + FrameClassWriter w = new FrameClassWriter(0, new BytesLoader(res)); // app/Base absent + assertEquals("app/Base", w.getCommonSuperClass("app/A", "app/B")); + assertFalse("a resolved shared base must not be flagged incomplete", w.isHierarchyIncomplete()); + } + + @Test + public void loaderResolvedMergeIsNotFlaggedIncomplete() { + // A precise, load-based Object result (two unrelated JDK types) is exact, not a hierarchy gap, so + // it must not be flagged. + FrameClassWriter w = new FrameClassWriter(0, getClass().getClassLoader()); + assertEquals("java/lang/Object", w.getCommonSuperClass("java/lang/String", "java/lang/Integer")); + assertFalse(w.isHierarchyIncomplete()); + } + + private static byte[] classExtending(String internal, String superName) { + return classExtendingImplementing(internal, superName, (String[]) null); + } + + private static byte[] classExtendingImplementing(String internal, String superName, String... itfs) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internal, null, superName, itfs); + cw.visitEnd(); + return cw.toByteArray(); + } + + private static byte[] interfaceClass(String internal) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_ABSTRACT | org.objectweb.asm.Opcodes.ACC_INTERFACE, + internal, null, "java/lang/Object", null); + cw.visitEnd(); + return cw.toByteArray(); + } + + /** A loader that serves the given {@code name.class -> bytes} as resources (parent = bootstrap). */ + private static final class BytesLoader extends ClassLoader { + private final java.util.Map resources; + + BytesLoader(java.util.Map resources) { + super(null); + this.resources = resources; + } + + @Override + public java.io.InputStream getResourceAsStream(String name) { + byte[] b = resources.get(name); + return b != null ? new java.io.ByteArrayInputStream(b) : super.getResourceAsStream(name); + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + byte[] b = resources.get(name.replace('.', '/') + ".class"); + if (b == null) { + throw new ClassNotFoundException(name); + } + return defineClass(name, b, 0, b.length); + } + } +} 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..57b3b8746b6 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/HardeningEngineTest.java @@ -0,0 +1,769 @@ +/* + * 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 native interface: {@code interface extends NativeInterface}. */ + private static byte[] nativeInterface(String internalName) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_ABSTRACT | org.objectweb.asm.Opcodes.ACC_INTERFACE, + internalName, null, "java/lang/Object", + new String[]{"com/codename1/system/NativeInterface"}); + cw.visitEnd(); + return cw.toByteArray(); + } + + /** A class declaring {@code count} distinct no-arg void methods (all sharing the {@code ()V} descriptor). */ + private static byte[] classWithVoidMethods(String internal, int count) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internal, null, "java/lang/Object", null); + for (int i = 0; i < count; i++) { + org.objectweb.asm.MethodVisitor mv = cw.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC, + "m" + i, "()V", null, null); + mv.visitCode(); + mv.visitInsn(org.objectweb.asm.Opcodes.RETURN); + mv.visitMaxs(0, 1); + mv.visitEnd(); + } + cw.visitEnd(); + return cw.toByteArray(); + } + + @Test + public void memberNamingScopeAccumulatesSameDescriptorMethodsAcrossClasses() { + // Same-descriptor methods across an inheritance hierarchy cannot share an obfuscated name (that + // would be an accidental override), so their naming scope is the SUM across classes, not the + // per-class max. Two classes each declaring 30 ()V methods => a scope of 60; the earlier per-class + // max (30) would undersize the dictionary and drop ProGuard back to short names. + java.util.Map classes = new java.util.HashMap(); + classes.put("app/A", classWithVoidMethods("app/A", 30)); + classes.put("app/B", classWithVoidMethods("app/B", 30)); + assertEquals(60, HardeningEngine.maxMemberNamingScope(classes)); + } + + /** A class with a SourceFile and a static run() that instantiates each referenced type (keeping it reachable). */ + private static byte[] mainReferencing(String internal, String sourceFile, String... refs) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter( + org.objectweb.asm.ClassWriter.COMPUTE_FRAMES); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internal, null, "java/lang/Object", null); + cw.visitSource(sourceFile, null); + org.objectweb.asm.MethodVisitor init = cw.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC, + "", "()V", null, null); + init.visitCode(); + init.visitVarInsn(org.objectweb.asm.Opcodes.ALOAD, 0); + init.visitMethodInsn(org.objectweb.asm.Opcodes.INVOKESPECIAL, "java/lang/Object", + "", "()V", false); + init.visitInsn(org.objectweb.asm.Opcodes.RETURN); + init.visitMaxs(1, 1); + init.visitEnd(); + org.objectweb.asm.MethodVisitor m = cw.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "run", "()V", null, null); + m.visitCode(); + for (String ref : refs) { + m.visitTypeInsn(org.objectweb.asm.Opcodes.NEW, ref); + m.visitInsn(org.objectweb.asm.Opcodes.DUP); + m.visitMethodInsn(org.objectweb.asm.Opcodes.INVOKESPECIAL, ref, "", "()V", false); + m.visitInsn(org.objectweb.asm.Opcodes.POP); + } + m.visitInsn(org.objectweb.asm.Opcodes.RETURN); + m.visitMaxs(2, 0); + m.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + + /** A class carrying a {@code SourceFile} attribute plus a constructor and a method to rename. */ + private static byte[] classWithSource(String internal, String sourceFile) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter( + org.objectweb.asm.ClassWriter.COMPUTE_FRAMES); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internal, null, "java/lang/Object", null); + cw.visitSource(sourceFile, null); + org.objectweb.asm.MethodVisitor init = cw.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC, + "", "()V", null, null); + init.visitCode(); + init.visitVarInsn(org.objectweb.asm.Opcodes.ALOAD, 0); + init.visitMethodInsn(org.objectweb.asm.Opcodes.INVOKESPECIAL, "java/lang/Object", + "", "()V", false); + init.visitInsn(org.objectweb.asm.Opcodes.RETURN); + init.visitMaxs(1, 1); + init.visitEnd(); + org.objectweb.asm.MethodVisitor m = cw.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC, + "doThing", "()V", null, null); + m.visitCode(); + m.visitInsn(org.objectweb.asm.Opcodes.RETURN); + m.visitMaxs(0, 1); + m.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + + @Test + public void engineMappingRecordsNonDefaultSourceFiles() throws Exception { + org.junit.Assume.assumeTrue("ProGuard renamer needs JDK <=20", HardeningEngine.proguardCanRunHere()); + // Screen is a Kotlin class (Screen.kt) whose name can't reconstruct the file; Widget's Widget.java + // IS the synthesized default. The engine strips SourceFile, so it must record Screen.kt in the + // mapping (else a retrace points Screen at a non-existent Screen.java) but need not record Widget. + File jar = tmp.newFile("srcfile.jar"); + FileOutputStream fo = new FileOutputStream(jar); + ZipOutputStream zos = new ZipOutputStream(fo); + zos.putNextEntry(new ZipEntry("app/Main.class")); + // Main (kept) references Screen and Widget so ProGuard reaches and renames them. + zos.write(mainReferencing("app/Main", "Main.java", "app/Screen", "app/Widget")); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("app/Screen.class")); + zos.write(classWithSource("app/Screen", "Screen.kt")); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("app/Widget.class")); + zos.write(classWithSource("app/Widget", "Widget.java")); + zos.closeEntry(); + zos.finish(); + fo.close(); + + Map hints = new HashMap(); + hints.put("harden.level", "standard"); // rename on (engine-renamed target) + File mapping = tmp.newFile("srcfile-map.txt"); + HardeningRequest req = new HardeningRequest() + .inputJar(jar).outputJar(tmp.newFile("srcfile-out.jar")).mappingFile(mapping) + .workDir(tmp.newFolder("srcfile-work")) + .config(HardeningConfig.from(hints, "ios", true)) + .mainClass("app.Main"); + HardeningResult r = HardeningEngine.harden(req); + assertTrue(r.isHardened()); + String map = new String(java.nio.file.Files.readAllBytes(mapping.toPath()), "UTF-8"); + assertTrue("the Kotlin source file must be recorded: " + map, + map.contains("\"fileName\":\"Screen.kt\"")); + assertFalse("the default Widget.java need not be recorded", + map.contains("\"fileName\":\"Widget.java\"")); + } + + /** A class whose {@code run()} method loads each given string literal (and discards it). */ + private static byte[] classWithLiterals(String internal, String... literals) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internal, null, "java/lang/Object", null); + org.objectweb.asm.MethodVisitor mv = cw.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "run", "()V", null, null); + mv.visitCode(); + for (String s : literals) { + mv.visitLdcInsn(s); + mv.visitInsn(org.objectweb.asm.Opcodes.POP); + } + mv.visitInsn(org.objectweb.asm.Opcodes.RETURN); + mv.visitMaxs(1, 0); + mv.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + + private File writeJar(String name, String internal, byte[] classBytes) throws Exception { + File jar = tmp.newFile(name); + FileOutputStream fo = new FileOutputStream(jar); + ZipOutputStream zos = new ZipOutputStream(fo); + zos.putNextEntry(new ZipEntry(internal + ".class")); + zos.write(classBytes); + zos.closeEntry(); + zos.finish(); + fo.close(); + return jar; + } + + private HardeningResult hardenAppWithLibrary(String platform, String appInternal, String[] appLiterals, + String libInternal, String[] libLiterals, String suffix) throws Exception { + File appJar = writeJar("app-" + suffix + ".jar", appInternal, classWithLiterals(appInternal, appLiterals)); + File libJar = writeJar("lib-" + suffix + ".jar", libInternal, classWithLiterals(libInternal, libLiterals)); + Map hints = new HashMap(); + hints.put("harden.level", "aggressive"); + hints.put("harden.strings", "all"); + hints.put("harden.rename", "false"); // isolate string encryption -- no ProGuard dependency + hints.put("harden.controlFlow", "false"); + HardeningRequest req = new HardeningRequest() + .inputJar(appJar).outputJar(tmp.newFile("out-" + suffix + ".jar")) + .mappingFile(tmp.newFile("map-" + suffix + ".txt")) + .reportFile(tmp.newFile("report-" + suffix + ".json")) + .workDir(tmp.newFolder("work-" + suffix)) + .config(HardeningConfig.from(hints, platform, true)) + .mainClass(appInternal.replace('/', '.')); + req.addLibraryJar(libJar); + return HardeningEngine.harden(req); + } + + /** + * A class with {@code public static final String FIELD = value} plus a second, unreferenced + * {@code OTHER} constant so an Android build (where the referenced FIELD is preserved) still encrypts + * something and is marked hardened. + */ + private static byte[] classWithStaticFinalString(String internal, String field, String value) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internal, null, "java/lang/Object", null); + cw.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, field, "Ljava/lang/String;", null, value).visitEnd(); + cw.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "OTHER", "Ljava/lang/String;", null, + "an unrelated secret constant not named by any bundled source").visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + + /** The ConstantValue of the named static-final String field in a class, or null if stripped/absent. */ + private static String constantValueOf(byte[] classBytes, final String field) { + final String[] holder = new String[1]; + new org.objectweb.asm.ClassReader(classBytes).accept( + new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) { + @Override + public org.objectweb.asm.FieldVisitor visitField(int access, String name, String desc, + String sig, Object value) { + if (field.equals(name)) { + holder[0] = value instanceof String ? (String) value : null; + } + return null; + } + }, org.objectweb.asm.ClassReader.SKIP_CODE); + return holder[0]; + } + + private HardeningResult hardenClassWithBundledSource(String platform, String constantValue, + String bundledSource, String suffix) throws Exception { + File jar = tmp.newFile("cv-" + suffix + ".jar"); + FileOutputStream fo = new FileOutputStream(jar); + ZipOutputStream zos = new ZipOutputStream(fo); + zos.putNextEntry(new ZipEntry("app/Constants.class")); + zos.write(classWithStaticFinalString("app/Constants", "MODE", constantValue)); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("com/lib/Native.java")); + zos.write(bundledSource.getBytes("UTF-8")); + zos.closeEntry(); + zos.finish(); + fo.close(); + Map hints = new HashMap(); + hints.put("harden.level", "aggressive"); + hints.put("harden.strings", "all"); + hints.put("harden.rename", "false"); + HardeningRequest req = new HardeningRequest() + .inputJar(jar).outputJar(tmp.newFile("cv-out-" + suffix + ".jar")) + .mappingFile(tmp.newFile("cv-map-" + suffix + ".txt")) + .reportFile(tmp.newFile("cv-report-" + suffix + ".json")) + .workDir(tmp.newFolder("cv-work-" + suffix)) + .config(HardeningConfig.from(hints, platform, false)) + .mainClass("app.Constants"); + return HardeningEngine.harden(req); + } + + @Test + public void staticFinalConstantReferencedByBundledSourceKeepsItsConstantValueOnAndroid() throws Exception { + // A bundled Android .java source references app.Constants.MODE in a case label. On Android the + // source is compiled against the transformed classes, so stripping MODE's ConstantValue would + // break that compilation. The engine preserves it (still a compile-time constant) on Android. + String value = "the mode constant a bundled source needs"; + String source = "package com.lib; class Native { int f(int x){ switch(x){ " + + "case /* app.Constants. */ 0: return app.Constants.MODE.length(); default: return 0; } } }"; + HardeningResult r = hardenClassWithBundledSource("and", value, source, "and"); + assertTrue(r.isHardened()); + byte[] cls = readAll(r.getHardenedJar()).get("app/Constants.class"); + assertEquals("MODE keeps its ConstantValue so the bundled source still compiles", value, + constantValueOf(cls, "MODE")); + // Preservation is selective: a constant NOT named by any bundled source is still encrypted. + assertEquals("an unreferenced constant is still stripped/encrypted", null, + constantValueOf(cls, "OTHER")); + } + + @Test + public void staticFinalConstantIsStrippedWhenNoSourceCompilesAgainstIt() throws Exception { + // iOS routes bundled .java into the resource tree and never compiles it, so there is no + // constant-expression hazard: MODE's ConstantValue is stripped and encrypted as usual. + String value = "the mode constant a bundled source needs"; + String source = "package com.lib; class Native { int f(){ return app.Constants.MODE.length(); } }"; + HardeningResult r = hardenClassWithBundledSource("ios", value, source, "ios"); + assertTrue(r.isHardened()); + assertEquals("on iOS the ConstantValue is stripped (encrypted), no bundled source compiles it", + null, constantValueOf(readAll(r.getHardenedJar()).get("app/Constants.class"), "MODE")); + assertTrue("the stripped constant is encrypted", r.getEncryptedStrings() >= 1); + } + + @Test + public void librarySharedLiteralsStayPlaintextOnParparVM() throws Exception { + // On a ParparVM-C target a compile-time literal is a never-interned constant-pool object while an + // encrypted app copy is intern()ed, so a value that ALSO appears as a literal in an unhardened + // library class must be left plaintext or a valid literal == against the library copy (which held + // before hardening) would break. A value unique to the app is still encrypted. + String shared = "value shared between the app and an unhardened library class"; + String appOnly = "a secret value that appears only in the application"; + HardeningResult r = hardenAppWithLibrary("ios", "app/App", new String[]{shared, appOnly}, + "lib/Lib", new String[]{shared}, "ios"); + assertTrue(r.isHardened()); + byte[] app = readAll(r.getHardenedJar()).get("app/App.class"); + assertTrue("a library-shared literal stays plaintext to preserve == on ParparVM", + StringEncryptTransform.containsStringLiteral(app, shared)); + assertFalse("an app-only literal is still encrypted", + StringEncryptTransform.containsStringLiteral(app, appOnly)); + } + + @Test + public void librarySharedLiteralsAreEncryptedOnRealJvm() throws Exception { + // On a real-JVM target every compile-time literal is interned to the same pool intern() uses, so + // there is no cross-boundary identity hazard: the library scan is skipped and the shared value IS + // encrypted (full coverage, no needless plaintext). + String shared = "value shared between the app and an unhardened library class"; + HardeningResult r = hardenAppWithLibrary("javase", "app/App", new String[]{shared}, + "lib/Lib", new String[]{shared}, "jvm"); + assertTrue(r.isHardened()); + byte[] app = readAll(r.getHardenedJar()).get("app/App.class"); + assertFalse("on a real JVM the shared literal is encrypted (no cross-boundary hazard)", + StringEncryptTransform.containsStringLiteral(app, shared)); + } + + private HardeningResult harden(HardeningProfile profile, String platform, boolean renameSupported) + throws Exception { + File in = buildInputJar(); + 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 offLevelWithStaleOverrideAppliesNoTransform() throws Exception { + // harden.level=off with a leftover harden.rename=true must not count as an applied transform, + // so a non-entitled build that turned hardening off is skipped (harden() returns SKIPPED for + // OFF) rather than rejected as not-entitled by the CLI's entitlement gate. + Map hints = new HashMap(); + hints.put("harden.level", "off"); + hints.put("harden.rename", "true"); + HardeningConfig cfg = HardeningConfig.from(hints, "ios", true); + assertFalse("off must apply no transform even with a stale override", + HardeningEngine.willApplyAnyTransform(cfg)); + File in = buildInputJar(); + File out = tmp.newFile("off-override-hardened.jar"); + HardeningRequest req = new HardeningRequest() + .inputJar(in).outputJar(out).mappingFile(tmp.newFile("off-override-map.txt")) + .workDir(tmp.newFolder("off-override-work")) + .config(cfg) + .mainClass("com.codename1.hardening.fixture.Secrets"); + HardeningResult r = HardeningEngine.harden(req); + assertFalse(r.isHardened()); + assertEquals(HardeningResult.Outcome.SKIPPED_NOT_REQUESTED, r.getOutcome()); + } + + @Test + public void androidRenameOnlyIsHardenedViaR8() throws Exception { + // Android (renameSupported=false), standard with strings off: the engine renames nothing, + // 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 reportSerializesWarnings() throws Exception { + // On the ParparVM native ports control-flow obfuscation is skipped as unsafe, which records a + // warning. String encryption still applies on iOS, so the build is hardened and a report is + // written (no ProGuard/rename needed). The warning must appear in the JSON report, not only in + // the forked-process log, or a consumer reading the report is told less than the truth. + File in = buildInputJar(); + File out = tmp.newFile("app-hardened-warn.jar"); + File mapping = tmp.newFile("mapping-warn.txt"); + File report = tmp.newFile("report-warn.json"); + Map hints = new HashMap(); + hints.put("harden.level", "aggressive"); + HardeningConfig cfg = HardeningConfig.from(hints, "ios", false); + HardeningRequest req = new HardeningRequest() + .inputJar(in).outputJar(out).mappingFile(mapping).reportFile(report) + .workDir(tmp.newFolder("work-warn")).config(cfg) + .mainClass("com.codename1.hardening.fixture.Secrets").buildKey("TESTKEY"); + HardeningResult r = HardeningEngine.harden(req); + assertTrue("string encryption should harden the iOS build", r.isHardened()); + assertFalse("the skipped control-flow pass should record a warning", r.getWarnings().isEmpty()); + String json = new String(Files.readAllBytes(report.toPath()), Charset.forName("UTF-8")); + assertTrue("report must contain a warnings array: " + json, json.indexOf("\"warnings\"") >= 0); + assertTrue("report must serialize the warning text: " + json, + json.indexOf("control-flow obfuscation is not applied") >= 0); + } + + @Test + public void scannerKeepsNativeInterfacePeers() throws Exception { + // Phase 1: find the native interface. Phase 2: keep ITS generated Impl/Stub peer -- narrow, + // not the old blanket **Impl / **Stub. + Map classes = new HashMap(); + classes.put("app/MyNative", nativeInterface("app/MyNative")); + classes.put(HELPER, resourceBytes(HELPER)); + InputJarKeepScanner scanner = new InputJarKeepScanner(); + scanner.scan(classes); + java.util.List rules = scanner.keepRules(); + assertTrue("the native interface's Impl peer must be kept", + rules.contains("-keep class app.MyNativeImpl { *; }")); + assertTrue("the native interface's Stub peer must be kept", + rules.contains("-keep class app.MyNativeStub { *; }")); + // A plain class is NOT kept -- there is no reflection to keep it for. + for (String rule : rules) { + assertFalse("a non-native class must not be kept: " + rule, + rule.contains("hardening.fixture.Helper")); + } + } + + @Test + public void androidExportsNativeInterfaceKeepsToR8() throws Exception { + // On Android the engine does not rename (R8 does), so the native-interface peer keeps plus + // the user's harden.keep must reach the R8 keep file. + File jar = tmp.newFile("r8.jar"); + FileOutputStream fo = new FileOutputStream(jar); + ZipOutputStream zos = new ZipOutputStream(fo); + putClass(zos, SECRETS); + zos.putNextEntry(new ZipEntry("app/MyNative.class")); + zos.write(nativeInterface("app/MyNative")); + 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("native interface peer must reach R8", + keep.contains("-keep class app.MyNativeImpl { *; }")); + assertTrue("the main class must reach R8", + keep.contains("com.codename1.hardening.fixture.Secrets")); + assertTrue("the user's harden.keep must reach R8", + 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/MappingWriterTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/MappingWriterTest.java new file mode 100644 index 00000000000..3deb3bb00d8 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/MappingWriterTest.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.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 java.util.HashMap; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** The mapping must carry the real source filename for classes whose name can't reconstruct it. */ +public class MappingWriterTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private File mappingWith(String body) throws Exception { + File f = tmp.newFile("mapping.txt"); + Files.write(f.toPath(), body.getBytes(Charset.forName("UTF-8"))); + return f; + } + + private String read(File f) throws Exception { + return new String(Files.readAllBytes(f.toPath()), Charset.forName("UTF-8")); + } + + @Test + public void injectsIndentedSourceFileMetadataForNonDefaultFilesOnly() throws Exception { + File map = mappingWith("com.foo.Screen -> a:\n" + + " void onClick() -> b\n" + + "com.foo.Widget -> c:\n"); + Map sf = new HashMap(); + sf.put("com.foo.Screen", "Screen.kt"); // Kotlin: name can't reconstruct the file + // Widget deliberately absent: its file is the synthesized default, so nothing to record. + + MappingWriter.injectSourceFiles(map, sf); + String out = read(map); + // The comment is INDENTED and placed immediately after the Screen class line, so the retrace + // parser attaches it to Screen (a column-0 comment would be skipped). + assertTrue(out, out.contains("com.foo.Screen -> a:\n" + + " # {\"id\":\"sourceFile\",\"fileName\":\"Screen.kt\"}\n")); + // The member line survives and stays under Screen. + assertTrue(out, out.contains("void onClick() -> b")); + // Widget got no comment (not in the map). + assertFalse(out, out.contains("Widget.kt")); + assertFalse(out, out.contains("\"fileName\":\"Widget")); + } + + @Test + public void escapesControlCharactersInSourceFileMetadata() throws Exception { + File map = mappingWith("com.foo.Screen -> a:\n"); + Map sf = new HashMap(); + sf.put("com.foo.Screen", "od\td\nx.kt"); // a tab and a newline in the file name + + MappingWriter.injectSourceFiles(map, sf); + String out = read(map); + // The control characters are emitted as escapes (\t, \n), so the metadata stays a single comment + // line -- a raw newline would split it and break the reader. + assertTrue(out, out.contains("\"fileName\":\"od\\td\\nx.kt\"}")); + // Exactly one metadata comment line -- the raw newline did NOT split it into two lines. + int first = out.indexOf("# {\"id\":\"sourceFile\""); + assertTrue(first >= 0); + assertEquals("metadata must be a single line", -1, out.indexOf("# {\"id\":\"sourceFile\"", first + 1)); + } + + @Test + public void noMapOrMissingFileIsANoOp() throws Exception { + File map = mappingWith("com.foo.A -> a:\n"); + MappingWriter.injectSourceFiles(map, null); + MappingWriter.injectSourceFiles(map, new HashMap()); + assertTrue(read(map).contains("com.foo.A -> a:")); + // A non-existent file must not throw. + MappingWriter.injectSourceFiles(new File(tmp.getRoot(), "nope.txt"), + java.util.Collections.singletonMap("com.foo.A", "A.kt")); + } +} diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/MethodSizeTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/MethodSizeTest.java new file mode 100644 index 00000000000..187306838b3 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/MethodSizeTest.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.hardening; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.JumpInsnNode; +import org.objectweb.asm.tree.LabelNode; + +/** The upper-bound byte estimator must never undercount an instruction's widest legal encoding. */ +public class MethodSizeTest { + + @Test + public void conditionalJumpChargesItsWidenedEightBytes() { + // A conditional IF* has no wide form, so ASM widens an out-of-range one to an inverted 3-byte + // conditional over a 5-byte GOTO_W == 8 bytes. Charging fewer would let a method dense with + // long-range conditionals pass the preflight and then throw MethodTooLargeException at write time. + LabelNode l = new LabelNode(); + InsnList insns = new InsnList(); + insns.add(new JumpInsnNode(Opcodes.IFEQ, l)); + insns.add(l); + assertEquals(8, MethodSize.estimateBytes(insns)); + } + + @Test + public void gotoAndJsrChargeTheirFiveByteWideForm() { + // GOTO/JSR DO have a 5-byte wide form (GOTO_W/JSR_W), so 5 is their widest encoding. + LabelNode g = new LabelNode(); + InsnList gotoList = new InsnList(); + gotoList.add(new JumpInsnNode(Opcodes.GOTO, g)); + gotoList.add(g); + assertEquals(5, MethodSize.estimateBytes(gotoList)); + + LabelNode j = new LabelNode(); + InsnList jsrList = new InsnList(); + jsrList.add(new JumpInsnNode(Opcodes.JSR, j)); + jsrList.add(j); + assertEquals(5, MethodSize.estimateBytes(jsrList)); + } +} diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.java new file mode 100644 index 00000000000..710cadf00d9 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/OutputVerifierTest.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.ByteArrayInputStream; +import java.io.InputStream; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Test; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +/** + * The output verifier double-checks every hardened class, but a class whose superclass is supplied only by + * the target platform (absent from the supplied jars) must not be rejected just because ASM's load-based + * data-flow verifier cannot resolve it -- its frames are already computed from the bytes. + */ +public class OutputVerifierTest { + + @Test + public void unresolvedSuperclassIsAcceptedViaStructuralFallback() throws Exception { + // app/C extends an absent app/Base. The data-flow verifier throws trying to resolve Base; verify() + // must fall back to structural verification (which needs no hierarchy) and NOT reject the class. + byte[] c = classWithSuperCtor("app/C", "app/Base"); + Map resources = new HashMap(); + resources.put("app/C.class", c); + ClassLoader hierarchy = new BytesLoader(resources); // app/Base intentionally absent + + Map classes = new LinkedHashMap(); + classes.put("app/C", c); + OutputVerifier.verify(classes, hierarchy); // must not throw + } + + @Test + public void structurallyValidClassWithResolvableHierarchyStillPasses() throws Exception { + // A class whose hierarchy DOES resolve goes through the full data-flow verification and passes. + Map classes = new LinkedHashMap(); + classes.put("app/Ok", classWithSuperCtor("app/Ok", "java/lang/Object")); + OutputVerifier.verify(classes, getClass().getClassLoader()); // must not throw + } + + @Test + public void unresolvedTypeInsideAMethodBodyIsAcceptedNotRejected() throws Exception { + // app/User.make() returns a new instance of an absent app/Missing. Depending on where the load + // fails, CheckClassAdapter's data-flow analyzer PRINTS the missing-type failure to the report + // instead of throwing; either way the class must be accepted via the structural fallback, not + // rejected as invalid bytecode. + byte[] u = classReturningNewInstanceOf("app/User", "app/Missing"); + Map classes = new LinkedHashMap(); + classes.put("app/User", u); + Map resources = new HashMap(); + resources.put("app/User.class", u); + OutputVerifier.verify(classes, new BytesLoader(resources)); // app/Missing absent; must not throw + } + + @Test + public void genuineErrorIsNotMaskedByAMissingTypeInAnotherMethod() throws Exception { + // One method references an absent type (tolerable); another has a genuine data-flow error (returns + // an int where a reference is required). Per-method verification must still REJECT the class -- the + // missing-type tolerance is scoped to the offending method and must not swallow the real error. + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "app/Mixed", null, "java/lang/Object", null); + MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + ctor.visitCode(); + ctor.visitVarInsn(Opcodes.ALOAD, 0); + ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + ctor.visitInsn(Opcodes.RETURN); + ctor.visitMaxs(1, 1); + ctor.visitEnd(); + // Tolerable: references an absent type. + MethodVisitor good = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "make", + "()Lapp/Missing;", null, null); + good.visitCode(); + good.visitTypeInsn(Opcodes.NEW, "app/Missing"); + good.visitInsn(Opcodes.DUP); + good.visitMethodInsn(Opcodes.INVOKESPECIAL, "app/Missing", "", "()V", false); + good.visitInsn(Opcodes.ARETURN); + good.visitMaxs(2, 0); + good.visitEnd(); + // Genuine defect: returns an int where an object reference is required. + MethodVisitor bad = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "bad", + "()Ljava/lang/Object;", null, null); + bad.visitCode(); + bad.visitInsn(Opcodes.ICONST_1); + bad.visitInsn(Opcodes.ARETURN); + bad.visitMaxs(1, 0); + bad.visitEnd(); + cw.visitEnd(); + byte[] mixed = cw.toByteArray(); + + Map classes = new LinkedHashMap(); + classes.put("app/Mixed", mixed); + Map resources = new HashMap(); + resources.put("app/Mixed.class", mixed); + boolean rejected = false; + try { + OutputVerifier.verify(classes, new BytesLoader(resources)); + } catch (HardeningException expected) { + rejected = true; + } + org.junit.Assert.assertTrue("a genuine data-flow error must still be rejected", rejected); + } + + /** A class with a method {@code static make()} that returns {@code new ()}. */ + private static byte[] classReturningNewInstanceOf(String internal, String absentType) { + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internal, null, "java/lang/Object", null); + MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + ctor.visitCode(); + ctor.visitVarInsn(Opcodes.ALOAD, 0); + ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + ctor.visitInsn(Opcodes.RETURN); + ctor.visitMaxs(1, 1); + ctor.visitEnd(); + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "make", + "()L" + absentType + ";", null, null); + mv.visitCode(); + mv.visitTypeInsn(Opcodes.NEW, absentType); + mv.visitInsn(Opcodes.DUP); + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, absentType, "", "()V", false); + mv.visitInsn(Opcodes.ARETURN); + mv.visitMaxs(2, 0); + mv.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + + private static byte[] classWithSuperCtor(String internal, String superName) { + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internal, null, superName, null); + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + mv.visitCode(); + mv.visitVarInsn(Opcodes.ALOAD, 0); + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, superName, "", "()V", false); + mv.visitInsn(Opcodes.RETURN); + mv.visitMaxs(1, 1); + mv.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + + /** A loader that serves the given {@code name.class -> bytes} as resources (parent = bootstrap). */ + private static final class BytesLoader extends ClassLoader { + private final Map resources; + + BytesLoader(Map resources) { + super(null); + this.resources = resources; + } + + @Override + public InputStream getResourceAsStream(String name) { + byte[] b = resources.get(name); + return b != null ? new ByteArrayInputStream(b) : super.getResourceAsStream(name); + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + byte[] b = resources.get(name.replace('.', '/') + ".class"); + if (b == null) { + throw new ClassNotFoundException(name); + } + return defineClass(name, b, 0, b.length); + } + } +} diff --git a/maven/cn1-hardening/src/test/java/com/codename1/hardening/ProGuardRunnerTest.java b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ProGuardRunnerTest.java new file mode 100644 index 00000000000..11fd94520f8 --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/ProGuardRunnerTest.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.hardening; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.File; +import org.junit.Test; + +/** ProGuard file names must be quoted with a quote character the path itself does not contain. */ +public class ProGuardRunnerTest { + + @Test + public void ordinaryPathUsesSingleQuotes() { + String q = ProGuardRunner.quote(new File("/tmp/plain.jar")); + assertTrue(q, q.startsWith("'") && q.endsWith("'")); + assertTrue(q, q.indexOf("plain.jar") >= 0); + } + + @Test + public void apostrophePathFallsBackToDoubleQuotes() { + // ProGuard cannot escape a quote inside a quoted name, so a path with an apostrophe (o'brien) + // must be double-quoted -- single quotes would truncate the name at the apostrophe. + String q = ProGuardRunner.quote(new File("/tmp/o'brien/app.jar")); + assertTrue(q, q.startsWith("\"") && q.endsWith("\"")); + assertTrue(q, q.indexOf("o'brien") >= 0); + } + + @Test + public void pathWithBothQuoteCharactersIsRejected() { + try { + ProGuardRunner.quote(new File("/tmp/o'brien\"x/app.jar")); + fail("a path with both a single and a double quote cannot be represented to ProGuard"); + } catch (IllegalArgumentException expected) { + // expected: fail clearly rather than emit invalid config + } + } +} diff --git a/maven/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..32c7c27bace --- /dev/null +++ b/maven/cn1-hardening/src/test/java/com/codename1/hardening/StringEncryptTransformTest.java @@ -0,0 +1,1088 @@ +/* + * 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 repeatedLiteralIsHoistedAndDecodedOnce() throws Exception { + // The same value is used by two methods. It must be hoisted to ONE synthetic field decoded + // once (encryptedCount == 1, not 2), and both reads must return that one interned object. + String shared = "a shared hoisted secret literal"; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Hoist", null, "java/lang/Object", null); + for (String name : new String[] {"a", "b"}) { + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, name, "()Ljava/lang/String;", null, null); + m.visitCode(); + m.visitLdcInsn(shared); + m.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + } + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 21); + byte[] out = t.transform(w.toByteArray()); + assertEquals("a repeated literal must be hoisted to a single decoded field", 1, t.getEncryptedCount()); + assertFalse(StringEncryptTransform.containsStringLiteral(out, shared)); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.Hoist", out); + Object a = c.getMethod("a").invoke(null); + Object b = c.getMethod("b").invoke(null); + assertEquals(shared, a); + org.junit.Assert.assertSame("both reads share one interned object", a, b); + } + + @Test + public void hoistedFieldNameDoesNotCollideWithExistingField() throws Exception { + // The class already declares a field named exactly like the first generated hoisted name. + // The transform must pick a different name, not add a duplicate field. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/FieldClash", null, "java/lang/Object", null); + w.visitField(org.objectweb.asm.Opcodes.ACC_PRIVATE | org.objectweb.asm.Opcodes.ACC_STATIC, + "zqL$0", "Ljava/lang/String;", null, null).visitEnd(); + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "get", "()Ljava/lang/String;", null, null); + m.visitCode(); + m.visitLdcInsn("a clashing hoist secret value"); + m.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 31); + byte[] out = t.transform(w.toByteArray()); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.FieldClash", out); + assertEquals("a clashing hoist secret value", c.getMethod("get").invoke(null)); + } + + @Test + public void injectedInitializerCiphertextIsNotRewritten() throws Exception { + // Construct two literals A and B where B is exactly the ciphertext of A for this class's key. + // The hoisted initializer's LDC of A's ciphertext (== B) must NOT be rewritten into a read of + // B's field, or reads an unassigned field and throws ExceptionInInitializerError. + String owner = "app/Involutive"; + StringEncryptTransform probe = new StringEncryptTransform(true, 99); + int base = probe.keyBase(owner); + String a = "an involutive secret literal"; + String b = StringEncryptTransform.encode(a, base); // ciphertext of A == plaintext B + + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + owner, null, "java/lang/Object", null); + addStringGetter(w, "a", a); + addStringGetter(w, "b", b); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 99); + byte[] out = t.transform(w.toByteArray()); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.Involutive", out); // must not throw on + assertEquals(a, c.getMethod("a").invoke(null)); + assertEquals(b, c.getMethod("b").invoke(null)); + } + + @Test + public void oversizedLiteralIsCountedAsExcluded() throws Exception { + // A valid ASCII literal too large to encrypt (its ciphertext could overflow the constant pool) + // is left plaintext AND counted, so an strings:all build reports the exclusion. + StringBuilder big = new StringBuilder(); + for (int i = 0; i < 30000; i++) { + big.append('x'); + } + String huge = big.toString(); + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Oversized", null, "java/lang/Object", null); + addStringGetter(w, "huge", huge); + addStringGetter(w, "small", "an encryptable small secret value"); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 7); + byte[] out = t.transform(w.toByteArray()); + assertEquals("the oversized literal must be counted as excluded", 1, t.getOversizedLiteralCount()); + assertTrue("the small literal is still encrypted", t.getEncryptedCount() >= 1); + assertTrue("the oversized literal stays plaintext", + StringEncryptTransform.containsStringLiteral(out, huge)); + assertFalse("the small literal is encrypted away", + StringEncryptTransform.containsStringLiteral(out, "an encryptable small secret value")); + } + + /** Builds a class whose existing is padByteApprox bytes of harmless ICONST_0/POP filler. */ + private static org.objectweb.asm.ClassWriter classWithBigClinit(String owner, int padPairs) { + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + owner, null, "java/lang/Object", null); + org.objectweb.asm.MethodVisitor clinit = w.visitMethod(org.objectweb.asm.Opcodes.ACC_STATIC, + "", "()V", null, null); + clinit.visitCode(); + for (int i = 0; i < padPairs; i++) { + clinit.visitInsn(org.objectweb.asm.Opcodes.ICONST_0); + clinit.visitInsn(org.objectweb.asm.Opcodes.POP); + } + clinit.visitInsn(org.objectweb.asm.Opcodes.RETURN); + clinit.visitMaxs(1, 0); + clinit.visitEnd(); + return w; + } + + private static boolean hasHelper(byte[] out) { + final boolean[] saw = {false}; + new org.objectweb.asm.ClassReader(out).accept(new org.objectweb.asm.ClassVisitor( + org.objectweb.asm.Opcodes.ASM9) { + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, String desc, + String sig, String[] exceptions) { + if (name.startsWith("zqCI$")) { + saw[0] = true; + } + return null; + } + }, org.objectweb.asm.ClassReader.SKIP_CODE); + return saw[0]; + } + + @Test + public void existingLargeClinitIsSplitWhenCombinedWithNewInit() throws Exception { + // A small new initializer that would fit on its own is still split into a helper when the class + // already carries a near the 65535-byte limit -- the split decision measures the + // COMBINED encoded size, not just the new init. ~59.6 KB of existing + a handful of + // literals exceeds the safe bound, so the new init must move into a helper. + org.objectweb.asm.ClassWriter w = classWithBigClinit("app/BigExistingClinit", 29800); + for (int i = 0; i < 40; i++) { + addStringGetter(w, "probe" + i, "a hoisted secret literal value number " + i); + } + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 13); + byte[] out = t.transform(w.toByteArray()); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + assertTrue("a large existing must push the new init into a helper", hasHelper(out)); + assertEquals(40, t.getEncryptedCount()); + Class c = new ByteLoader().define("app.BigExistingClinit", out); + assertEquals("a hoisted secret literal value number 0", c.getMethod("probe0").invoke(null)); + } + + @Test + public void staticFinalEncryptionIsCappedByConstantPoolBudget() throws Exception { + // A class with thousands of static-final String constants would overflow the 65535-entry pool if + // every one were encrypted (each adds a PUTSTATIC Fieldref + NameAndType). The transform caps it + // at the pool budget and leaves the rest plaintext (safe: the field value is dead once reads are + // inlined) rather than throwing ClassTooLargeException. + int count = 12000; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/ManyConstants", null, "java/lang/Object", null); + for (int i = 0; i < count; i++) { + w.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "F" + i, "Ljava/lang/String;", null, + "a static final constant value number " + i).visitEnd(); + } + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 3); + byte[] out = t.transform(w.toByteArray()); + assertTrue("some constants are encrypted within the pool budget", t.getEncryptedCount() > 0); + assertTrue("the rest are left plaintext and reported", t.getClinitFullLiteralCount() > 0); + // The budget-skipped constants must be excluded jar-wide, so an equal LDC in another class is not + // encrypted+interned while these fields stay plaintext (a GETSTATIC == mismatch on ParparVM). + assertFalse("budget-skipped constants are recorded for jar-wide exclusion", + t.getNewlyExcluded().isEmpty()); + // The class assembles and verifies -- no ClassTooLargeException. + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + } + + @Test + public void jarExcludedValueStaysPlaintextInAStaticFinalField() throws Exception { + // A value excluded jar-wide (a method elsewhere could not grow to encrypt it) must stay plaintext + // in the static-final field that declares it, or a GETSTATIC read of that decoded+interned field + // would compare != to the excluded plaintext literal on ParparVM. The static-field path must honor + // jarExcluded exactly like the method-literal path. + String excluded = "a jar-wide excluded constant that must remain plaintext everywhere"; + String other = "an unrelated constant that is still encrypted here"; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Const", null, "java/lang/Object", null); + w.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "K", "Ljava/lang/String;", null, excluded).visitEnd(); + w.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "K2", "Ljava/lang/String;", null, other).visitEnd(); + w.visitEnd(); + + java.util.Set jarExcluded = new java.util.HashSet(); + jarExcluded.add(excluded); + StringEncryptTransform t = new StringEncryptTransform(true, 3, null, null, jarExcluded); + byte[] out = t.transform(w.toByteArray()); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + assertTrue("the jar-excluded constant keeps its plaintext ConstantValue", + StringEncryptTransform.containsStringLiteral(out, excluded)); + assertFalse("a non-excluded constant is still encrypted", + StringEncryptTransform.containsStringLiteral(out, other)); + } + + @Test + public void shortLiteralsAreLeftPlaintextAndDisclosed() throws Exception { + // One- and two-character literals are not worth the decoder overhead; in strings:all they stay + // plaintext but must be counted (distinctly) so the coverage claim does not silently omit them. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Shorts", null, "java/lang/Object", null); + org.objectweb.asm.MethodVisitor mv = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "run", "()V", null, null); + mv.visitCode(); + mv.visitLdcInsn("x"); // 1 char -> short + mv.visitInsn(org.objectweb.asm.Opcodes.POP); + mv.visitLdcInsn("ab"); // 2 char -> short + mv.visitInsn(org.objectweb.asm.Opcodes.POP); + mv.visitLdcInsn("x"); // duplicate short -> distinct count is still 2 + mv.visitInsn(org.objectweb.asm.Opcodes.POP); + mv.visitLdcInsn("a long enough literal to actually encrypt"); + mv.visitInsn(org.objectweb.asm.Opcodes.POP); + mv.visitInsn(org.objectweb.asm.Opcodes.RETURN); + mv.visitMaxs(1, 0); + mv.visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 9); + byte[] out = t.transform(w.toByteArray()); + assertEquals("two distinct short literals disclosed", 2, t.getShortLiteralCount()); + assertTrue("the long literal is still encrypted", t.getEncryptedCount() >= 1); + assertTrue("a short literal stays plaintext", + StringEncryptTransform.containsStringLiteral(out, "ab")); + } + + @Test + public void shortStaticFinalConstantIsLeftPlaintextAndDisclosed() throws Exception { + // A short static-final String is skipped by encryptStaticFinalStrings (shouldEncryptLiteral -> + // shouldEncrypt rejects length <= 2), so its ConstantValue stays plaintext and would leak into + // ParparVM's C pool. The short-literal disclosure must count that field channel too -- not only + // method LDCs -- or an strings:all build could advertise full coverage while a short constant leaks. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/ShortConst", null, "java/lang/Object", null); + // Only a short static-final constant, no method LDC of it: the count must come from the field channel. + w.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "S", "Ljava/lang/String;", null, "ab").visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 9); + byte[] out = t.transform(w.toByteArray()); + assertEquals("the short static-final constant is disclosed", 1, t.getShortLiteralCount()); + assertTrue("the short static-final constant stays plaintext", + StringEncryptTransform.containsStringLiteral(out, "ab")); + } + + @Test + public void getStaticCollectorResolvesInheritedFieldToDeclaringClass() throws Exception { + // A GETSTATIC app/C.X whose field X is declared by superclass app/B must be recorded as app/B.X -- + // the declaring class the transform keys on -- not the reference owner app/C, or the preservation + // would miss it and B.X's ConstantValue would still be migrated. + int fld = org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL; + org.objectweb.asm.ClassWriter b = new org.objectweb.asm.ClassWriter(0); + b.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/B", null, "java/lang/Object", null); + b.visitField(fld, "X", "Ljava/lang/String;", null, "a declared constant value").visitEnd(); + b.visitEnd(); + org.objectweb.asm.ClassWriter c = new org.objectweb.asm.ClassWriter(0); + c.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/C", null, "app/B", null); + org.objectweb.asm.MethodVisitor m = c.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "m", "()Ljava/lang/String;", null, null); + m.visitCode(); + m.visitFieldInsn(org.objectweb.asm.Opcodes.GETSTATIC, "app/C", "X", "Ljava/lang/String;"); + m.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + c.visitEnd(); + + java.util.Map res = new java.util.HashMap(); + res.put("app/B.class", b.toByteArray()); + res.put("app/C.class", c.toByteArray()); + ClassLoader hierarchy = new ResourceOnlyLoader(res); + java.util.Set out = new java.util.HashSet(); + StringEncryptTransform.collectGetStaticStringReads(c.toByteArray(), out, hierarchy); + assertTrue("inherited GETSTATIC resolves to the declaring class", out.contains("app/B.X")); + assertFalse("the reference owner is not recorded", out.contains("app/C.X")); + } + + @Test + public void externallyGetStaticReadConstantKeepsItsConstantValue() throws Exception { + // A static-final String read by a non-inlined GETSTATIC (generated bytecode) must keep its + // ConstantValue: moving it into would change reentrant-init ordering (a superclass reading + // this subclass constant during init would then see null). So it stays plaintext and is excluded + // jar-wide. Without that marking the same constant is encrypted away. + int flags = org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Sub", null, "java/lang/Object", null); + w.visitField(flags, "CONST", "Ljava/lang/String;", null, "a secret constant value here").visitEnd(); + w.visitEnd(); + byte[] input = w.toByteArray(); + + StringEncryptTransform kept = new StringEncryptTransform(true, 9); + kept.setExternallyReadStaticFields(java.util.Collections.singleton("app/Sub.CONST")); + byte[] keptOut = kept.transform(input); + assertEquals("the externally-read constant is disclosed", 1, kept.getExternallyReadConstantCount()); + assertTrue("its ConstantValue is preserved (plaintext)", + StringEncryptTransform.containsStringLiteral(keptOut, "a secret constant value here")); + assertTrue("its value is excluded jar-wide", + kept.getNewlyExcluded().contains("a secret constant value here")); + + StringEncryptTransform enc = new StringEncryptTransform(true, 9); + byte[] encOut = enc.transform(input); + assertEquals(0, enc.getExternallyReadConstantCount()); + assertFalse("without the marking the constant is encrypted away", + StringEncryptTransform.containsStringLiteral(encOut, "a secret constant value here")); + } + + @Test + public void classWithUnresolvableFrameMergeIsLeftUnhardenedAndLiteralsExcluded() throws Exception { + // app/C.pick merges app/A and app/B at a control-flow join; both extend absent platform + // intermediates whose shared base is also absent, so FrameClassWriter cannot resolve the merge and + // flags it incomplete. The transform must ship C UNHARDENED (its literal stays plaintext) rather + // than emit a possibly-invalid Object-widened frame, AND exclude that literal jar-wide so another + // class does not encrypt+intern it and break a valid == against C's plaintext copy on ParparVM. + int v18 = org.objectweb.asm.Opcodes.V1_8; + int pubStatic = org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(v18, org.objectweb.asm.Opcodes.ACC_PUBLIC, "app/C", null, "java/lang/Object", null); + org.objectweb.asm.MethodVisitor s = w.visitMethod(pubStatic, "secret", "()Ljava/lang/String;", null, null); + s.visitCode(); + s.visitLdcInsn("a token worth encrypting"); + s.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + s.visitMaxs(1, 0); + s.visitEnd(); + org.objectweb.asm.MethodVisitor m = w.visitMethod(pubStatic, "pick", + "(ZLapp/A;Lapp/B;)Ljava/lang/Object;", null, null); + m.visitCode(); + org.objectweb.asm.Label l1 = new org.objectweb.asm.Label(); + org.objectweb.asm.Label l2 = new org.objectweb.asm.Label(); + m.visitVarInsn(org.objectweb.asm.Opcodes.ILOAD, 0); + m.visitJumpInsn(org.objectweb.asm.Opcodes.IFEQ, l1); + m.visitVarInsn(org.objectweb.asm.Opcodes.ALOAD, 1); // app/A + m.visitJumpInsn(org.objectweb.asm.Opcodes.GOTO, l2); + m.visitLabel(l1); + m.visitVarInsn(org.objectweb.asm.Opcodes.ALOAD, 2); // app/B + m.visitLabel(l2); // frame here merges app/A and app/B + m.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + m.visitMaxs(1, 3); + m.visitEnd(); + w.visitEnd(); + byte[] input = w.toByteArray(); + + java.util.Map res = new java.util.HashMap(); + res.put("app/A.class", classExtendingInternal("app/A", "app/PlatformA")); + res.put("app/B.class", classExtendingInternal("app/B", "app/PlatformB")); + ClassLoader hierarchy = new ResourceOnlyLoader(res); // PlatformA, PlatformB, Base all absent + + StringEncryptTransform t = new StringEncryptTransform(true, 9, hierarchy); + byte[] out = t.transform(input); + assertTrue("class with an unresolvable merge is left unhardened", t.isHierarchyIncompleteSkipped()); + org.junit.Assert.assertArrayEquals("the unhardened class is returned byte-for-byte", input, out); + assertTrue("its literal stays plaintext", + StringEncryptTransform.containsStringLiteral(out, "a token worth encrypting")); + assertTrue("its literal is excluded jar-wide so no other class encrypts it", + t.getNewlyExcluded().contains("a token worth encrypting")); + } + + private static byte[] classExtendingInternal(String internal, String superName) { + org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0); + cw.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + internal, null, superName, null); + cw.visitEnd(); + return cw.toByteArray(); + } + + /** Serves {@code name.class -> bytes} as resources with no parent, so absent classes stay unresolvable. */ + private static final class ResourceOnlyLoader extends ClassLoader { + private final java.util.Map resources; + + ResourceOnlyLoader(java.util.Map resources) { + super(null); + this.resources = resources; + } + + @Override + public InputStream getResourceAsStream(String name) { + byte[] b = resources.get(name); + return b != null ? new java.io.ByteArrayInputStream(b) : super.getResourceAsStream(name); + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + byte[] b = resources.get(name.replace('.', '/') + ".class"); + if (b == null) { + throw new ClassNotFoundException(name); + } + return defineClass(name, b, 0, b.length); + } + } + + @Test + public void perAccessEncryptionIsCappedByConstantPoolBudget() throws Exception { + // The per-access channel is NOT pool-neutral when a value's plaintext is retained elsewhere: the + // ciphertext Utf8+String is a NET addition. Here every literal is ALSO kept plaintext in a class + // annotation (annotation values are never encrypted), so encrypting all of an interface's method + // copies would push the pool past 65535 and make ClassWriter.toByteArray() throw + // ClassTooLargeException. The transform must budget the ciphertext growth per distinct value and + // leave the overflow values plaintext (recorded for a jar-wide exclusion) instead. + int perMethod = 300; + int methods = 60; + int total = perMethod * methods; // 18000 distinct literals -- past what the 60000-item budget allows + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + // An interface, so every method literal goes through per access (never hoisted to ). + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_ABSTRACT | org.objectweb.asm.Opcodes.ACC_INTERFACE, + "app/ManyLiterals", null, "java/lang/Object", null); + // Retain every value as plaintext in a class-level annotation array; no encryption channel reaches + // annotation values, so each stays referenced and the method-copy ciphertext is a net pool add. + org.objectweb.asm.AnnotationVisitor av = w.visitAnnotation("Lapp/Keep;", false); + org.objectweb.asm.AnnotationVisitor arr = av.visitArray("value"); + for (int i = 0; i < total; i++) { + arr.visit(null, "interface per-access literal value number " + i); + } + arr.visitEnd(); + av.visitEnd(); + int n = 0; + for (int mth = 0; mth < methods; mth++) { + org.objectweb.asm.MethodVisitor mv = w.visitMethod( + org.objectweb.asm.Opcodes.ACC_PUBLIC, "m" + mth, "()V", null, null); + mv.visitCode(); + for (int k = 0; k < perMethod; k++, n++) { + mv.visitLdcInsn("interface per-access literal value number " + n); + mv.visitInsn(org.objectweb.asm.Opcodes.POP); + } + mv.visitInsn(org.objectweb.asm.Opcodes.RETURN); + mv.visitMaxs(1, 0); + mv.visitEnd(); + } + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 7); + byte[] out = t.transform(w.toByteArray()); + // The class assembles and verifies -- crucially, no ClassTooLargeException from a pool overflow. + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + assertTrue("some literals are encrypted within the pool budget", t.getEncryptedCount() > 0); + assertFalse("the pool-budget guard must leave the overflow values for a jar-wide exclusion", + t.getNewlyExcluded().isEmpty()); + } + + @Test + public void staticFinalConstantWithFullClinitStaysPlaintextAndReports() throws Exception { + // A static-final ConstantValue can only be moved into ; when is already full it + // cannot, so the field keeps its plaintext. That is safe (javac inlined every read as an LDC, + // which IS encrypted, so the field value is dead), and reported via getClinitFullLiteralCount. + org.objectweb.asm.ClassWriter w = classWithBigClinit("app/FullClinitStatic", 31500); // ~63 KB + w.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "SECRET", "Ljava/lang/String;", null, + "a static-final constant with a full clinit").visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 5); + byte[] out = t.transform(w.toByteArray()); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + assertEquals("the un-encryptable static-final is reported", 1, t.getClinitFullLiteralCount()); + assertTrue("its constant stays plaintext rather than the build aborting", + StringEncryptTransform.containsStringLiteral(out, "a static-final constant with a full clinit")); + } + + @Test + public void clinitTooFullFallsBackToPerAccessEncryption() throws Exception { + // The existing is so close to the limit that not even a helper CALL would fit, so the + // literal cannot be HOISTED. Rather than leaving it plaintext (which would break a cross-class + // == against an encrypted copy elsewhere), it is encrypted PER ACCESS -- no growth. + org.objectweb.asm.ClassWriter w = classWithBigClinit("app/FullClinit", 31500); // ~63 KB + addStringGetter(w, "probe", "a literal encrypted per access here"); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 5); + byte[] out = t.transform(w.toByteArray()); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + assertTrue("the literal is encrypted per access, not left plaintext", t.getEncryptedCount() >= 1); + assertFalse("its plaintext must be gone", + StringEncryptTransform.containsStringLiteral(out, "a literal encrypted per access here")); + Class c = new ByteLoader().define("app.FullClinit", out); + assertEquals("a literal encrypted per access here", c.getMethod("probe").invoke(null)); + } + + @Test + public void preJava8InterfaceConstantIsCountedAsExcluded() throws Exception { + // A Java 7 interface cannot host a /decoder, so its own static-final String constant + // stays plaintext. The transform must not silently ship it: it leaves it and counts it so the + // engine can warn. + String secret = "legacy interface constant secret value"; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_7, org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_INTERFACE | org.objectweb.asm.Opcodes.ACC_ABSTRACT, + "app/LegacyIface", null, "java/lang/Object", null); + w.visitField(org.objectweb.asm.Opcodes.ACC_PUBLIC | org.objectweb.asm.Opcodes.ACC_STATIC + | org.objectweb.asm.Opcodes.ACC_FINAL, "SECRET", "Ljava/lang/String;", null, secret) + .visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 55); + byte[] out = t.transform(w.toByteArray()); + assertEquals("nothing can be encrypted on a pre-Java-8 interface", 0, t.getEncryptedCount()); + assertEquals("its constant must be counted as an exclusion", 1, t.getLegacyInterfaceConstantCount()); + assertTrue("the constant is left as-is (reported, not silently dropped)", + StringEncryptTransform.containsStringLiteral(out, secret)); + // It must ALSO be excluded jar-wide, or an equal LDC elsewhere would be encrypted+interned and a + // GETSTATIC read of this still-plaintext interface field would compare != to it on ParparVM. + assertTrue("the un-encryptable interface constant is recorded for jar-wide exclusion", + t.getNewlyExcluded().contains(secret)); + } + + @Test + public void annotationStringsAreCountedAsExcluded() throws Exception { + // Strings in annotation element values/arrays live in the annotation metadata, not an LDC or a + // ConstantValue, so no channel encrypts them. They must be counted (and stay plaintext); an enum + // reference is not a string literal and must NOT be counted. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Annotated", null, "java/lang/Object", null); + org.objectweb.asm.AnnotationVisitor av = w.visitAnnotation("Lapp/Anno;", true); + av.visit("secret", "a class annotation secret value"); + org.objectweb.asm.AnnotationVisitor arr = av.visitArray("list"); + arr.visit(null, "an array annotation value"); + arr.visitEnd(); + av.visitEnum("kind", "Lapp/Kind;", "FOO"); // enum reference -- not a string literal + av.visitEnd(); + org.objectweb.asm.FieldVisitor fv = w.visitField(org.objectweb.asm.Opcodes.ACC_PRIVATE, + "f", "I", null, null); + org.objectweb.asm.AnnotationVisitor fav = fv.visitAnnotation("Lapp/Anno;", true); + fav.visit("fieldSecret", "a field annotation secret value"); + fav.visitEnd(); + fv.visitEnd(); + // A type-use annotation on a field type (visibleTypeAnnotations) also carries a string. + org.objectweb.asm.FieldVisitor tv = w.visitField(org.objectweb.asm.Opcodes.ACC_PRIVATE, + "typed", "Ljava/lang/String;", null, null); + org.objectweb.asm.AnnotationVisitor tav = tv.visitTypeAnnotation( + org.objectweb.asm.TypeReference.newTypeReference( + org.objectweb.asm.TypeReference.FIELD).getValue(), + null, "Lapp/TypeAnno;", true); + tav.visit("typeSecret", "a type-use annotation secret value"); + tav.visitEnd(); + tv.visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 9); + byte[] out = t.transform(w.toByteArray()); + assertEquals("the four distinct annotation strings are counted, the enum ref is not", + 4, t.getAnnotationLiteralCount()); + // The annotation string survives verbatim in the constant pool (no channel encrypts it). + assertTrue("annotation strings stay plaintext (no channel reaches them)", + containsRawBytes(out, "a class annotation secret value")); + } + + private static boolean containsRawBytes(byte[] haystack, String needle) throws Exception { + byte[] n = needle.getBytes("UTF-8"); + outer: + for (int i = 0; i + n.length <= haystack.length; i++) { + for (int j = 0; j < n.length; j++) { + if (haystack[i + j] != n[j]) { + continue outer; + } + } + return true; + } + return false; + } + + @Test + public void nearLimitMethodExcludesHoistedLiteralsInsteadOfOverflowing() throws Exception { + // Replacing an LDC (2 bytes for a small pool index) with GETSTATIC (3 bytes) grows a method, so + // hoisting a literal in a method already near the 65535-byte limit could overflow it. Those + // values are excluded jar-wide (their LDCs stay plaintext, method unchanged); a literal in a + // normal method is still encrypted. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/NearLimit", null, "java/lang/Object", null); + // A method already ~60 KB with three encryptable literals. + org.objectweb.asm.MethodVisitor big = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "big", "()V", null, null); + big.visitCode(); + for (int i = 0; i < 30100; i++) { + big.visitInsn(org.objectweb.asm.Opcodes.ICONST_0); + big.visitInsn(org.objectweb.asm.Opcodes.POP); + } + for (int i = 0; i < 3; i++) { + big.visitLdcInsn("a near-limit hoisted secret literal " + i); + big.visitInsn(org.objectweb.asm.Opcodes.POP); + } + big.visitInsn(org.objectweb.asm.Opcodes.RETURN); + big.visitMaxs(1, 0); + big.visitEnd(); + addStringGetter(w, "small", "a normal hoisted secret literal"); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 3); + byte[] out = t.transform(w.toByteArray()); + assertEquals("the near-limit method's three literals are excluded", 3, t.getNewlyExcluded().size()); + assertTrue("the normal method's literal is still encrypted", t.getEncryptedCount() >= 1); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + assertTrue("an excluded literal stays plaintext", + StringEncryptTransform.containsStringLiteral(out, "a near-limit hoisted secret literal 0")); + } + + @Test + public void jarExcludedValueIsLeftPlaintextEvenInAllMode() throws Exception { + // A value the engine marked as a jar-wide exclusion must be left plaintext here, so it is not + // encrypted in this class while a copy stays plaintext in the class that could not encrypt it. + String excluded = "a jar-wide excluded secret value"; + String encrypted = "an ordinary encryptable secret value"; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Excl", null, "java/lang/Object", null); + addStringGetter(w, "excluded", excluded); + addStringGetter(w, "kept", encrypted); + w.visitEnd(); + + java.util.Set jarExcluded = new java.util.HashSet(); + jarExcluded.add(excluded); + StringEncryptTransform t = new StringEncryptTransform(true, 3, null, null, jarExcluded); + byte[] out = t.transform(w.toByteArray()); + assertTrue("the excluded value stays plaintext", + StringEncryptTransform.containsStringLiteral(out, excluded)); + assertFalse("the ordinary value is still encrypted", + StringEncryptTransform.containsStringLiteral(out, encrypted)); + } + + @Test + public void poolTooFullForDecoderSkipsClassAndReportsJarWideExclusion() throws Exception { + // A class whose constant pool is already within the decoder's overhead of the 65535 limit cannot + // encrypt anything (the decoder itself would not fit). It is skipped, and its selected values are + // reported as jar-wide exclusions so they stay plaintext in every other class too. + int count = 30000; // ~60000 pool entries (Utf8 + String each) -> past the decoder-reserve gate + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/PoolFull", null, "java/lang/Object", null); + int perMethod = 250; + for (int start = 0; start < count; start += perMethod) { + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "m" + start, "()V", null, null); + m.visitCode(); + for (int i = start; i < start + perMethod && i < count; i++) { + m.visitLdcInsn("pool_full_secret_literal_number_" + i); + m.visitInsn(org.objectweb.asm.Opcodes.POP); + } + m.visitInsn(org.objectweb.asm.Opcodes.RETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + } + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 3); + byte[] out = t.transform(w.toByteArray()); + assertEquals("nothing can be encrypted when even the decoder would not fit", 0, t.getEncryptedCount()); + assertTrue("its values are reported for jar-wide exclusion", t.getNewlyExcluded().size() > 0); + assertTrue("the class is returned unchanged (plaintext)", + StringEncryptTransform.containsStringLiteral(out, "pool_full_secret_literal_number_0")); + } + + @Test + public void interfaceMethodTooLargeSkipsAndReportsLiterals() throws Exception { + // The interface path decodes per access (an INVOKESTATIC after each LDC). A method already near + // the limit cannot take those, so its literals are left plaintext and reported; a normal method + // in the same interface is still encrypted -- the build must not abort. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_INTERFACE | org.objectweb.asm.Opcodes.ACC_ABSTRACT, + "app/BigIface", null, "java/lang/Object", null); + // A static method already ~60 KB, then a few encryptable literals it cannot fit a decode call for. + org.objectweb.asm.MethodVisitor big = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "big", "()V", null, null); + big.visitCode(); + for (int i = 0; i < 30100; i++) { + big.visitInsn(org.objectweb.asm.Opcodes.ICONST_0); + big.visitInsn(org.objectweb.asm.Opcodes.POP); + } + for (int i = 0; i < 5; i++) { + big.visitLdcInsn("a big-interface secret literal number " + i); + big.visitInsn(org.objectweb.asm.Opcodes.POP); + } + big.visitInsn(org.objectweb.asm.Opcodes.RETURN); + big.visitMaxs(1, 0); + big.visitEnd(); + // A normal static method whose literal must still be encrypted. + org.objectweb.asm.MethodVisitor small = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "small", "()Ljava/lang/String;", null, null); + small.visitCode(); + small.visitLdcInsn("a small interface secret literal"); + small.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + small.visitMaxs(1, 0); + small.visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 3); + byte[] out = t.transform(w.toByteArray()); + assertEquals("the near-limit method's literals are reported", 5, t.getMethodFullLiteralCount()); + assertEquals("and recorded for jar-wide exclusion", 5, t.getNewlyExcluded().size()); + assertTrue("the normal method's literal is still encrypted", t.getEncryptedCount() >= 1); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + assertTrue("the un-encryptable literal stays plaintext", + StringEncryptTransform.containsStringLiteral(out, "a big-interface secret literal number 0")); + assertFalse("the small literal is encrypted away", + StringEncryptTransform.containsStringLiteral(out, "a small interface secret literal")); + } + + @Test + public void poolHeavyClassEncryptsPerAccessInsteadOfOverflowingOrLeavingPlaintext() throws Exception { + // A class with far more distinct literals than the constant pool can hold once each is hoisted + // (a field + its reference constants) would overflow the 65535-entry pool. Rather than + // ClassTooLargeException -- or leaving some plaintext (which breaks cross-class ==) -- the class + // falls back to per-access encryption: no per-value field, so no pool growth, and every + // occurrence is encrypted and interned. A probe reuses one value so a decode is checked. + int count = 12000; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/PoolHeavy", null, "java/lang/Object", null); + int perMethod = 200; + for (int start = 0; start < count; start += perMethod) { + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "m" + start, "()V", null, null); + m.visitCode(); + for (int i = start; i < start + perMethod && i < count; i++) { + m.visitLdcInsn("pool_heavy_secret_literal_number_" + i); + m.visitInsn(org.objectweb.asm.Opcodes.POP); + } + m.visitInsn(org.objectweb.asm.Opcodes.RETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + } + addStringGetter(w, "probe", "pool_heavy_secret_literal_number_0"); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 71); + byte[] out = t.transform(w.toByteArray()); + // Per-access encrypts every OCCURRENCE (count filler + the probe reuse), where hoisting would + // encrypt each distinct value once; count+1 therefore proves the per-access fallback ran. + assertEquals(count + 1, t.getEncryptedCount()); + assertFalse("no literal is left plaintext", + StringEncryptTransform.containsStringLiteral(out, "pool_heavy_secret_literal_number_5")); + // The class assembles and verifies -- no ClassTooLargeException -- and a value decodes. + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.PoolHeavy", out); + assertEquals("pool_heavy_secret_literal_number_0", c.getMethod("probe").invoke(null)); + } + + @Test + public void oversizedInitializerIsSplitAcrossHelpers() throws Exception { + // A generated class with enough distinct literals that a single would exceed the + // 65535-byte method limit. Hoisting must split the initializer across helper methods so the + // class still assembles, verifies and runs -- rather than throwing MethodTooLargeException. + // 6000 fields * ~11 bytes/init-unit ~= 66 KB, past the method limit but well under the + // constant-pool budget (so this exercises the method-size split, not the pool cap). + int count = 6000; + String probeValue = "big_clinit_secret_literal_number_0"; + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/BigClinit", null, "java/lang/Object", null); + // Spread the literals across small, individually-valid methods (each pops what it loads). + int perMethod = 200; + for (int start = 0; start < count; start += perMethod) { + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "m" + start, "()V", null, null); + m.visitCode(); + for (int i = start; i < start + perMethod && i < count; i++) { + m.visitLdcInsn("big_clinit_secret_literal_number_" + i); + m.visitInsn(org.objectweb.asm.Opcodes.POP); + } + m.visitInsn(org.objectweb.asm.Opcodes.RETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + } + // A probe that returns literal 0 so we can confirm a hoisted value decodes correctly. + addStringGetter(w, "probe", probeValue); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 4242); + byte[] out = t.transform(w.toByteArray()); + assertEquals("every distinct literal is hoisted once", count, t.getEncryptedCount()); + + // The output must verify (no MethodTooLargeException was thrown building it, and no method is + // too large or malformed). + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + + // The split actually happened: at least one synthetic initializer helper exists. + final boolean[] sawHelper = {false}; + new org.objectweb.asm.ClassReader(out).accept(new org.objectweb.asm.ClassVisitor( + org.objectweb.asm.Opcodes.ASM9) { + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, String desc, + String sig, String[] exceptions) { + if (name.startsWith("zqCI$")) { + sawHelper[0] = true; + } + return null; + } + }, org.objectweb.asm.ClassReader.SKIP_CODE); + assertTrue("the oversized initializer must be split into helper methods", sawHelper[0]); + + assertFalse(StringEncryptTransform.containsStringLiteral(out, probeValue)); + Class c = new ByteLoader().define("app.BigClinit", out); + assertEquals(probeValue, c.getMethod("probe").invoke(null)); + } + + private static void addStringGetter(org.objectweb.asm.ClassWriter w, String name, String value) { + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, name, "()Ljava/lang/String;", null, null); + m.visitCode(); + m.visitLdcInsn(value); + m.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + } + + @Test + public void encryptsEvenWhenDecoderNameCollides() throws Exception { + // A class that already declares a member named "zqdec$" must NOT be skipped: skipping would + // leave its literal in plaintext while an equal literal elsewhere was encrypted, breaking a + // valid literal == on ParparVM. The transform picks a non-colliding decoder name instead. + org.objectweb.asm.ClassWriter w = new org.objectweb.asm.ClassWriter(0); + w.visit(org.objectweb.asm.Opcodes.V1_8, org.objectweb.asm.Opcodes.ACC_PUBLIC, + "app/Clash", null, "java/lang/Object", null); + // A pre-existing member named exactly like the decoder. + w.visitField(org.objectweb.asm.Opcodes.ACC_PRIVATE | org.objectweb.asm.Opcodes.ACC_STATIC, + "zqdec$", "I", null, null).visitEnd(); + org.objectweb.asm.MethodVisitor m = w.visitMethod(org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC, "secret", "()Ljava/lang/String;", null, null); + m.visitCode(); + m.visitLdcInsn("this is a clash secret value"); + m.visitInsn(org.objectweb.asm.Opcodes.ARETURN); + m.visitMaxs(1, 0); + m.visitEnd(); + w.visitEnd(); + + StringEncryptTransform t = new StringEncryptTransform(true, 11); + byte[] out = t.transform(w.toByteArray()); + assertTrue("the clashing class must still be encrypted, not skipped", t.getEncryptedCount() >= 1); + assertFalse("plaintext must be gone despite the name clash", + StringEncryptTransform.containsStringLiteral(out, "this is a clash secret value")); + CheckClassAdapter.verify(new org.objectweb.asm.ClassReader(out), false, + new java.io.PrintWriter(new java.io.StringWriter())); + Class c = new ByteLoader().define("app.Clash", out); + assertEquals("this is a clash secret value", c.getMethod("secret").invoke(null)); + } + + @Test + public void oversizedLiteralIsLeftPlaintextNotCrashing() throws Exception { + // A large-but-valid ASCII literal (40000 chars = 40000 UTF-8 bytes, under the 65535 limit) + // 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..81eaf0de076 --- /dev/null +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/MappingFile.java @@ -0,0 +1,422 @@ +/* + * 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 String declaringClass; // FQ class an inlined method came from, or null for this class + final int startLine; // obfuscated range start (0 if none) + final int endLine; // obfuscated range end + final int originalStartLine; // original range start (0 if none / same) + final int originalEndLine; // original range end (== start for a single line) + + MethodMapping(String originalName, String declaringClass, int startLine, int endLine, + int originalStartLine, int originalEndLine) { + this.originalName = originalName; + this.declaringClass = declaringClass; + this.startLine = startLine; + this.endLine = endLine; + this.originalStartLine = originalStartLine; + 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>(); + // R8's recorded source file (e.g. Screen.kt) from a "# {"id":"sourceFile",...}" comment; null + // when the mapping carries no such metadata. Lets a hardened build -- which strips SourceFile + // from the binary, so the device reports no filename -- still name the real source file. + String sourceFile; + + ClassMapping(String originalName) { + this.originalName = originalName; + } + } + + // obfuscated class binary name -> mapping + private final Map byObfuscated = new HashMap(); + // Same ClassMapping objects keyed by their ORIGINAL (deobfuscated) FQCN, so an inlined method's + // declaring class -- named by its original name on the member line -- can recover its sourceFile. + private final Map byOriginal = new HashMap(); + + public static MappingFile parse(String text) throws IOException { + return parse(new StringReader(text)); + } + + 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) { + String trimmed = line.trim(); + // R8 records per-class metadata as an INDENTED comment, e.g. + // # {"id":"sourceFile","fileName":"Screen.kt"}. It is not a member line (no " -> "), + // so capture the source file here rather than dropping it in parseMemberLine. + if (trimmed.startsWith("#")) { + String sf = parseSourceFileMetadata(trimmed); + if (sf != null) { + current.sourceFile = sf; + } + } else { + mf.parseMemberLine(current, trimmed); + } + } + } + return mf; + } + + /** + * Extracts the {@code fileName} from an R8 {@code sourceFile} metadata comment such as + * {@code # {"id":"sourceFile","fileName":"Screen.kt"}}, or {@code null} when the comment is not + * one. Deliberately a small hand scan rather than a JSON dependency (this module is zero-dep), but it + * DOES honor JSON string escapes: the engine writes the value with backslash-escaped {@code "} and + * {@code \}, so a filename containing either (a Unix path can) must be decoded back rather than + * truncated at the first escaped quote. + */ + private static String parseSourceFileMetadata(String comment) { + if (comment.indexOf("\"id\":\"sourceFile\"") < 0) { + return null; + } + String key = "\"fileName\":\""; + int at = comment.indexOf(key); + if (at < 0) { + return null; + } + int i = at + key.length(); + int n = comment.length(); + StringBuilder name = new StringBuilder(); + while (i < n) { + char c = comment.charAt(i); + if (c == '\\' && i + 1 < n) { + // A JSON escape. Decode the ones the writer emits -- the quote/backslash/slash escapes, the + // control-character escapes (b f n r t) and the 4-hex-digit backslash-u form -- back to + // their literal characters, so a filename that contained a control character round-trips + // instead of being read as the escape letters. + char e = comment.charAt(i + 1); + if (e == 'u' && i + 6 <= n) { + try { + name.append((char) Integer.parseInt(comment.substring(i + 2, i + 6), 16)); + i += 6; + continue; + } catch (NumberFormatException malformed) { + // Not a valid 4-hex-digit escape; fall through and treat 'u' as a literal character. + } + } + switch (e) { + case 'b': name.append('\b'); break; + case 'f': name.append('\f'); break; + case 'n': name.append('\n'); break; + case 'r': name.append('\r'); break; + case 't': name.append('\t'); break; + default: name.append(e); break; // \" \\ \/ and any other -> the literal next char + } + i += 2; + } else if (c == '"') { + // Return the decoded value verbatim: the closing quote bounds the JSON string exactly, so + // any leading/trailing whitespace is part of the filename (a Unix name may have it) and must + // be preserved, not trimmed. Only a genuinely empty value counts as "no source file". + return name.length() == 0 ? null : name.toString(); + } else { + name.append(c); + i++; + } + } + return null; // unterminated JSON string + } + + 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); + // Also index by original FQCN so an inlined method's declaring class (recorded by its ORIGINAL + // name on the member line) can recover its own sourceFile metadata for the inline frame. + byOriginal.put(original, cm); + return cm; + } + + 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 qualifiedMethod = sp < 0 ? beforeParen : beforeParen.substring(sp + 1); + // An R8 inline record can name a method from ANOTHER class, fully qualified + // ("com.example.Callee.run"). Split the declaring class off so the retraced frame reports + // Callee.run / Callee.java rather than gluing the callee's FQ name onto the enclosing class. + String declaringClass = null; + String originalMethod = qualifiedMethod; + int lastDot = qualifiedMethod.lastIndexOf('.'); + if (lastDot > 0) { + declaringClass = qualifiedMethod.substring(0, lastDot); + originalMethod = qualifiedMethod.substring(lastDot + 1); + } + List list = cm.methods.get(obfName); + if (list == null) { + list = new ArrayList(); + cm.methods.put(obfName, list); + } + list.add(new MethodMapping(originalMethod, declaringClass, 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; + // For the enclosing class, keep the filename the frame actually reported when it is a real + // source name that renaming can't reconstruct -- a Kotlin frame carries Screen.kt, and a + // package-private class carries the file it was declared in (Main.java). But when the reported + // name is just the obfuscated class name with an extension (SourceFile renamed to match the + // obfuscated class, or a ParparVM synthesized .java), it carries no information, so + // synthesize .java from the retraced class instead. + String file = preferredSourceFile(obfuscated.getFileName(), obfuscated.getClassName(), + originalClass, cm.sourceFile); + // ParparVM records a constructor / static initializer under the runtime sentinel names + // __INIT__ / __CLINIT__ (BytecodeMethod), but a ProGuard mapping keys them as /. + // Normalize before the lookup, or the frame misses its method record and keeps the sentinel + // name with no line mapping. + String methodName = normalizeInitializer(obfuscated.getMethodName()); + List candidates = cm.methods.get(methodName); + List out = new ArrayList(); + if (candidates != null && !candidates.isEmpty()) { + for (MethodMapping m : candidates) { + if (m.startLine != 0 && observed >= m.startLine && observed <= m.endLine) { + out.add(frameFor(m, originalClass, file, observed)); + } + } + if (out.isEmpty()) { + // No line to disambiguate (Unknown Source, or the mapping records omit obfuscated + // ranges). ProGuard/R8 can reuse one obfuscated name for several overloads, so picking + // the first would name the wrong original method. Emit every candidate to preserve the + // ambiguity rather than fabricate a single answer. + for (MethodMapping m : candidates) { + out.add(frameFor(m, originalClass, file, observed)); + } + } + } else { + out.add(new Frame(originalClass, methodName, file, observed)); + } + return out; + } + + /** + * Maps ParparVM's runtime initializer sentinels to the JVM names a ProGuard mapping uses, so a + * crash inside a constructor or static initializer resolves its method record. Any other name is + * returned unchanged. + */ + private static String normalizeInitializer(String methodName) { + if ("__INIT__".equals(methodName)) { + return ""; + } + if ("__CLINIT__".equals(methodName)) { + return ""; + } + return methodName; + } + + /** Builds a frame for one method record, honoring an inlinee's own declaring class/source file. */ + private Frame frameFor(MethodMapping m, String enclosingClass, String enclosingFile, int observed) { + String cls = m.declaringClass != null ? m.declaringClass : enclosingClass; + String file; + if (m.declaringClass != null) { + // An inlined method comes from another class (Helper.kt, a package-private class in a + // differently named file). Prefer that class's own recorded sourceFile metadata when the + // mapping has it, so the inline frame points at Helper.kt rather than a fabricated + // Helper.java; fall back to the synthesized .java only when it has none. + ClassMapping dc = byOriginal.get(m.declaringClass); + file = synthesizedSourceFile(m.declaringClass, dc != null ? dc.sourceFile : null); + } else { + file = enclosingFile; + } + return new Frame(cls, m.originalName, file, m.mapLine(observed)); + } + + /** + * The source file to report for the enclosing class. Keeps a real reported name (Screen.kt, + * Main.java); otherwise -- when the reported name is empty or is just the obfuscated class name with + * an extension (a renamed/synthesized placeholder) -- prefers R8's recorded {@code sourceFile} + * metadata when the mapping has it (so a hardened build that stripped SourceFile still names + * Screen.kt), and only falls back to synthesizing {@code .java} when it does not. + */ + private static String preferredSourceFile(String reported, String obfClassName, String originalClass, + String mappedSourceFile) { + if (reported == null || reported.length() == 0) { + return synthesizedSourceFile(originalClass, mappedSourceFile); + } + int dot = reported.lastIndexOf('.'); + String reportedBase = dot > 0 ? reported.substring(0, dot) : reported; + String obfSimple = obfClassName; + int sep = Math.max(obfSimple.lastIndexOf('.'), obfSimple.lastIndexOf('/')); + if (sep >= 0) { + obfSimple = obfSimple.substring(sep + 1); + } + int dollar = obfSimple.indexOf('$'); + if (dollar > 0) { + obfSimple = obfSimple.substring(0, dollar); + } + if (reportedBase.equals(obfSimple)) { + return synthesizedSourceFile(originalClass, mappedSourceFile); + } + return reported; + } + + /** R8's recorded source file when the mapping has it, else a synthesized {@code .java}. */ + private static String synthesizedSourceFile(String originalClass, String mappedSourceFile) { + if (mappedSourceFile != null && mappedSourceFile.length() > 0) { + return mappedSourceFile; + } + return simpleSourceFile(originalClass); + } + + private static String simpleSourceFile(String fqcn) { + int d = fqcn.lastIndexOf('.'); + String simple = d < 0 ? fqcn : fqcn.substring(d + 1); + 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..8e13c751a6f --- /dev/null +++ b/maven/cn1-retrace/src/main/java/com/codename1/retrace/RetraceMain.java @@ -0,0 +1,96 @@ +/* + * 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.FileInputStream; +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])) { + // Read the mapping as UTF-8 explicitly, not the platform default charset (which would + // corrupt Unicode class/method/source-file names on a non-UTF-8 host such as Windows Java + // 8). The rest of the pipeline -- MappingWriter.injectSourceFiles -- also reads/writes UTF-8. + InputStreamReader fr = new InputStreamReader( + new FileInputStream(new File(args[i + 1])), Charset.forName("UTF-8")); + try { + files.add(MappingFile.parse(fr)); + } finally { + 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..6101ab6610a --- /dev/null +++ b/maven/cn1-retrace/src/test/java/com/codename1/retrace/MappingFileTest.java @@ -0,0 +1,302 @@ +/* + * 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.assertTrue; + +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()); + } + + private static final String INIT_MAPPING = + "com.example.MyForm -> zqaaaa:\n" + + " 50:55:void () -> \n" + + " 70:72:void () -> \n"; + + @Test + public void usesR8SourceFileMetadataWhenSourceFileStripped() throws Exception { + // A hardened Android build strips SourceFile, so the device reports no real filename; R8's + // sourceFile metadata comment carries the true name (Screen.kt) and must be used instead of a + // synthesized Screen.java. + String mapping = + "com.example.Screen -> a.b:\n" + + " # {\"id\":\"sourceFile\",\"fileName\":\"Screen.kt\"}\n" + + " 142:145:void onClick() -> a\n"; + MappingFile mf = MappingFile.parse(mapping); + // Reported file is the obfuscated class placeholder (b.java) -- a stripped-SourceFile symptom. + Frame placeholder = mf.retrace(new Frame("a.b", "a", "b.java", 143)); + assertEquals("com.example.Screen", placeholder.getClassName()); + assertEquals("onClick", placeholder.getMethodName()); + assertEquals("Screen.kt", placeholder.getFileName()); + // Empty reported file (the other stripped-SourceFile symptom) resolves the same way. + Frame empty = mf.retrace(new Frame("a.b", "a", "", 143)); + assertEquals("Screen.kt", empty.getFileName()); + } + + @Test + public void decodesJsonEscapesInSourceFileMetadata() throws Exception { + // The engine writes the sourceFile value JSON-escaped, so a filename containing a quote or a + // backslash (a Unix path can) arrives as \" / \\. The parser must decode those back rather than + // stopping at the first escaped quote, or the retraced filename is truncated/still-escaped. + // File value is: weird\"name\\.kt (an escaped quote and an escaped backslash). + String mapping = + "com.example.Screen -> a.b:\n" + + " # {\"id\":\"sourceFile\",\"fileName\":\"weird\\\"name\\\\.kt\"}\n" + + " 142:145:void onClick() -> a\n"; + MappingFile mf = MappingFile.parse(mapping); + Frame out = mf.retrace(new Frame("a.b", "a", "b.java", 143)); + assertEquals("weird\"name\\.kt", out.getFileName()); + } + + @Test + public void preservesLeadingAndTrailingWhitespaceInSourceFileName() throws Exception { + // A Unix source filename may legitimately have surrounding whitespace; the closing quote bounds the + // JSON value exactly, so the parser must keep it verbatim rather than trim it to a different name. + String mapping = + "com.example.Screen -> a.b:\n" + + " # {\"id\":\"sourceFile\",\"fileName\":\" Screen .kt \"}\n" + + " 142:145:void onClick() -> a\n"; + MappingFile mf = MappingFile.parse(mapping); + Frame out = mf.retrace(new Frame("a.b", "a", "b.java", 143)); + assertEquals(" Screen .kt ", out.getFileName()); + } + + @Test + public void decodesControlCharacterEscapesInSourceFileMetadata() throws Exception { + // A filename with a control character (a tab here, plus a unicode-escaped 'A') is written with the + // control chars escaped so the single-line comment is not split. The parser must decode \t and + // the 4-hex-digit backslash-u form back to their literal characters, not the letters t / u. + // File value is: od\td\u0041.kt -> oddA.kt + String mapping = + "com.example.Screen -> a.b:\n" + + " # {\"id\":\"sourceFile\",\"fileName\":\"od\\td\\u0041.kt\"}\n" + + " 142:145:void onClick() -> a\n"; + MappingFile mf = MappingFile.parse(mapping); + Frame out = mf.retrace(new Frame("a.b", "a", "b.java", 143)); + assertEquals("od\td" + "A.kt", out.getFileName()); + } + + @Test + public void synthesizesSourceFileWhenMappingHasNoMetadata() throws Exception { + // Without sourceFile metadata, a stripped-SourceFile frame still synthesizes .java. + String mapping = + "com.example.Screen -> a.b:\n" + + " 142:145:void onClick() -> a\n"; + Frame out = MappingFile.parse(mapping).retrace(new Frame("a.b", "a", "b.java", 143)); + assertEquals("Screen.java", out.getFileName()); + } + + @Test + public void normalizesParparVmConstructorSentinel() throws Exception { + // ParparVM records a constructor frame under the runtime sentinel __INIT__; the mapping keys it + // as . Without normalization the lookup misses and the frame keeps __INIT__. + MappingFile mf = MappingFile.parse(INIT_MAPPING); + Frame out = mf.retrace(new Frame("zqaaaa", "__INIT__", "zqaaaa.java", 52)); + assertEquals("com.example.MyForm", out.getClassName()); + assertEquals("", out.getMethodName()); + } + + @Test + public void normalizesParparVmStaticInitializerSentinel() throws Exception { + MappingFile mf = MappingFile.parse(INIT_MAPPING); + Frame out = mf.retrace(new Frame("zqaaaa", "__CLINIT__", "zqaaaa.java", 71)); + assertEquals("com.example.MyForm", out.getClassName()); + assertEquals("", out.getMethodName()); + } + + @Test + public void retracesMethodByLineRange() throws Exception { + MappingFile mf = MappingFile.parse(MAPPING); + 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 inlinedMethodFromAnotherClassKeepsItsOwnClass() throws Exception { + // The inlinee 'a' at obf line 1 is Callee.run from a DIFFERENT class; the retraced frame must + // report Callee/Callee.java, not the enclosing Outer with Callee.run glued on as the method. + MappingFile mf = MappingFile.parse( + "com.example.Outer -> x:\n" + + " 1:1:void com.example.Callee.run():12:12 -> a\n" + + " 1:1:void outerMethod():30:30 -> a\n"); + java.util.List frames = mf.retraceAll(new Frame("x", "a", "x.java", 1)); + assertEquals(2, frames.size()); + assertEquals("com.example.Callee", frames.get(0).getClassName()); + assertEquals("run", frames.get(0).getMethodName()); + assertEquals("Callee.java", frames.get(0).getFileName()); + assertEquals("com.example.Outer", frames.get(1).getClassName()); + assertEquals("outerMethod", frames.get(1).getMethodName()); + } + + @Test + public void inlinedMethodFromKotlinClassUsesItsOwnSourceFile() throws Exception { + // The inlinee is Helper.doIt from a Kotlin class whose declaration records sourceFile Helper.kt. + // The inline frame must report Helper.kt, not a fabricated Helper.java. + MappingFile mf = MappingFile.parse( + "com.example.Outer -> x:\n" + + " 1:1:void outerMethod():30:30 -> a\n" + + "com.example.Helper -> y:\n" + + " # {\"id\":\"sourceFile\",\"fileName\":\"Helper.kt\"}\n" + + " void doIt() -> b\n" + // Back in Outer: an inlined call to Helper.doIt at obf line 1 of method a. + + "com.example.Outer -> x:\n" + + " 1:1:void com.example.Helper.doIt():12:12 -> a\n" + + " 1:1:void outerMethod():30:30 -> a\n"); + java.util.List frames = mf.retraceAll(new Frame("x", "a", "x.java", 1)); + assertEquals(2, frames.size()); + assertEquals("com.example.Helper", frames.get(0).getClassName()); + assertEquals("doIt", frames.get(0).getMethodName()); + assertEquals("Helper.kt", frames.get(0).getFileName()); + assertEquals("com.example.Outer", frames.get(1).getClassName()); + } + + @Test + public void inlinedMethodWithoutMetadataStillSynthesizesJava() throws Exception { + // A declaring class with no recorded sourceFile metadata falls back to the synthesized name. + MappingFile mf = MappingFile.parse( + "com.example.Outer -> x:\n" + + " 1:1:void com.example.Callee.run():12:12 -> a\n" + + " 1:1:void outerMethod():30:30 -> a\n"); + assertEquals("Callee.java", + mf.retraceAll(new Frame("x", "a", "x.java", 1)).get(0).getFileName()); + } + + @Test + public void keepsRealReportedSourceFileButNotObfuscatedPlaceholder() throws Exception { + MappingFile mf = MappingFile.parse( + "com.example.Screen -> a:\n" + + " void onClick() -> b\n"); + // A real Kotlin source name the class name can't reconstruct is kept. + assertEquals("Screen.kt", + mf.retrace(new Frame("a", "b", "Screen.kt", 5)).getFileName()); + // A placeholder equal to the obfuscated class name is replaced by the retraced class's file. + assertEquals("Screen.java", + mf.retrace(new Frame("a", "b", "a.java", 5)).getFileName()); + // No reported name -> synthesized from the retraced class. + assertEquals("Screen.java", + mf.retrace(new Frame("a", "b", "", 5)).getFileName()); + } + + @Test + public void ambiguousFrameWithNoLineEmitsAllCandidates() throws Exception { + // Two unrelated methods share one obfuscated name with no obfuscated line ranges. A frame + // with no usable line can't disambiguate, so retraceAll must emit BOTH originals rather than + // fabricating the first. + MappingFile mf = MappingFile.parse( + "com.example.C -> x:\n" + + " void alpha() -> a\n" + + " void beta() -> a\n"); + java.util.List frames = mf.retraceAll(new Frame("x", "a", "", 0)); + assertEquals(2, frames.size()); + java.util.Set names = new java.util.HashSet(); + for (Frame f : frames) { + names.add(f.getMethodName()); + } + assertTrue(names.contains("alpha")); + assertTrue(names.contains("beta")); + } + + @Test + public void unknownClassPassesThroughUnchanged() throws Exception { + MappingFile mf = MappingFile.parse(MAPPING); + 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 21bd44838c3..69816f7369d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -340,6 +340,62 @@ static boolean usesFcmPush(int detectedPushVersion, String messagingService, && hasFirebaseConfiguration); } + /** + * True when a rename-delivering hardening profile is requested but Android R8 -- the only renamer + * on Android -- is not enabled to deliver it. R8 minification is emitted only when + * {@code android.enableProguard} is exactly {@code "true"} (the {@code minifyEnabled} gate), so any + * other value ({@code off}, {@code 0}, {@code False}, {@code no}, ...) leaves the rename unfulfilled; + * the check must mirror that predicate, not just reject the literal {@code "false"}, or a rename + * profile would be stamped {@code rename:r8}/hardened and ship without renaming. + */ + static boolean r8RenameRequiredButDisabled(boolean renameRequested, String enableProguardArg) { + return renameRequested && !"true".equals(enableProguardArg); + } + + /** + * True when a rename-delivering hardening profile is active for THIS build, so the R8 renaming the + * profile promises must be enforced. Gated first on the VERIFIED {@code cn1.hardened} output (set only + * after a successful, entitled engine run, exactly as {@link #hardeningR8Keep(BuildRequest)}): a build + * that took the {@code harden.allowUnhardenedLocalBuild} escape hatch has {@code cn1.harden.forceOff} + * set, so {@code hardenSourceJar} returned the original jar stamped {@code cn1.hardened=false} and + * nothing was renamed to enforce -- enforcing R8 there would defeat the documented escape hatch. The + * tri-state opt-outs ({@code androidHardeningEnabled}, {@code renameRequested}) are resolved by the + * caller with the engine's {@code boolTri} rules. + */ + static boolean androidRenameHardeningActive(BuildRequest request, boolean androidHardeningEnabled, + boolean renameRequested) { + if (!"true".equals(request.getArg("cn1.hardened", "false"))) { + return false; + } + String hardenLevel = request.getArg("harden.level", "off"); + return androidHardeningEnabled + && hardenLevel != null && !"off".equalsIgnoreCase(hardenLevel.trim()) + && hardenLevel.trim().length() > 0 + && renameRequested; + } + + /** + * True when this build will produce a signed release variant -- the only variant whose Gradle + * buildType carries {@code minifyEnabled}, and therefore the only one R8 actually renames. A + * debug-only build ({@code android.release=false} with a debug variant) or a build with no signing + * certificate runs only {@code assembleDebug}, so R8 never renames even with + * {@code android.enableProguard=true}. Mirrors the release/debug task selection. + */ + static boolean androidReleaseVariantBuilt(BuildRequest request) { + if (request.getCertificate() == null) { + return false; + } + boolean release = "true".equals(request.getArg("android.release", "true")); + boolean debug = release + ? "true".equals(request.getArg("android.debug", "false")) + : "true".equals(request.getArg("android.debug", "true")); + if (!release && !debug) { + // Neither explicitly selected: the builder falls back to building both, including release. + return true; + } + return release; + } + static boolean usesHuaweiPush(int detectedPushVersion, String messagingService, boolean hasHuaweiConfiguration) { return detectedPushVersion == 3 @@ -753,6 +809,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) { + // Gate on the VERIFIED cn1.hardened output (set only after a successful, entitled engine run), + // not harden.level: an Android opt-out (harden.and.enabled=off/0/false, or a level whose + // transforms are all disabled) makes the engine decline, and these keep rules must not then + // change which names R8 obfuscates. + if (!"true".equals(request.getArg("cn1.hardened", "false"))) { + return ""; + } + // Prefer the full keep set the engine derived from the input jar: the native-interface peers + // it found, plus the user's harden.keep. Those the automatic R8 analysis can't see, so without + // them R8 would rename a name-resolved class and the hardened release wouldn't resolve it. + String engineKeep = getLastHardeningR8Keep(); + if (engineKeep != null && engineKeep.trim().length() > 0) { + return engineKeep.endsWith("\n") ? engineKeep : engineKeep + "\n"; + } + // Fallback when the engine emitted no keep file (e.g. build() invoked without runBuild): the + // user's harden.keep. PropertyBusinessObject needs no keep -- its JSON/DB keys come from the + // string passed to its Property, not the field name, so renaming the field is safe. + StringBuilder sb = new StringBuilder(); + 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 +892,37 @@ 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 = androidRenameHardeningActive(request, androidHardeningEnabled, + hardenBoolArg(request, "harden.rename", true)); + // R8 actually renames only for a signed RELEASE variant built with minification: minifyEnabled + // lives in the release buildType and is emitted only when android.enableProguard is exactly + // "true", and a debug-only build (or one with no signing certificate) runs only assembleDebug. + // So a rename hardening profile must be rejected unless R8 will really run, not just when + // enableProguard is the literal "false" -- otherwise the APK ships stamped rename:r8/hardened + // without ever being renamed. + String enableProguard = request.getArg("android.enableProguard", "true"); + if (r8RenameRequiredButDisabled(hardenRenames, enableProguard) + || (hardenRenames && !androidReleaseVariantBuilt(request))) { + String reason = !"true".equals(enableProguard) + ? "android.enableProguard=" + enableProguard + " disables R8 (it renames only when " + + "android.enableProguard=true)" + : "this build produces no signed release variant (android.release=" + + request.getArg("android.release", "true") + ", android.debug=" + + request.getArg("android.debug", "false") + ", certificate " + + (request.getCertificate() == null ? "absent" : "present") + + "), and R8 minification applies only to the release build"; + throw new BuildException("harden.level=" + hardenLevel + " requires Android's R8/ProGuard " + + "renaming, but " + reason + ". Build a signed release variant with R8 enabled, set " + + "harden.rename=false, or set harden.level=off."); + } if (useGradle8) { getGradleJavaHome(); // will throw build exception if JAVA17_HOME is not set minimumGradleVersion = GRADLE_8_VERSION; @@ -4730,7 +4866,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 +4930,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 +5248,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" @@ -5426,6 +5568,16 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } String keepOverride = request.getArg("android.proguardKeepOverride", "Exceptions, InnerClasses, Signature, Deprecated, SourceFile, LineNumberTable, *Annotation*, EnclosingMethod"); + // On a build the engine actually hardened, strip SourceFile from R8's kept attributes for + // DexGuard parity (the retrace reconstructs the file name from the retraced class; + // LineNumberTable is kept so lines still retrace). Gate on the VERIFIED cn1.hardened output + // (set only after a successful, entitled engine run), so an opt-out build (harden.and.enabled + // =off/0, or a level whose transforms are all disabled -> engine declines) keeps its + // metadata. Only when the developer didn't supply their own attribute list. + if ("true".equals(request.getArg("cn1.hardened", "false")) + && request.getArg("android.proguardKeepOverride", null) == null) { + keepOverride = keepOverride.replace("SourceFile, ", "").replace(", SourceFile", "").replace("SourceFile,", ""); + } String keepFirebase = "-keep class com.google.android.gms.** { *; }\n\n" + "-keep class com.google.firebase.** { *; }\n\n"; @@ -5507,6 +5659,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..6526e0d3b3f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -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,548 @@ 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"; + } + + /** + * Extra fully-qualified class names to keep from renaming, beyond the main class, for a slice whose + * runtime resolves a class by its ORIGINAL name in generated native code (which the input-jar + * scanner cannot discover). Empty by default; the iOS builder adds the watch lifecycle entry. + */ + protected java.util.List extraKeepClasses(BuildRequest request) { + return java.util.Collections.emptyList(); + } + + /** + * Java source that stamps the hardening runtime properties ({@code cn1.mappingId}, + * {@code cn1.hardened}, {@code cn1.hardenLevel}) into {@code Display}, so every port's stub can + * 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. + */ + /** + * True for the ParparVM-to-C targets (iOS/mac/watch/tv/win/linux), whose app links against the + * {@code parparvm-java-api.jar} runtime. A compile-time literal there is a constant-pool object that + * ParparVM never interns, so an encrypted app copy of the same value would not be reference-equal to + * it; the runtime's literals are therefore excluded from encryption on these targets. + */ + protected boolean isParparVMCPlatform(String platform) { + return "ios".equals(platform) || "mac".equals(platform) || "watch".equals(platform) + || "tv".equals(platform) || "win".equals(platform) || "linux".equals(platform); + } + + /** + * Whether this builder's artifact links against the un-interned ParparVM Java runtime, so that + * runtime's literals must be excluded from encryption to preserve reference equality. Defaults to the + * ParparVM-C platform test, but the platform tag alone is insufficient for the JavaScript port: the + * ParparVM-to-JS builder reports {@code javascript} yet unpacks {@code parparvm-java-api.jar} into the + * translated app exactly like the native ParparVM-C targets, so {@code JavaScriptBuilder} overrides + * this to true. (A TeaVM-based JS build would not link that runtime, but the plugin's local JS builder + * is ParparVM-only.) + */ + protected boolean stagesParparVMRuntime(BuildRequest request) { + return isParparVMCPlatform(hardeningPlatform(request)); + } + + /** + * The platform tags this one build ships from the SAME hardened jar. The default builder emits a + * single slice ({@link #hardeningPlatform(BuildRequest)}); the Apple builder widens it to the iOS app + * plus any native-Mac, watch or tv slice. A shared jar cannot be hardened per-slice, so its + * {@code harden..enabled} opt-outs are combined: hardening runs unless EVERY shipped slice is + * opted out (see {@link #writeHardeningConfig}). Kept consistent with the daemon builder. + */ + protected java.util.List effectiveHardeningPlatforms(BuildRequest request) { + return java.util.Collections.singletonList(hardeningPlatform(request)); + } + + /** + * True when a {@code harden.*} boolean reads as disabled, matching the engine's tri-state parsing: + * {@code false}, {@code 0} and {@code off} all mean off, so a per-slice opt-out is recognized + * consistently rather than only as the literal {@code false}. + */ + protected static boolean hardenDisabled(String value) { + if (value == null) { + return false; + } + String t = value.trim().toLowerCase(); + return "false".equals(t) || "0".equals(t) || "off".equals(t); + } + + /** + * True when at least one of the shipped {@code slices} still wants hardening, i.e. NOT every slice + * opted out via {@code harden..enabled}. A shared jar is hardened as a whole, so the combined + * decision is this OR: hardening runs unless every slice is opted out. + */ + static boolean anySliceHardeningEnabled(java.util.List slices, BuildRequest request) { + for (String slice : slices) { + if (!hardenDisabled(request.getArg("harden." + slice + ".enabled", "true"))) { + return true; + } + } + return false; + } + + protected java.util.List hardeningLibraryJars(BuildRequest request) { + 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); + } + // On a ParparVM-C target the app also links against the ParparVM Java runtime + // (parparvm-java-api.jar -- java.lang.Boolean, java.lang.String, ...), which the builder stages + // later and never hardens. Its literals must reach the engine's library-literal exclusion scan, + // or an app value like "true" (encrypted then interned) would compare != to a runtime-returned + // copy such as Boolean.toString() -- a constant-pool literal ParparVM never interns -- breaking a + // reference comparison that held before hardening. It doubles as a -libraryjars entry for ProGuard. + if (stagesParparVMRuntime(request)) { + try { + File runtime = getResourceAsFile("/parparvm-java-api.jar", ".jar"); + if (runtime != null && runtime.exists() && !jars.contains(runtime)) { + jars.add(runtime); + } + } catch (IOException ex) { + // Best-effort: without the runtime jar the scan simply misses its literals (a rare == edge). + } + } + String raw = request.getArg("cn1.hardening.libraryJars", ""); + if (raw == null || raw.length() == 0) { + // Fallback: the maven plugin publishes the compile classpath here (a single injection + // 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 boolean hardeningRanThisBuild; + 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 { + // Idempotence FIRST, via a non-forgeable per-instance flag (not an input-jar marker: a + // spurious META-INF/CN1-HARDENED resource must not skip the transform). On a second call in + // this build -- a nested/delegated invocation -- the verified cn1.hardened / cn1.hardenLevel / + // cn1.mappingId from the first run are already in the request, so return WITHOUT touching them. + if (hardeningRanThisBuild) { + log("cn1-hardening: already hardened in this build; skipping"); + return sourceZip; + } + // cn1.hardened / cn1.hardenLevel / cn1.mappingId are engine OUTPUTS, never inputs. Clear any + // supplied values so the stubs never stamp a hardened state the engine didn't actually + // produce; they are set again below only from a verified hardening run. + request.putArgument("cn1.hardened", "false"); + request.putArgument("cn1.hardenLevel", "off"); + request.putArgument("cn1.mappingId", ""); + String level = request.getArg("harden.level", "off"); + if (level == null || level.trim().length() == 0 || "off".equalsIgnoreCase(level.trim())) { + return sourceZip; + } + // The client-side pre-flight (Check 1) sets this when a local/source target opted into an + // unhardened build via harden.allowUnhardenedLocalBuild; honor it as a single point. Prefer the + // per-build request arg (the Mojo injects its instance decision there) over the process-wide + // System property, which is racy under concurrent module builds -- another platform's build could + // clear it between this build's pre-flight and this read. The System property stays as a fallback + // for any caller that has not migrated to the request arg. + if ("true".equals(request.getArg("cn1.harden.forceOff", null)) + || "true".equals(System.getProperty("cn1.harden.forceOff"))) { + log("cn1-hardening: forced off for this local build; building unhardened"); + return sourceZip; + } + 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. Derive a per-build id regardless of + // harden.rename: R8 still renames whenever minification is on (independent of the + // engine's rename), and when minification is off an identity map is uploaded -- both + // need a non-empty id to key the mapping the app carries. An empty id would leave + // hardened Android crashes unretraceable. + if ((lastHardeningMappingId == null || lastHardeningMappingId.length() == 0) + && !hardeningRenameSupported()) { + lastHardeningMappingId = downstreamMappingId(request, hardened); + } + // Propagate the mapping id / hardened flag / level into the request BEFORE the + // builder generates its stubs, so the stubs stamp them as runtime properties + // (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()); + hardeningRanThisBuild = true; + 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)); + // A build can ship several slices from this ONE shared hardened jar (the iOS app plus a + // native-Mac/watch/tv slice). The engine reads a single harden..enabled, so a + // combined build would otherwise honor only one slice's opt-out and silently ignore the others'. + // Coalesce them: hardening runs unless EVERY shipped slice is opted out. A shared jar cannot be + // hardened one way for one slice and another for the other, so this is the honest combination. + p.setProperty("harden." + hardeningPlatform(request) + ".enabled", + Boolean.toString(anySliceHardeningEnabled(effectiveHardeningPlatforms(request), request))); + // The keep rule must name the FULLY QUALIFIED main class: getMainClass() is the simple name + // (the stubs combine it with getPackageName()), so passing it bare would keep a default-package + // class and let ProGuard rename the real application class out from under the generated stub. + p.setProperty("cn1.mainClass", fullyQualifiedMainClass(request)); + // Keep any class a slice resolves by its original name in generated native code (e.g. the + // watch lifecycle entry embedded in cn1_watch_runtime_start), which the input-jar scanner + // cannot see. Appended as ProGuard -keep rules to harden.keep, the caller-keep channel. + java.util.List extraKeeps = extraKeepClasses(request); + if (extraKeeps != null && !extraKeeps.isEmpty()) { + StringBuilder kb = new StringBuilder(p.getProperty("harden.keep", "")); + for (String cls : extraKeeps) { + if (cls == null || cls.trim().length() == 0) { + continue; + } + if (kb.length() > 0) { + kb.append('\n'); + } + kb.append("-keep class ").append(cls.trim()).append(" { *; }"); + } + p.setProperty("harden.keep", kb.toString()); + } + p.setProperty("cn1.renameSupported", Boolean.toString(hardeningRenameSupported())); + // Local plugin builds are ungated: the engine is open source and a developer must be able + // to reproduce a cloud failure locally. The cloud daemon sets this from the account tier. + 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. + */ + // The id is necessarily fixed BEFORE R8 runs -- the app carries it as a compile-time constant, + // and R8's own mapping.txt does not exist until after the app is compiled, so the id cannot be a + // hash of that mapping. It is a per-BUILD nonce (build key + platform + hardened jar bytes + a + // unique run stamp), so every build -- even a byte-identical rebuild, or two builds that reuse a + // build key but produce different R8 mappings because android.proguardKeep or the R8 version + // changed -- gets a distinct id. The daemon uploads that build's R8 mapping.txt keyed by THIS id, + // so a crash report's id selects the exact mapping that build shipped and no upload overwrites + // another build's mapping in the same slot. + private String downstreamMappingId(BuildRequest request, File hardenedJar) { + String seed = resolveBuildKey(request) + ":" + hardeningPlatform(request) + + ":" + System.nanoTime() + ":" + System.identityHashCode(hardenedJar); + try { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); + md.update(seed.getBytes("UTF-8")); + // Fold in the hardened application jar's bytes so two builds that reuse a build key but + // differ in code get distinct ids -- resolveMappingId promises to distinguish a rebuilt + // app that reused a build key. A byte-identical rebuild keeps the same id, matching its + // identical R8 mapping. + if (hardenedJar != null && hardenedJar.isFile()) { + java.io.InputStream in = new java.io.FileInputStream(hardenedJar); + try { + byte[] buf = new byte[65536]; + int n; + while ((n = in.read(buf)) > 0) { + md.update(buf, 0, n); + } + } finally { + in.close(); + } + } + byte[] digest = md.digest(); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + 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 6e7d60eca6b..12f150cd9c8 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -488,6 +488,93 @@ 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"; + } + + /** + * Every Apple slice this one build ships from the SAME hardened jar: the iOS app plus any native-Mac, + * watchOS or tvOS target. hardeningPlatform() reports one tag ("mac" for a combined build), so the + * engine reads a single harden..enabled; listing every slice here lets each target's + * harden..enabled participate in the combined opt-out (Executor.writeHardeningConfig hardens + * unless EVERY slice is opted out). The slices cannot be hardened independently -- there is one binary. + * Reads the RAW hints, because this runs in the shared Executor before the slice builders' parseHints. + */ + @Override + protected java.util.List effectiveHardeningPlatforms(BuildRequest request) { + return appleHardeningSlices(request); + } + + /** The Apple slices a build ships from the shared hardened jar: ios plus any mac/watch/tv target. */ + static java.util.List appleHardeningSlices(BuildRequest request) { + java.util.List platforms = new java.util.ArrayList(); + platforms.add("ios"); + if ("true".equals(request.getArg("macNative.enabled", "false"))) { + platforms.add("mac"); + } + if (watchTargetEnabled(request)) { + platforms.add("watch"); + } + if (tvTargetEnabled(request)) { + platforms.add("tv"); + } + return platforms; + } + + /** True when this build ships a tvOS slice (tvNative.enabled or a tvMain entry point). */ + static boolean tvTargetEnabled(BuildRequest request) { + return "true".equals(request.getArg("tvNative.enabled", "false")) + || request.getArg("tvMain", + request.getArg("tvNative.mainClass", "")).trim().length() > 0; + } + + /** + * The watch lifecycle entry class is resolved by its ORIGINAL fully-qualified name at run time -- + * {@code CN1WatchBootstrap} embeds it in {@code cn1_watch_runtime_start("")} -- and that + * request-only string is not a reference the input-jar scanner can discover. When the watch ships a + * distinct entry class, keep it so the rename doesn't leave the watch runtime looking up a name that + * no longer exists. (A shared entry is the phone main class, already kept as {@code cn1.mainClass}; + * the tvOS target boots through the translated main-class symbol, not a by-name lookup.) + */ + @Override + protected java.util.List extraKeepClasses(BuildRequest request) { + return watchEntryKeepClasses(request); + } + + /** True when this build ships a watchOS slice (watchNative.enabled or a watchMain entry point). */ + static boolean watchTargetEnabled(BuildRequest request) { + return "true".equals(request.getArg("watchNative.enabled", "false")) + || request.getArg("watchMain", + request.getArg("watchNative.mainClass", "")).trim().length() > 0; + } + + /** The distinct watch entry class to keep (fully qualified), or empty when it shares the main class. */ + static java.util.List watchEntryKeepClasses(BuildRequest request) { + if (!watchTargetEnabled(request)) { + return java.util.Collections.emptyList(); + } + String watchMain = request.getArg("watchMain", + request.getArg("watchNative.mainClass", "")).trim(); + String main = request.getMainClass() == null ? "" : request.getMainClass().trim(); + if (watchMain.length() == 0 || watchMain.equals(main)) { + return java.util.Collections.emptyList(); + } + if (watchMain.indexOf('.') < 0) { + String pkg = request.getPackageName(); + if (pkg != null && pkg.trim().length() > 0) { + watchMain = pkg.trim() + "." + watchMain; + } + } + return java.util.Collections.singletonList(watchMain); + } + @Override public boolean build(File sourceZip, BuildRequest request) throws BuildException { // Builder instances are normally single-use, but keep scan-derived @@ -2052,6 +2139,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..939c5a599b7 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java @@ -89,6 +89,22 @@ public File getJavaScriptDeployableArtifact() { return jsDeployableArtifact; } + @Override + protected String hardeningPlatform(BuildRequest request) { + return "javascript"; + } + + /** + * The ParparVM-to-JS build unpacks parparvm-java-api.jar into the translated app (see stageJavaApi), + * so that runtime's literals must be excluded from encryption to preserve reference equality -- + * exactly as on the native ParparVM-C targets. hardeningPlatform() is "javascript" here, which the + * base class does not treat as ParparVM-C, so opt this builder in explicitly. + */ + @Override + protected boolean stagesParparVMRuntime(BuildRequest request) { + return true; + } + @Override public boolean build(File sourceZip, BuildRequest request) throws BuildException { debug("Request Args: "); @@ -132,7 +148,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 +403,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 +415,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 +433,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 7278da0314b..b25bc9cd08a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -147,12 +147,27 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException return; } + // Run the hardening pre-flight BEFORE the Android up-to-date cache short-circuit below. The cache + // check keys only on source-file timestamps (getSourcesModificationTime), not on build hints, so an + // explicit hardening request -- especially one made via a -D command-line property -- would + // otherwise return a stale, potentially unhardened APK without the pre-flight ever validating (or + // refusing) it. Checking here means an invalid/unsupported hardening request fails loudly instead + // of silently succeeding with the cached artifact. + applyHardeningPreflight(); + if (platform.contains("android")) { if (!BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { - String apkName = project.getBuild().getFinalName() + ".apk"; - File apkFile = new File(project.getBuild().getDirectory() + File.separator + apkName); + File apkFile = androidApkFile(); try { - if (apkFile.exists() && apkFile.lastModified() >= getSourcesModificationTime()) { + // Up-to-date only when the APK is newer than the sources AND was built with the SAME + // hardening outcome, recorded in the marker beside it. The source-timestamp check cannot + // see a hardening change made through a build hint, so a change in EITHER direction -- + // enabling hardening (a stale unhardened APK) or disabling it (a stale hardened APK) -- + // must invalidate the cache and rebuild, rather than publish an APK that contradicts the + // current configuration. + if (apkFile.exists() + && hardeningCacheKey.equals(readTextFileOrNull(androidHardeningCacheMarker())) + && apkFile.lastModified() >= getSourcesModificationTime()) { getLog().info("Sources have not been modified since APK at " + apkFile + " was created. Skipping Android build"); return; } @@ -171,6 +186,470 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException getLog().error("Failed to merge properties from library "+ex.libName+". " + ex.getMessage()); throw new MojoExecutionException("Failed to merge properties from library "+ex.libName+". " + ex.getMessage(), ex); } + + // Record the hardening outcome this APK was built with, so a later invocation that changes + // hardening (in either direction) invalidates the timestamp-only up-to-date cache above. + if (platform.contains("android") && !BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { + File marker = androidHardeningCacheMarker(); + if (androidApkFile().exists()) { + try { + writeStringToFile(marker, hardeningCacheKey); + } catch (IOException ex) { + getLog().debug("Could not record the hardening cache key at " + marker, ex); + } + } + } + } + + /** The Android APK this build produces (also the cache key's anchor). */ + private File androidApkFile() { + String apkName = project.getBuild().getFinalName() + ".apk"; + return new File(project.getBuild().getDirectory() + File.separator + apkName); + } + + /** The marker recording the hardening outcome the cached APK was built with, beside the APK. */ + private File androidHardeningCacheMarker() { + File apk = androidApkFile(); + return new File(apk.getParentFile(), apk.getName() + ".cn1hardenkey"); + } + + /** + * A stable fingerprint of every effective {@code codename1.arg.harden.*} setting, so a transform, + * keep-rule or seed change -- not only a level change -- invalidates the APK cache. Keys are sorted so + * the fingerprint is order-independent. + */ + private static String hardeningSettingsFingerprint(Properties settings) { + java.util.TreeMap hints = new java.util.TreeMap(); + for (String key : settings.stringPropertyNames()) { + if (key.startsWith("codename1.arg.harden.")) { + hints.put(key, settings.getProperty(key)); + } + } + StringBuilder sb = new StringBuilder(); + for (java.util.Map.Entry e : hints.entrySet()) { + sb.append(e.getKey()).append('=').append(e.getValue()).append('\n'); + } + return sha256Hex(sb.toString()); + } + + /** SHA-256 of {@code s} as lowercase hex (falls back to the string hash if SHA-256 is somehow absent). */ + private static String sha256Hex(String s) { + try { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); + byte[] d = md.digest(s.getBytes(java.nio.charset.Charset.forName("UTF-8"))); + StringBuilder sb = new StringBuilder(d.length * 2); + for (byte b : d) { + sb.append(Character.forDigit((b >> 4) & 0xf, 16)); + sb.append(Character.forDigit(b & 0xf, 16)); + } + return sb.toString(); + } catch (java.security.NoSuchAlgorithmException ex) { + return Integer.toHexString(s.hashCode()); + } + } + + /** Reads a small text file's trimmed content, or {@code null} if it is absent or unreadable. */ + private static String readTextFileOrNull(File f) { + if (f == null || !f.isFile()) { + return null; + } + try { + return new String(java.nio.file.Files.readAllBytes(f.toPath()), + java.nio.charset.Charset.forName("UTF-8")).trim(); + } catch (IOException ex) { + return null; + } + } + + /** Writes {@code content} to {@code f} (UTF-8), creating parent directories as needed. */ + private static void writeStringToFile(File f, String content) throws IOException { + if (f.getParentFile() != null) { + f.getParentFile().mkdirs(); + } + java.nio.file.Files.write(f.toPath(), + content.getBytes(java.nio.charset.Charset.forName("UTF-8"))); + } + + /** + * 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. + */ + /** + * The hardening pre-flight decisions, carried on the Mojo INSTANCE (not JVM-global state) so a + * concurrent module build under {@code mvn -T} cannot clobber them. Each reactor module executes its + * own CN1BuildMojo instance, so instance fields are naturally per-build; a shared System property was + * racy -- another platform's build could clear {@code cn1.harden.forceOff} between this build setting + * it and the Executor reading it, running hardening locally despite the escape-hatch decision. These + * are injected into this build's BuildRequest ({@link #applyHardeningRequestArgs}) so the Executor + * reads them from the request it was handed rather than from process-wide state. + */ + private boolean hardeningForceOff; + private String hardeningLibraryJars; + /** True once the pre-flight has resolved that hardening will ACTUALLY run for this build (non-off, + * not force-off). Used to publish the library classpath the engine needs. */ + private boolean hardeningWillRun; + /** A fingerprint of the hardening OUTCOME for this build ("unhardened", or "hardened:<level>"), + * recorded next to the Android APK so the timestamp-only up-to-date cache -- which cannot see a + * hardening change made through a build hint -- is invalidated when the outcome changes in EITHER + * direction (enabling or disabling hardening), not just off->on. */ + private String hardeningCacheKey = "unhardened"; + + /** Injects the pre-flight hardening decisions into this build's request (per-build, not global). */ + private void applyHardeningRequestArgs(BuildRequest r) { + if (hardeningForceOff) { + r.putArgument("cn1.harden.forceOff", "true"); + } + if (hardeningLibraryJars != null && hardeningLibraryJars.length() > 0) { + r.putArgument("cn1.hardening.libraryJars", hardeningLibraryJars); + } + } + + private void applyHardeningPreflight() throws MojoFailureException { + Properties settings = new Properties(); + File settingsFile = new File(getCN1ProjectDir(), "codenameone_settings.properties"); + 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); + } + } + // Overlay -D command-line hints (e.g. -Dcodename1.arg.harden.level=standard) so an explicit + // hardening request made only on the command line is seen by this early check -- and, because this + // runs before the Android up-to-date cache short-circuit, is not silently dropped when a prior APK + // is newer than the sources (getSourcesModificationTime does not account for build hints). + overlayCommandLineBuildHints(settings); + applyHardeningPreflight(settings); + } + + /** + * Runs the hardening pre-flight against a given set of effective settings. Called first with the + * project's own {@code codenameone_settings.properties} (fail-fast before the merged jar is built), + * and again after {@code createAntProject} merges the CN1Lib-contributed + * {@code codenameone_library_appended/required.properties} -- a library can turn hardening on via + * {@code codename1.arg.harden.level}, which the early call cannot see, and without this second pass + * such a build would slip past the local-build refusal / force-off and produce a locally hardened + * artifact whose mapping is never uploaded. + */ + private void applyHardeningPreflight(Properties settings) throws MojoFailureException { + String level = settings.getProperty("codename1.arg.harden.level", "off"); + // A per-platform opt-out (harden..enabled=false) means hardening won't run for + // this target, so the pre-flight must not reject it -- treat the level as off. The native-Mac + // 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); + } + // A combined Apple build (iOS app + native-Mac/watch/tvOS slice) hardens ONE shared jar, so the + // builder hardens it unless EVERY shipped slice is opted out (Executor.anySliceHardeningEnabled). + // The preflight must use that same all-slice decision: keying off only the selected slice would, + // e.g. with harden.mac.enabled=false but iOS still on, treat the level as off and skip the + // local-build/on-device-debug refusal while the engine goes on to harden the shared jar locally + // and orphan its mapping. A non-Apple target has a single slice, so its own opt-out still applies. + boolean platformOptedOut; + if (isAppleHardenPlatform(hardenPlatform)) { + platformOptedOut = allAppleHardeningSlicesOptedOut(settings); + } else { + platformOptedOut = hardenPlatform != null && isHardenFalse( + settings.getProperty("codename1.arg.harden." + hardenPlatform + ".enabled", "true")); + } + if (platformOptedOut) { + level = "off"; + } + // Even at a non-off level, a build that has overridden every individual transform off + // (e.g. harden.rename=false, harden.strings=off, harden.controlFlow=false) requests nothing: + // the engine treats that as SKIPPED_NOT_REQUESTED, which is equivalent to off. Resolve the + // overrides to the effective transform set so such a build is not rejected on a local/source + // or on-device-debug target for a "hardening" it isn't actually asking for. An unknown level + // is NOT reduced here -- it must reach the preflight so the invalid-level check rejects it. + if (hardeningReducesToOff(settings, level, hardenPlatform)) { + 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()); + hardeningForceOff = true; + } else { + hardeningForceOff = false; + } + hardeningWillRun = !"off".equalsIgnoreCase(level.trim()) && !r.isForceOff(); + // The APK's hardening OUTCOME. An unhardened build has one key regardless of the nominal level, so + // two off/force-off invocations still hit the cache. A hardened build fingerprints EVERY effective + // harden.* setting -- not just the level -- because harden.strings/rename/controlFlow/keep/seed all + // change the produced transforms and mapping, so two hardened:aggressive builds with different seeds + // or keep rules must still invalidate the cache. Compared against the marker recorded beside the APK. + hardeningCacheKey = hardeningWillRun + ? "hardened:" + hardeningSettingsFingerprint(settings) + : "unhardened"; + // Publish the compile classpath so the hardening engine can hand it to ProGuard as library + // jars (so an application method that overrides a framework method is not renamed apart from + // its superclass). Only needed when hardening will actually run. + if (hardeningWillRun) { + 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()); + } + } + hardeningLibraryJars = sb.toString(); + } catch (org.apache.maven.artifact.DependencyResolutionRequiredException ex) { + getLog().debug("Could not resolve compile classpath for hardening library jars", ex); + } + } else { + hardeningLibraryJars = null; + } + } + + /** + * 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; + } + + /** + * True when a {@code harden.*} boolean setting reads as disabled, using the same tri-state rules + * as the engine's {@code HardeningConfig.boolTri}: {@code false}, {@code 0} and {@code off} all + * mean off. Recognizing only the literal {@code false} here would preflight-reject a + * local/source build that {@code harden..enabled=off} had actually turned off. + */ + private static boolean isHardenFalse(String value) { + if (value == null) { + return false; + } + String t = value.trim().toLowerCase(); + return "false".equals(t) || "0".equals(t) || "off".equals(t); + } + + /** True for the Apple hardening tags whose build ships several slices from one shared hardened jar. */ + private static boolean isAppleHardenPlatform(String hardenPlatform) { + return "ios".equals(hardenPlatform) || "mac".equals(hardenPlatform); + } + + /** + * True only when EVERY Apple slice this build ships (the iOS app plus any native-Mac/watch/tvOS + * target) has opted out via {@code harden..enabled}. Mirrors IPhoneBuilder.appleHardeningSlices + * / Executor.anySliceHardeningEnabled from the settings so the preflight's "reduced to off" decision + * matches the builder's "harden unless every slice opted out". A slice is present only when its target + * is enabled, so an unrelated tvOS opt-out never affects a plain iOS build. + */ + static boolean allAppleHardeningSlicesOptedOut(Properties settings) { + if (!isHardenFalse(settings.getProperty("codename1.arg.harden.ios.enabled", "true"))) { + return false; + } + if ("true".equals(settings.getProperty("codename1.arg.macNative.enabled", "false")) + && !isHardenFalse(settings.getProperty("codename1.arg.harden.mac.enabled", "true"))) { + return false; + } + if (appleSliceTargetEnabled(settings, "watch") + && !isHardenFalse(settings.getProperty("codename1.arg.harden.watch.enabled", "true"))) { + return false; + } + if (appleSliceTargetEnabled(settings, "tv") + && !isHardenFalse(settings.getProperty("codename1.arg.harden.tv.enabled", "true"))) { + return false; + } + return true; + } + + /** True when the watch/tv slice is shipped: its {@code Native.enabled} or a {@code Main}. */ + private static boolean appleSliceTargetEnabled(Properties settings, String slice) { + return "true".equals(settings.getProperty("codename1.arg." + slice + "Native.enabled", "false")) + || settings.getProperty("codename1.arg." + slice + "Main", + settings.getProperty("codename1.arg." + slice + "Native.mainClass", "")).trim() + .length() > 0; + } + + /** + * True when, at this level, at least one hardening transform is still requested once the + * individual {@code harden.*} overrides are applied -- mirroring the engine's + * {@code HardeningConfig}/{@code willApplyAnyTransform} "is anything requested" decision (the + * platform-safety refinement is the server's, and only ever narrows this). A level whose every + * transform is overridden off requests nothing and is equivalent to {@code off}, so the preflight + * must not reject it. + */ + /** + * True when a valid non-off level requests no transform once the {@code harden.*} + * overrides are applied, so it is equivalent to {@code off} and must not be rejected. An unknown + * or misspelled level (rank 0) returns {@code false} so it is left untouched and reaches + * {@link HardeningPreflight#check} -- which rejects it fast, client-side, rather than letting a + * cloud build be submitted for the forked engine to reject later. + */ + static boolean hardeningReducesToOff(Properties settings, String level) { + return hardeningReducesToOff(settings, level, null); + } + + /** + * As {@link #hardeningReducesToOff(Properties, String)}, but taking the resolved hardening + * {@code platform} so a transform the engine SKIPS as unsafe on that target does not keep the level + * from reducing to off. Without this, a local iOS build with only control-flow left on (which the + * engine skips on the ParparVM native ports), or a JavaScript build with only string encryption left + * on (skipped on JS), would be rejected for a hardening it would never actually apply. + */ + static boolean hardeningReducesToOff(Properties settings, String level, String platform) { + return hardenLevelRank(level) >= 1 && !hardeningRequestsAnyTransform(settings, level, platform); + } + + static boolean hardeningRequestsAnyTransform(Properties settings, String level) { + return hardeningRequestsAnyTransform(settings, level, null); + } + + static boolean hardeningRequestsAnyTransform(Properties settings, String level, String platform) { + int rank = hardenLevelRank(level); + if (rank <= 0) { + return false; + } + // rank is 1..3 here (standard/aggressive/paranoid), so the standard-level defaults -- renaming + // and constant-string encryption -- are on unless explicitly overridden off. Control-flow is a + // default only from aggressive up. + boolean atLeastAggressive = rank >= 2; + // Rename is delivered on every platform (by the engine, or by R8 on Android), so it always counts. + boolean rename = hardenBoolTri( + settings.getProperty("codename1.arg.harden.rename"), true); + // String encryption and control-flow are subject to the engine's platform-safety rules: a + // transform the engine would skip on this target must not, on its own, keep the level from + // reducing to off. When the platform is unknown the safety checks pass (conservative -- the + // build is still preflighted rather than silently allowed). + boolean stringsOn = hardenStringsRequested( + settings.getProperty("codename1.arg.harden.strings"), true) + && stringEncryptionAppliesOn(platform); + boolean controlFlow = hardenBoolTri( + settings.getProperty("codename1.arg.harden.controlFlow"), atLeastAggressive) + && controlFlowAppliesOn(platform); + return rename || stringsOn || controlFlow; + } + + /** Engine rule: string encryption is skipped only on JavaScript (it would break the JS bridge). */ + private static boolean stringEncryptionAppliesOn(String platform) { + return !"javascript".equals(platform); + } + + /** + * Engine rule: control-flow obfuscation runs only on the JVM-bytecode ports (Android, JavaSE/ + * desktop); it is skipped on the ParparVM native ports and JavaScript. An unknown platform is + * treated as applicable so an ambiguous build is preflighted rather than silently allowed. + */ + private static boolean controlFlowAppliesOn(String platform) { + if (platform == null) { + return true; + } + return "and".equals(platform) || "android".equals(platform) + || "javase".equals(platform) || "desktop".equals(platform); + } + + /** off/empty/unknown = 0, standard = 1, aggressive = 2, paranoid = 3. */ + private static int hardenLevelRank(String level) { + if (level == null) { + return 0; + } + String v = level.trim().toLowerCase(); + if ("standard".equals(v)) { + return 1; + } + if ("aggressive".equals(v)) { + return 2; + } + if ("paranoid".equals(v)) { + return 3; + } + return 0; + } + + /** Tri-state boolean matching the engine's {@code HardeningConfig.boolTri}. */ + private static boolean hardenBoolTri(String value, boolean def) { + if (value == null) { + return def; + } + String t = value.trim().toLowerCase(); + if (t.isEmpty()) { + return def; + } + if ("true".equals(t) || "1".equals(t) || "2".equals(t) || "3".equals(t) || "on".equals(t)) { + return true; + } + if ("false".equals(t) || "0".equals(t) || "off".equals(t)) { + return false; + } + return def; + } + + /** + * Whether string encryption is requested, matching {@code HardeningConfig}'s {@code harden.strings} + * parsing: {@code off} disables; {@code constants}/{@code all} enable; anything else (including an + * unset value) falls back to the level default, which the CLI validates up front. + */ + private static boolean hardenStringsRequested(String strings, boolean def) { + if (strings == null) { + return def; + } + String v = strings.trim().toLowerCase(); + if (v.isEmpty()) { + return def; + } + if ("off".equals(v)) { + return false; + } + if ("constants".equals(v) || "all".equals(v)) { + return true; + } + return def; + } + + /** Maps {@code codename1.platform} to the {@code harden..enabled} opt-out key. */ + private static String normalizeHardenPlatform(String platform) { + if (platform == null) { + 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; } /** @@ -882,6 +1361,14 @@ private void createAntProject() throws IOException, LibraryPropertiesException, } + // Re-run the hardening pre-flight against the MERGED effective settings: a CN1Lib can supply + // codename1.arg.harden.level (or a per-platform opt-out) via the appended/required properties + // just merged above, which the early pre-flight -- run before this jar existed -- could not see. + // Without this, a library that turns hardening on would slip a local/source build past the + // local-build refusal / force-off and produce a locally hardened artifact whose mapping is never + // uploaded (so its crashes could never be retraced). + applyHardeningPreflight(cn1SettingsProps); + cn1SettingsProps.setProperty("codename1.arg.hyp.beamId", logPasskey); cn1SettingsProps.setProperty("codename1.arg.maven.codenameone-core.version", cn1MavenVersion); @@ -1313,6 +1800,7 @@ private void doAndroidLocalBuild(File tmpProjectDir, Properties props, File dist r.putArgument(currentKey, value); } } + applyHardeningRequestArgs(r); BuildRequest request = r; request.setIncludeSource(true); @@ -1323,7 +1811,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()"); @@ -1516,6 +2004,7 @@ private void doIOSLocalBuild(File tmpProjectDir, Properties props, File distJar) r.putArgument(currentKey, value); } } + applyHardeningRequestArgs(r); BuildRequest request = r; String incSources = request.getArg("build.incSources", null); @@ -1527,7 +2016,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) { @@ -1633,6 +2122,7 @@ private void doWindowsNativeLocalBuild(File tmpProjectDir, Properties props, Fil r.putArgument(currentKey, props.getProperty(key)); } } + applyHardeningRequestArgs(r); // Authenticode signing certificate. Configured through settings/properties // (codename1.windows.signing.certificate = path to the .p12/.pfx, and // codename1.windows.signing.password). This mirrors the cloud build, whose @@ -1658,7 +2148,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) { @@ -1669,6 +2159,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) { @@ -1736,10 +2228,11 @@ private void doLinuxNativeLocalBuild(File tmpProjectDir, Properties props, File r.putArgument(currentKey, props.getProperty(key)); } } + applyHardeningRequestArgs(r); 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) { @@ -1750,6 +2243,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) { @@ -1829,10 +2324,11 @@ private void doJavaScriptLocalBuild(File tmpProjectDir, Properties props, File d r.putArgument(currentKey, value); } } + applyHardeningRequestArgs(r); 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/AndroidGradleBuilderVersionTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java index bf6e1f7ee2a..2b55108140c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java @@ -37,6 +37,80 @@ void comparesMinorVersionsInsteadOfOnlyTheMajorVersion() { assertTrue(AndroidGradleBuilder.compareVersions("8.13.2", "8.13") > 0); } + @Test + void renameHardeningRejectsEveryValueThatLeavesR8Off() { + // R8 renames only when android.enableProguard is exactly "true"; every other value leaves it + // off. A rename profile must be rejected for all of them, not only the literal "false". + for (String off : new String[] {"false", "off", "0", "no", "False", "OFF", "", "yes"}) { + assertTrue(AndroidGradleBuilder.r8RenameRequiredButDisabled(true, off), + "rename requested + enableProguard=" + off + " must be rejected"); + } + // Exactly "true" enables R8, so a rename profile is fine. + assertFalse(AndroidGradleBuilder.r8RenameRequiredButDisabled(true, "true")); + // When rename is not requested, R8 being off is irrelevant. + assertFalse(AndroidGradleBuilder.r8RenameRequiredButDisabled(false, "off")); + assertFalse(AndroidGradleBuilder.r8RenameRequiredButDisabled(false, "true")); + } + + @Test + void renameHardeningNeedsAReleaseVariantNotJustEnableProguard() { + // R8 minifyEnabled lives in the release buildType, so a debug-only build never renames even + // with the default android.enableProguard=true. + BuildRequest debugOnly = new BuildRequest(); + debugOnly.setCertificate(new byte[] {1, 2, 3}); + debugOnly.putArgument("android.release", "false"); + debugOnly.putArgument("android.debug", "true"); + assertFalse(AndroidGradleBuilder.androidReleaseVariantBuilt(debugOnly), + "android.release=false + debug builds only assembleDebug"); + + // A default (release) build with a certificate does produce a release variant. + BuildRequest release = new BuildRequest(); + release.setCertificate(new byte[] {1, 2, 3}); + assertTrue(AndroidGradleBuilder.androidReleaseVariantBuilt(release)); + + // Neither explicitly selected falls back to building both (release included). + BuildRequest both = new BuildRequest(); + both.setCertificate(new byte[] {1, 2, 3}); + both.putArgument("android.release", "false"); + both.putArgument("android.debug", "false"); + assertTrue(AndroidGradleBuilder.androidReleaseVariantBuilt(both)); + + // No signing certificate means only assembleDebug runs, so no release variant. + BuildRequest noCert = new BuildRequest(); + noCert.putArgument("android.release", "true"); + assertFalse(AndroidGradleBuilder.androidReleaseVariantBuilt(noCert)); + } + + @Test + void forcedOffLocalBuildDoesNotRequireR8() { + // harden.allowUnhardenedLocalBuild takes the escape hatch: hardenSourceJar returns the original + // jar stamped cn1.hardened=false, so the R8-rename enforcement must NOT fire even though the level + // still reads aggressive -- otherwise a local build with R8 off or no release cert is rejected + // despite opting out of hardening. + BuildRequest forcedOff = new BuildRequest(); + forcedOff.putArgument("harden.level", "aggressive"); + forcedOff.putArgument("cn1.hardened", "false"); + assertFalse(AndroidGradleBuilder.androidRenameHardeningActive(forcedOff, true, true), + "cn1.hardened=false (forced-off escape hatch) must not require R8"); + + // A build that actually hardened (verified output) with a rename profile DOES require R8. + BuildRequest hardened = new BuildRequest(); + hardened.putArgument("harden.level", "aggressive"); + hardened.putArgument("cn1.hardened", "true"); + assertTrue(AndroidGradleBuilder.androidRenameHardeningActive(hardened, true, true), + "a verified hardened rename profile requires R8"); + + // Even with cn1.hardened=true, harden.level=off or rename opted out needs no R8. + BuildRequest offLevel = new BuildRequest(); + offLevel.putArgument("harden.level", "off"); + offLevel.putArgument("cn1.hardened", "true"); + assertFalse(AndroidGradleBuilder.androidRenameHardeningActive(offLevel, true, true)); + assertFalse(AndroidGradleBuilder.androidRenameHardeningActive(hardened, true, false), + "rename opted out needs no R8"); + assertFalse(AndroidGradleBuilder.androidRenameHardeningActive(hardened, false, true), + "harden.and.enabled=false needs no R8"); + } + @Test void typedPushAutoDetectsBothAndroidProviderConfigurations() { assertTrue(AndroidGradleBuilder.usesFcmPush(3, "auto", true)); 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..f816006bcc9 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HardeningBooleanArgTest.java @@ -0,0 +1,107 @@ +/* + * 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)"); + } + + @Test + void parparvmCTargetsAreRecognizedForRuntimeLiteralExclusion() { + // The parparvm-java-api.jar runtime-literal exclusion applies exactly to the ParparVM-to-C + // targets, where a compile-time literal is a constant-pool object that is never interned; the + // DEX/JVM/JS targets intern their compile-time literals so no exclusion is needed there. + Probe p = new Probe(); + for (String t : new String[] {"ios", "mac", "watch", "tv", "win", "linux"}) { + assertTrue(p.isParparVMCPlatform(t), t + " translates to C via ParparVM"); + } + for (String t : new String[] {"and", "android", "javase", "desktop", "javascript"}) { + assertFalse(p.isParparVMCPlatform(t), t + " interns its compile-time literals"); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java new file mode 100644 index 00000000000..4e874fa8706 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +/** + * A combined build ships several Apple slices from one shared hardened jar, so every slice's + * harden.<platform>.enabled must participate in the opt-out: hardening runs unless EVERY slice is + * opted out. Matches the daemon builder so the local and cloud decisions agree. + */ +class IPhoneBuilderHardeningOptOutTest { + + @Test + void combinedBuildListsEveryAppleSlice() { + BuildRequest plain = new BuildRequest(); + assertEquals(Arrays.asList("ios"), IPhoneBuilder.appleHardeningSlices(plain), + "a plain iOS build ships only the iOS slice"); + + BuildRequest combined = new BuildRequest(); + combined.putArgument("macNative.enabled", "true"); + combined.putArgument("watchNative.enabled", "true"); + assertEquals(Arrays.asList("ios", "mac", "watch"), + IPhoneBuilder.appleHardeningSlices(combined), + "a combined build lists the iOS app plus its native-Mac and watch slices"); + } + + @Test + void hardeningRunsUnlessEverySliceOptedOut() { + BuildRequest req = new BuildRequest(); + java.util.List iosMac = Arrays.asList("ios", "mac"); + + // Neither opted out -> harden. + assertTrue(Executor.anySliceHardeningEnabled(iosMac, req)); + + // Only the iOS slice opted out, Mac still on -> still harden (the shared jar is hardened for Mac). + req.putArgument("harden.ios.enabled", "false"); + assertTrue(Executor.anySliceHardeningEnabled(iosMac, req), + "harden.ios.enabled=false alone must NOT skip hardening the shared jar the Mac slice wants"); + + // Only the Mac slice opted out, iOS still on -> still harden (fixes the old 'consult only mac' bug). + req = new BuildRequest(); + req.putArgument("harden.mac.enabled", "off"); + assertTrue(Executor.anySliceHardeningEnabled(iosMac, req), + "harden.mac.enabled=off must no longer leave the iOS artifact unhardened"); + + // EVERY slice opted out -> skip. + req = new BuildRequest(); + req.putArgument("harden.ios.enabled", "false"); + req.putArgument("harden.mac.enabled", "0"); + assertFalse(Executor.anySliceHardeningEnabled(iosMac, req), + "hardening is skipped only when every shipped slice opted out"); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderWatchKeepTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderWatchKeepTest.java new file mode 100644 index 00000000000..7aa335102b4 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderWatchKeepTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * The watch lifecycle entry class is resolved by its original fully-qualified name at run time, so a + * distinct watchMain must be kept from renaming; a watch that shares the phone main class needs no + * extra keep (the main class is already kept). + */ +class IPhoneBuilderWatchKeepTest { + + private static BuildRequest request(String main, String pkg, String... kv) { + BuildRequest r = new BuildRequest(); + if (main != null) { + r.setMainClass(main); + } + if (pkg != null) { + r.setPackageName(pkg); + } + for (int i = 0; i < kv.length; i += 2) { + r.putArgument(kv[i], kv[i + 1]); + } + return r; + } + + @Test + void distinctFullyQualifiedWatchMainIsKept() { + BuildRequest r = request("MyApp", "com.example", + "watchNative.enabled", "true", "watchMain", "com.example.MyWatchApp"); + List keep = IPhoneBuilder.watchEntryKeepClasses(r); + assertEquals(1, keep.size()); + assertEquals("com.example.MyWatchApp", keep.get(0)); + } + + @Test + void simpleWatchMainIsQualifiedWithThePackage() { + BuildRequest r = request("MyApp", "com.example", + "watchMain", "MyWatchApp"); + List keep = IPhoneBuilder.watchEntryKeepClasses(r); + assertEquals(1, keep.size()); + assertEquals("com.example.MyWatchApp", keep.get(0)); + assertTrue(IPhoneBuilder.watchTargetEnabled(r), "a watchMain entry auto-enables the watch slice"); + } + + @Test + void watchSharingTheMainClassNeedsNoExtraKeep() { + // watchMain equals the phone main class (already kept as cn1.mainClass). + BuildRequest r = request("MyApp", "com.example", + "watchNative.enabled", "true", "watchMain", "MyApp"); + assertTrue(IPhoneBuilder.watchEntryKeepClasses(r).isEmpty()); + } + + @Test + void noWatchTargetMeansNoKeep() { + BuildRequest r = request("MyApp", "com.example"); + assertTrue(IPhoneBuilder.watchEntryKeepClasses(r).isEmpty()); + assertTrue(!IPhoneBuilder.watchTargetEnabled(r)); + } +} 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..2e5a128ded2 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HardeningPreflightTest.java @@ -0,0 +1,206 @@ +/* + * 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")); + } + + @Test + public void levelWithEveryTransformOverriddenOffRequestsNothing() { + // standard defaults to rename + constant-string encryption; overriding all of them off leaves + // nothing for the engine to do (SKIPPED_NOT_REQUESTED), so the mojo must treat it as off and + // not reject a local/source build for a "hardening" it isn't asking for. + java.util.Properties p = new java.util.Properties(); + p.setProperty("codename1.arg.harden.rename", "false"); + p.setProperty("codename1.arg.harden.strings", "off"); + p.setProperty("codename1.arg.harden.controlFlow", "false"); + assertFalse(CN1BuildMojo.hardeningRequestsAnyTransform(p, "standard")); + assertFalse(CN1BuildMojo.hardeningRequestsAnyTransform(p, "aggressive")); + assertFalse(CN1BuildMojo.hardeningRequestsAnyTransform(p, "paranoid")); + } + + @Test + public void aRemainingTransformStillCounts() { + // Any single transform left on means hardening IS requested. + java.util.Properties strings = new java.util.Properties(); + strings.setProperty("codename1.arg.harden.rename", "false"); + strings.setProperty("codename1.arg.harden.strings", "all"); + strings.setProperty("codename1.arg.harden.controlFlow", "false"); + assertTrue(CN1BuildMojo.hardeningRequestsAnyTransform(strings, "standard")); + + java.util.Properties rename = new java.util.Properties(); + rename.setProperty("codename1.arg.harden.strings", "off"); + rename.setProperty("codename1.arg.harden.controlFlow", "false"); + // rename unset -> defaults on at standard. + assertTrue(CN1BuildMojo.hardeningRequestsAnyTransform(rename, "standard")); + } + + @Test + public void defaultsAtStandardRequestHardening() { + // No overrides at a non-off level requests hardening (rename + constant strings by default). + assertTrue(CN1BuildMojo.hardeningRequestsAnyTransform(new java.util.Properties(), "standard")); + // off requests nothing regardless of overrides. + assertFalse(CN1BuildMojo.hardeningRequestsAnyTransform(new java.util.Properties(), "off")); + } + + @Test + public void invalidLevelIsNotReducedToOff() { + // A misspelled level must NOT be silently rewritten to off by the no-transform reduction: it + // has to reach HardeningPreflight.check() so the client-side invalid-level validation fires. + java.util.Properties allOff = new java.util.Properties(); + allOff.setProperty("codename1.arg.harden.rename", "false"); + allOff.setProperty("codename1.arg.harden.strings", "off"); + allOff.setProperty("codename1.arg.harden.controlFlow", "false"); + assertFalse(CN1BuildMojo.hardeningReducesToOff(allOff, "stanadrd")); + assertFalse(CN1BuildMojo.hardeningReducesToOff(new java.util.Properties(), "stanadrd")); + // The invalid level still fails the preflight, unchanged. + assertTrue(HardeningPreflight.check("stanadrd", "ios-device", false, false).isFailed()); + } + + @Test + public void validLevelWithEveryTransformOffReducesToOff() { + java.util.Properties allOff = new java.util.Properties(); + allOff.setProperty("codename1.arg.harden.rename", "false"); + allOff.setProperty("codename1.arg.harden.strings", "off"); + allOff.setProperty("codename1.arg.harden.controlFlow", "false"); + assertTrue(CN1BuildMojo.hardeningReducesToOff(allOff, "standard")); + // A real request or plain off is not "reduced" (off has nothing to reduce). + assertFalse(CN1BuildMojo.hardeningReducesToOff(new java.util.Properties(), "standard")); + assertFalse(CN1BuildMojo.hardeningReducesToOff(allOff, "off")); + } + + @Test + public void controlFlowOnlyReducesToOffOnParparVMTargets() { + // aggressive with rename+strings off leaves only control flow, which the engine SKIPS on the + // ParparVM native ports. So on iOS this reduces to off (must not be rejected), while on a + // JVM-bytecode target (JavaSE/Android) control flow really runs and it does NOT reduce to off. + java.util.Properties cfOnly = new java.util.Properties(); + cfOnly.setProperty("codename1.arg.harden.rename", "false"); + cfOnly.setProperty("codename1.arg.harden.strings", "off"); + // controlFlow unset -> on by default at aggressive. + assertTrue(CN1BuildMojo.hardeningReducesToOff(cfOnly, "aggressive", "ios"), + "control flow is skipped on iOS, so nothing runs"); + assertTrue(CN1BuildMojo.hardeningReducesToOff(cfOnly, "aggressive", "win")); + assertFalse(CN1BuildMojo.hardeningReducesToOff(cfOnly, "aggressive", "javase"), + "control flow really runs on JavaSE"); + assertFalse(CN1BuildMojo.hardeningReducesToOff(cfOnly, "aggressive", "and"), + "control flow really runs on Android"); + } + + @Test + public void stringOnlyReducesToOffOnJavaScript() { + // standard with rename+controlFlow off leaves only string encryption, which the engine SKIPS on + // JavaScript (the native bridge). So on JS this reduces to off, but on iOS strings really run. + java.util.Properties strOnly = new java.util.Properties(); + strOnly.setProperty("codename1.arg.harden.rename", "false"); + strOnly.setProperty("codename1.arg.harden.controlFlow", "false"); + // strings unset -> constant-string encryption on by default at standard. + assertTrue(CN1BuildMojo.hardeningReducesToOff(strOnly, "standard", "javascript"), + "string encryption is skipped on JavaScript, so nothing runs"); + assertFalse(CN1BuildMojo.hardeningReducesToOff(strOnly, "standard", "ios"), + "string encryption really runs on iOS"); + } + + @Test + public void combinedAppleBuildIsOffOnlyWhenEverySliceOptedOut() { + // The builder hardens a combined Apple build's shared jar unless EVERY slice opts out, so the + // preflight must reduce to off under the same all-slice rule. Otherwise a combined build that + // opted out only Mac would skip the local-build refusal while the engine still hardens for iOS. + java.util.Properties macOff = new java.util.Properties(); + macOff.setProperty("codename1.arg.macNative.enabled", "true"); + macOff.setProperty("codename1.arg.harden.mac.enabled", "false"); + assertFalse(CN1BuildMojo.allAppleHardeningSlicesOptedOut(macOff), + "iOS still enabled -> the shared jar is hardened, so not off"); + + macOff.setProperty("codename1.arg.harden.ios.enabled", "off"); + assertTrue(CN1BuildMojo.allAppleHardeningSlicesOptedOut(macOff), + "every shipped slice opted out -> off"); + + // A plain iOS build (no mac/watch/tv slice) with iOS opted out is off. + java.util.Properties iosOff = new java.util.Properties(); + iosOff.setProperty("codename1.arg.harden.ios.enabled", "false"); + assertTrue(CN1BuildMojo.allAppleHardeningSlicesOptedOut(iosOff)); + + // An unrelated tvOS opt-out on a build that ships no tvOS slice must not turn hardening off. + java.util.Properties tvOnly = new java.util.Properties(); + tvOnly.setProperty("codename1.arg.harden.tv.enabled", "false"); + assertFalse(CN1BuildMojo.allAppleHardeningSlicesOptedOut(tvOnly), + "iOS is still enabled and no tvOS slice ships, so hardening runs"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/BoundedOutputStreamTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/BoundedOutputStreamTest.java new file mode 100644 index 00000000000..ee6fc22dfd7 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/crash/BoundedOutputStreamTest.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.crash; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.PrintStream; +import org.junit.jupiter.api.Test; + +/** The bounded buffer that caps raw-stack capture so a huge trace cannot OOM during crash handling. */ +class BoundedOutputStreamTest { + + @Test + void keepsFirstBytesAndDiscardsTheRest() throws Exception { + CrashProtection.BoundedOutputStream b = new CrashProtection.BoundedOutputStream(10); + b.write("hello".getBytes("UTF-8"), 0, 5); + b.write("world!!!".getBytes("UTF-8"), 0, 8); // only 5 more fit + assertEquals("helloworld", b.toUtf8()); + b.write('x'); // past capacity: discarded + assertEquals("helloworld", b.toUtf8()); + } + + @Test + void capsAHugePrintStreamRenderingAtCapacity() throws Exception { + // Simulate a pathologically large rendering: far more than the cap is written, but the buffer + // never grows past its fixed capacity, so crash handling cannot allocate without bound. + int cap = 4096; + CrashProtection.BoundedOutputStream b = new CrashProtection.BoundedOutputStream(cap); + PrintStream ps = new PrintStream(b, true, "UTF-8"); + for (int i = 0; i < 100000; i++) { + ps.print("0123456789"); + } + ps.flush(); + assertEquals(cap, b.toUtf8().length()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java new file mode 100644 index 00000000000..40e8408a61b --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/crash/PiiScrubberRawStackTest.java @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.crash; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** scrubRawStack keeps frame-line coordinates while still scrubbing message PII. */ +class PiiScrubberRawStackTest { + + private final PiiScrubber scrubber = new PiiScrubber(); + + @Test + void nullPassesThrough() { + assertEquals(null, scrubber.scrubRawStack(null)); + } + + @Test + void largeJavaScriptColumnOffsetsAreMaskedShortLineNumbersSurvive() { + // A minified bundle's column runs to six-plus digits. Because a message can plant a long id in a + // frame-shaped line and no text heuristic can distinguish it, the raw stack is scrubbed uniformly: + // the 6+ digit column is masked (precise coordinates come from the structured frames), while an + // ordinary short line number is left readable. + String stack = "TypeError: undefined is not a function\n" + + " at run (http://host/app.js:1:123456)\n" + + " at go (http://host/app.js:42)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("app.js:42") >= 0, scrubbed); + } + + @Test + void unindentedFirefoxAtSignFrameIsScrubbedBecauseItIsAmbiguous() { + // The Firefox/Safari fn@url:line:col form is emitted WITHOUT indentation, so a message + // continuation that reproduces the grammar exactly (user@https://host/app.js:1:123456) is + // indistinguishable from a real frame -- no shape check can tell them apart. To avoid leaking a + // crafted message's numeric tail, an unindented @ line is treated as a message and its long tail + // is masked. (Structured frames carry the coordinate for symbolication; the indented V8 + // 'at ...:line:col' form still preserves it -- see v8AsyncFrameWithMultiWordLabel...) + String stack = "Error: boom\nrun@http://host/app.js:1:123456\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + } + + @Test + void parparVmLineNumbersSurvive() { + // An ordinary ParparVM line number is short (< 6 digits), so it is left readable by the uniform + // digit-run masking (only 6+ digit runs -- long ids or minified columns -- are masked). + String stack = " at com.foo.Bar.baz:4242\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("baz:4242") >= 0, scrubbed); + } + + @Test + void messageMentioningAFileIsNotAFrame() { + // A free-form message can mention a file and an id; it is not a frame just because it contains + // ".js:", so its long id must still be masked. (No terminal :line:column, no leading "at ".) + String stack = "Error: account 123456 failed in app.js: retry\n" + + " at run (http://host/app.js:1:98765)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account [num] failed") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + // ...while the real frame below keeps its coordinate. + assertTrue(scrubbed.indexOf("app.js:1:98765") >= 0, scrubbed); + } + + @Test + void messageLineStartingWithAtIsNotAFrame() { + // printStackTrace can wrap a message onto a line that begins with "at " but carries no frame + // location; its long id must still be scrubbed. A real JVM frame below keeps its line number. + String stack = "java.lang.RuntimeException: bad\n" + + "at account 123456 failed to load\n" + + "\tat com.foo.Bar.baz(Bar.java:4242)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account [num] failed") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("Bar.java:4242") >= 0, scrubbed); + } + + @Test + void messageWithIncidentalParenthesesIsNotAFrame() { + // A message wrapped onto an "at ..." line can carry incidental parentheses that are not a frame + // location; its id must still be scrubbed. Real parenthesized locations below survive. + String stack = "java.lang.RuntimeException: bad\n" + + "at account 123456 failed (retry)\n" + + "\tat com.foo.Bar.baz(Bar.java:4242)\n" + + "\tat com.foo.Qux.run(Native Method)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account [num] failed (retry)") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("Bar.java:4242") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("(Native Method)") >= 0, scrubbed); + } + + @Test + void messageWithAtPrefixAndColonNumberIsNotAFrame() { + // A wrapped message can start with "at " AND end in a colon-number or carry a parenthetical + // location, yet its identity has spaces, so it is not a frame and its id must be scrubbed. + String stack = "java.lang.RuntimeException: bad\n" + + "at account 123456 failed:789\n" + + "at account 654321 failed (token:12)\n" + + "\tat com.foo.Bar.baz(Bar.java:4242)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account [num] failed:789") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("654321") < 0, scrubbed); + // The real frame keeps its coordinate. + assertTrue(scrubbed.indexOf("Bar.java:4242") >= 0, scrubbed); + } + + @Test + void messageWithWhitespaceFreeIdentityIsNotAFrame() { + // A wrapped message with no spaces around its id ("at account123456failed:789") is not a frame: + // the bare colon-number form requires a dotted class.method or a URL, and a bare word/word+digits + // paren location ("(attempt:123456)") is not a real location either. + String stack = "java.lang.RuntimeException: bad\n" + + "at account123456failed:789\n" + + "at retry (attempt:654321)\n" + + "\tat com.foo.Bar.baz(Bar.java:4242)\n" + + " at com.foo.Bar.qux:998877\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account[num]failed:789") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("attempt:[num]") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("654321") < 0, scrubbed); + // Short numbers (an ordinary line number) survive; a 6+ digit run is masked whether it is a real + // coordinate or a planted id, since a message line and a frame line are indistinguishable. + assertTrue(scrubbed.indexOf("Bar.java:4242") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("998877") < 0, scrubbed); + } + + @Test + void frameUrlQueryDataIsScrubbedButCoordinateSurvives() { + // A JS frame URL can carry user data in its query; the terminal line:column coordinate must + // survive for symbolication, but the identity/URL/query before it still gets scrubbed. + String stack = "TypeError: boom\n" + + " at f (https://host/app.js?account=123456:1:42)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account=[num]") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf(":1:42)") >= 0, scrubbed); + } + + @Test + void coordinateFreeFrameMimicIsScrubbed() { + // A message mimicking a coordinate-free frame ("(Native Method)"/"(Unknown Source)") has no + // coordinate to protect, so the whole line is scrubbed; a real such frame's dotted identity has + // no long digit run and is unchanged. + String stack = "java.lang.RuntimeException: bad\n" + + "at account123456failed (Native Method)\n" + + "at other654321thing (Unknown Source)\n" + + "\tat com.foo.Bar.baz(Native Method)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account[num]failed") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("654321") < 0, scrubbed); + assertTrue(scrubbed.indexOf("com.foo.Bar.baz(Native Method)") >= 0, scrubbed); + } + + @Test + void onlyTheTerminalTwoCoordinateGroupsArePreserved() { + // A frame URL can carry colon-delimited user data before the real :line:column, e.g. + // host/account:123456:1:42. Only the terminal two numeric groups (:1:42) are the coordinate; + // the earlier :123456 is data and must be scrubbed. + String stack = "TypeError: boom\n" + + " at f (https://host/account:123456:1:42)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("account:[num]:1:42)") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + } + + @Test + void customScrubMessageOverrideReachesRawStack() { + // An app that redacts an app-specific token by overriding scrubMessage must have it redacted + // in rawStack too, not only in the separately-scrubbed message. Frame lines stay untouched by + // the override so coordinates survive. + PiiScrubber custom = new PiiScrubber() { + public String scrubMessage(String message) { + if (message == null) { + return null; + } + return super.scrubMessage(message).replace("SECRET", "[redacted]"); + } + }; + String stack = "java.lang.RuntimeException: token SECRET rejected\n" + + " at run (http://host/app.js:1:98765)\n"; + String scrubbed = custom.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("[redacted]") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("SECRET") < 0, scrubbed); + assertTrue(scrubbed.indexOf("app.js:1:98765") >= 0, scrubbed); + } + + @Test + void messageDigitsAndEmailsStillScrubbed() { + // The leading message line is free-form and can carry PII: a long id/phone is masked and an + // email is partially redacted, even though frame coordinates below are preserved. + String stack = "java.lang.RuntimeException: user 5551234567 test@example.com\n" + + " at com.foo.Bar.baz(Bar.java:42)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("[num]") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("5551234567") < 0, scrubbed); + assertTrue(scrubbed.indexOf("tes***@example.com") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("Bar.java:42") >= 0, scrubbed); + } + + @Test + void unindentedFrameShapedMessageContinuationIsScrubbed() { + // A message that wraps onto a line matching the frame grammar EXACTLY -- a dotted identity and a + // (File.java:line) location -- is still a message, not a frame: printStackTrace emits it at column + // 0 while real frames are indented. Its six-digit tail must be scrubbed, not preserved as a line + // number. The genuine indented frame below keeps its coordinate. + String stack = "java.lang.RuntimeException: bad\n" + + "at account.failed(File.java:123456)\n" + + " at com.foo.Bar.baz(Bar.java:42)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("Bar.java:42") >= 0, scrubbed); + } + + @Test + void atSignLineWithLongTailIsScrubbedWhateverItsSourceShape() { + // Every unindented '@' line is treated as a message (the Firefox form is ambiguous, see + // unindentedFirefoxAtSignFrame...), so a long numeric tail is masked whether the text after '@' + // is a bare host, a dotted host, or a full URL -- none of them is trusted as a frame coordinate. + String stack = "java.lang.RuntimeException: verifying\n" + + "status@host:1:123456\n" + + "status@host.com:1:234567\n" + + "renderApp@https://host/app.js:1:345678\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("234567") < 0, scrubbed); + assertTrue(scrubbed.indexOf("345678") < 0, scrubbed); + } + + @Test + void scrubFrameOverrideRedactsSyntheticMethodNameInRawStack() { + // An app that overrides scrubFrame to strip PII from a synthetic method name must have that + // redaction applied to the raw stack too, not only to the structured frames -- else the raw copy + // reintroduces the value the app explicitly removed. The frame's coordinate is still preserved. + PiiScrubber custom = new PiiScrubber() { + public String scrubFrame(String className, String methodName) { + return methodName.replace("secret", "[redacted]"); + } + }; + String stack = "java.lang.RuntimeException: boom\n" + + " at com.foo.Bar.secretMethod(Bar.java:42)\n"; + String scrubbed = custom.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("secretMethod") < 0, scrubbed); + assertTrue(scrubbed.indexOf("[redacted]Method") >= 0, scrubbed); + assertTrue(scrubbed.indexOf("Bar.java:42") >= 0, scrubbed); + } + + @Test + void scrubFrameOverrideReturningNullRemovesTheMethodFromRawStack() { + // scrubFrame may return null to REMOVE a sensitive synthetic method name (the structured frame + // renders it empty). The raw stack must not restore the original: the method is rendered empty. + PiiScrubber custom = new PiiScrubber() { + public String scrubFrame(String className, String methodName) { + return methodName.startsWith("secret") ? null : methodName; + } + }; + String stack = "java.lang.RuntimeException: boom\n" + + " at com.foo.Bar.secretHandler(Bar.java:42)\n"; + String scrubbed = custom.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("secretHandler") < 0, scrubbed); + assertTrue(scrubbed.indexOf("com.foo.Bar.(Bar.java:42)") >= 0, scrubbed); + } + + @Test + void indentedMessageContinuationWithArbitraryLabelIsScrubbed() { + // An INDENTED message continuation (the message itself contains "\n at ...") whose text happens + // to look like a frame -- an arbitrary multi-word label plus a (File.java:line) location -- must + // still be scrubbed: "account failed" is not a V8 label shape (async/new/bound/get/set/[as]), so + // its six-digit tail is masked, not preserved. A genuine V8 async frame below keeps its coordinate. + String stack = "java.lang.RuntimeException: bad\n" + + " at account failed (File.java:123456)\n" + + " at async run (https://host/app.js:2:98765)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("app.js:2:98765") >= 0, scrubbed); + } + + @Test + void v8AccessorAliasWithFreeTextIsScrubbed() { + // A real V8 accessor alias is a single property name ([as bar]); an indented continuation that + // hides free text inside the brackets -- " at account [as failed message] (File.java:123456)" + // -- is not a frame, so its numeric tail must be scrubbed. A genuine accessor frame below (single + // token alias) keeps its coordinate. + String stack = "java.lang.RuntimeException: bad\n" + + " at account [as failed message] (File.java:123456)\n" + + " at handler [as onClick] (https://host/app.js:3:98765)\n"; + String scrubbed = scrubber.scrubRawStack(stack); + assertTrue(scrubbed.indexOf("123456") < 0, scrubbed); + assertTrue(scrubbed.indexOf("app.js:3:98765") >= 0, scrubbed); + } + +} diff --git a/maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java b/maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java new file mode 100644 index 00000000000..b70ff00045e --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/crash/TraceFormatTest.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.crash; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** How the crash payload labels a raw stack so the server picks the right parser. */ +class TraceFormatTest { + + @Test + void emptyOrNoStack() { + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, null, "Android")); + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, "", "HTML5")); + } + + @Test + void javaScriptPortIsJsError() { + // The JavaScript port reports platform HTML5 and a JS engine stack. + assertEquals(CrashReportPayload.TRACE_JS, + CrashReportPayload.deriveTraceFormat(null, + "at run (http://host/app.js:12:34)\n", "HTML5")); + } + + @Test + void parparVmTextIsRecognized() { + // A native ParparVM-C target (not running on a JVM) with the " at X.Y:N" text. + assertEquals(CrashReportPayload.TRACE_PARPARVM, + CrashReportPayload.deriveTraceFormat(null, + " at com.foo.Bar.baz:42\n at com.foo.Bar.qux:7\n", "ios", false)); + } + + @Test + void jvmMessageWithParparvmShapedLineIsNotParparvm() { + // A real-JVM throwable whose MESSAGE contains a line shaped like a ParparVM frame (" at X.Y:N", + // no parentheses); printStackTrace echoes the message into the raw stack. On a JVM it must stay + // NONE so the server does not fabricate a frame from message text. + String raw = "java.lang.IllegalStateException: bad input:\n at fake.Type.method:12\n"; + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, raw, "Android", true)); + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, raw, "SE", true)); + // A skinless JavaSE desktop app on macOS/Windows reports mac/win yet runs on a JVM -- it must still + // be NONE, not parparvm, even though the name collides with the native C-target names. + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, raw, "mac", true)); + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, raw, "win", true)); + // The same raw text on a NATIVE ParparVM-C target (not on a JVM) IS parparvm-text. + assertEquals(CrashReportPayload.TRACE_PARPARVM, + CrashReportPayload.deriveTraceFormat(null, raw, "win", false)); + } + + @Test + void jvmStackIsNotMislabeledJavaScript() { + // A stackless JVM/Android throwable whose only frames are in its cause: printStackTrace + // produces a tab-indented JVM trace with parentheses. It must NOT be labeled js-error. + String jvm = "java.lang.RuntimeException: boom\n" + + "\tat com.foo.Bar.baz(Bar.java:42)\n" + + "\tat com.foo.Bar.main(Bar.java:7)\n"; + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, jvm, "Android")); + assertEquals(CrashReportPayload.TRACE_NONE, + CrashReportPayload.deriveTraceFormat(null, jvm, "SE")); + } +} diff --git a/maven/pom.xml b/maven/pom.xml index 89fee9c83fa..0de2ce4dbb2 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 @@ -377,7 +383,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..8b04febdc10 100644 --- a/vm/JavaAPI/src/java/lang/Throwable.java +++ b/vm/JavaAPI/src/java/lang/Throwable.java @@ -38,6 +38,9 @@ public class Throwable{ private Throwable cause; private String stack; private java.util.List suppressed; + private StackTraceElement[] parsedStack; + private boolean stackParsed; + private boolean stackReplaced; /** @@ -97,28 +100,182 @@ public java.lang.String getMessage(){ * The format of the backtrace information depends on the implementation. */ public void printStackTrace(){ - System.out.println(stack); + System.out.println(renderedStack()); if (cause != null) { System.out.println("Caused by "); cause.printStackTrace(); } } - + + public void printStackTrace(java.io.PrintStream s) { + s.println(renderedStack()); + if (cause != null) { + s.println("Caused by "); + cause.printStackTrace(s); + } + } + public void printStackTrace(PrintWriter s) { - s.println(stack); + s.println(renderedStack()); if (cause != null) { s.println("Caused by "); cause.printStackTrace(s); } } + + /** + * The text to print for this throwable's own frames. By default this is the native + * pre-rendered stack string. Once setStackTrace() has replaced the frames (an app + * sanitizing or clearing its trace), the native string is stale and no longer matches + * getStackTrace(), so render the replacement frames instead -- in the same + * " at <fqcn>.<method>:<line>" shape parseStackString reads back, so a + * captured rawStack stays consistent with the structured frames. + */ + private String renderedStack() { + if(!stackReplaced) { + return stack; + } + StringBuilder sb = new StringBuilder(); + sb.append(toString()); + if(parsedStack != null) { + for(int i = 0 ; i < parsedStack.length ; i++) { + StackTraceElement e = parsedStack[i]; + sb.append('\n').append(" at ").append(e.getClassName()) + .append('.').append(e.getMethodName()).append(':').append(e.getLineNumber()); + } + } + return sb.toString(); + } public StackTraceElement[] getStackTrace() { - 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; + stackReplaced = 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; } /**