Skip to content

Commit 5832c79

Browse files
committed
Support notarizing macOS applications
Also rework codesigning to follow the same pattern as jpackage, which matches the official guidelines of only explicitly signing executable code (dylibs and executables), properly wrapping the jdk in a macOS bundle, and only attaching entitlements/hardened runtime to executables. This combination of changes ensures that signing works even without preserving filesystem extended attributes, as tends to happen when zipping application bundles, because Mach-O files have signatures embedded in the file contents whereas all other file types have their signatures stored in FS extended attributes. (Non-code files do get signed, but only through the manifest on the bundle itself so the file doesn't need modification.) This has been tested on a clean macOS 13.2.1 VM that has gatekeeper using its default settings. The VM was disconnected from the internet after downloading to validate the notarization and stapling. The user is prompted to confirm opening an application that came from the internet, as one is with any properly signed and notarized application that was downloaded from the internet. fixes #286
1 parent 26a930e commit 5832c79

File tree

6 files changed

+222
-29
lines changed

6 files changed

+222
-29
lines changed

docs/macosx-specific-properties.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
<entitlements>path/to/entitlements.plist</entitlements>
1717
<codesignApp>true|false</codesignApp>
1818
<hardenedCodesign>true|false</hardenedCodesign>
19+
<notarizeApp>true|false</notarizeApp>
20+
<keyChainProfile>xcrun_notarytool_profile_name</keyChainProfile>
1921

2022
<!-- properties used for DMG disk image generation -->
2123
<backgroundImage>path/to/png</backgroundImage>
@@ -62,11 +64,13 @@
6264
## Signing properties
6365

6466
| Property | Mandatory | Default value | Description |
65-
| ------------------ | --------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
67+
|--------------------| --------- |---------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
6668
| `developerId` | :x: | | Signing identity. |
6769
| `entitlements` | :x: | | Path to [entitlements](https://developer.apple.com/documentation/bundleresources/entitlements) file. |
6870
| `codesignApp` | :x: | `true` | If it is set to `false`, generated app will not be codesigned. |
6971
| `hardenedCodesign` | :x: | `true` | If it is set to `true`, enable [hardened runtime](https://developer.apple.com/documentation/security/hardened_runtime) if MacOS version >= 10.13.6. |
72+
| `notarizeApp` | :x: | `false` | If it is set to `true`, generated app will be submitted to apple for notarization and the ticket will be stapled. |
73+
| `keyChainProfile` | :x: | | Profile name originally provided to `xcrun notarytool store-credentials`. Must be set if `notarizeApp` is `true`.
7074
| `macStartup` | :x: | `SCRIPT` | App startup type, using a `SCRIPT` or a binary (compiled version of the script: `UNIVERSAL`, `X86_64` or `ARM64`). |
7175

7276
## DMG generation properties
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package io.github.fvarrui.javapackager.gradle;
2+
3+
import io.github.fvarrui.javapackager.model.LinuxConfig;
4+
import org.gradle.api.file.RegularFileProperty;
5+
import org.gradle.api.provider.ListProperty;
6+
import org.gradle.api.provider.Property;
7+
import org.gradle.api.tasks.Input;
8+
import org.gradle.api.tasks.Optional;
9+
10+
import java.io.File;
11+
import java.util.ArrayList;
12+
import java.util.List;
13+
14+
public abstract class LinuxTaskConfig {
15+
@Input
16+
@Optional
17+
public abstract ListProperty<String> getCategories();
18+
@Input
19+
@Optional
20+
public abstract Property<Boolean> isGenerateDeb();
21+
@Input
22+
@Optional
23+
public abstract Property<Boolean> isGenerateRpm();
24+
@Input
25+
@Optional
26+
public abstract Property<Boolean> isGenerateAppImage();;
27+
@Input
28+
@Optional
29+
public abstract RegularFileProperty getPngFile();
30+
@Input
31+
@Optional
32+
public abstract Property<Boolean> isWrapJar();
33+
34+
public LinuxConfig buildConfig() {
35+
LinuxConfig ret = new LinuxConfig();
36+
ret.setCategories(getCategories().getOrElse(new ArrayList<>()));
37+
ret.setGenerateDeb(isGenerateDeb().getOrElse(true));
38+
ret.setGenerateRpm(isGenerateRpm().getOrElse(true));
39+
ret.setGenerateAppImage(isGenerateAppImage().getOrElse(true));
40+
ret.setPngFile(getPngFile().getAsFile().getOrNull());
41+
ret.setWrapJar(isWrapJar().getOrElse(true));
42+
return ret;
43+
}
44+
}

src/main/java/io/github/fvarrui/javapackager/model/MacConfig.java

+31-3
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ public class MacConfig implements Serializable {
3636
private File provisionProfile;
3737
private File customLauncher;
3838
private File customInfoPlist;
39+
private File customRuntimeInfoPlist;
3940
private boolean codesignApp = true;
41+
private boolean notarizeApp = false;
42+
private String keyChainProfile;
4043
private InfoPlist infoPlist = new InfoPlist();
4144
private boolean hardenedCodesign = true;
4245
private MacStartup macStartup = MacStartup.SCRIPT;
@@ -209,6 +212,14 @@ public void setCustomInfoPlist(File customInfoPlist) {
209212
this.customInfoPlist = customInfoPlist;
210213
}
211214

215+
public File getCustomRuntimeInfoPlist() {
216+
return customRuntimeInfoPlist;
217+
}
218+
219+
public void setCustomRuntimeInfoPlist(File customRuntimeInfoPlist) {
220+
this.customRuntimeInfoPlist = customRuntimeInfoPlist;
221+
}
222+
212223
public File getProvisionProfile() {
213224
return provisionProfile;
214225
}
@@ -233,6 +244,22 @@ public void setCodesignApp(boolean codesignApp) {
233244
this.codesignApp = codesignApp;
234245
}
235246

247+
public boolean isNotarizeApp() {
248+
return notarizeApp;
249+
}
250+
251+
public void setNotarizeApp(boolean notarizeApp) {
252+
this.notarizeApp = notarizeApp;
253+
}
254+
255+
public String getKeyChainProfile() {
256+
return keyChainProfile;
257+
}
258+
259+
public void setKeyChainProfile(String keyChainProfile) {
260+
this.keyChainProfile = keyChainProfile;
261+
}
262+
236263
public InfoPlist getInfoPlist() {
237264
return infoPlist;
238265
}
@@ -266,9 +293,10 @@ public String toString() {
266293
+ ", volumeName=" + volumeName + ", generateDmg=" + generateDmg + ", generatePkg=" + generatePkg
267294
+ ", relocateJar=" + relocateJar + ", appId=" + appId + ", developerId=" + developerId
268295
+ ", entitlements=" + entitlements + ", provisionProfile=" + provisionProfile + ", customLauncher="
269-
+ customLauncher + ", customInfoPlist=" + customInfoPlist + ", codesignApp=" + codesignApp
270-
+ ", infoPlist=" + infoPlist + ", hardenedCodesign=" + hardenedCodesign + ", macStartup=" + macStartup
271-
+ "]";
296+
+ customLauncher + ", customInfoPlist=" + customInfoPlist + ", customRuntimeInfoPlist="
297+
+ customRuntimeInfoPlist + ", codesignApp=" + codesignApp + ", notarizeApp=" + notarizeApp
298+
+ ", keyChainProfile=" + keyChainProfile + ", infoPlist=" + infoPlist + ", hardenedCodesign="
299+
+ hardenedCodesign + ", macStartup=" + macStartup + "]";
272300
}
273301

274302
/**

src/main/java/io/github/fvarrui/javapackager/packagers/MacPackager.java

+117-24
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,19 @@
99

1010
import java.io.File;
1111
import java.io.IOException;
12+
import java.nio.file.FileVisitResult;
13+
import java.nio.file.Files;
14+
import java.nio.file.Path;
15+
import java.nio.file.SimpleFileVisitor;
16+
import java.nio.file.attribute.BasicFileAttributes;
1217
import java.util.ArrayList;
1318
import java.util.Arrays;
1419
import java.util.Collection;
1520
import java.util.List;
1621
import java.util.stream.Collectors;
22+
import java.util.stream.Stream;
23+
import java.util.zip.ZipEntry;
24+
import java.util.zip.ZipOutputStream;
1725

1826
/**
1927
* Packager for MacOS
@@ -25,6 +33,7 @@ public class MacPackager extends Packager {
2533
private File resourcesFolder;
2634
private File javaFolder;
2735
private File macOSFolder;
36+
private File jreBundleFolder;
2837

2938
public File getAppFile() {
3039
return appFile;
@@ -73,7 +82,8 @@ protected void doCreateAppStructure() throws Exception {
7382
// sets common folders
7483
this.executableDestinationFolder = macOSFolder;
7584
this.jarFileDestinationFolder = javaFolder;
76-
this.jreDestinationFolder = new File(contentsFolder, "PlugIns/" + jreDirectoryName + "/Contents/Home");
85+
this.jreBundleFolder = new File(contentsFolder, "PlugIns/" + jreDirectoryName + ".jre");
86+
this.jreDestinationFolder = new File(jreBundleFolder, "Contents/Home");
7787
this.resourcesDestinationFolder = resourcesFolder;
7888

7989
}
@@ -83,6 +93,9 @@ protected void doCreateAppStructure() throws Exception {
8393
*/
8494
@Override
8595
public File doCreateApp() throws Exception {
96+
if(bundleJre) {
97+
processRuntimeInfoPlistFile();
98+
}
8699

87100
// copies jarfile to Java folder
88101
FileUtils.copyFileToFolder(jarFile, javaFolder);
@@ -97,6 +110,8 @@ public File doCreateApp() throws Exception {
97110

98111
codesign();
99112

113+
notarize();
114+
100115
return appFile;
101116
}
102117

@@ -157,6 +172,21 @@ private void processInfoPlistFile() throws Exception {
157172
Logger.info("Info.plist file created in " + infoPlistFile.getAbsolutePath());
158173
}
159174

175+
/**
176+
* Creates and writes the Info.plist inside the JRE if no custom file is specified.
177+
* @throws Exception if anything goes wrong
178+
*/
179+
private void processRuntimeInfoPlistFile() throws Exception {
180+
File infoPlistFile = new File(jreBundleFolder, "Contents/Info.plist");
181+
if(macConfig.getCustomRuntimeInfoPlist() != null && macConfig.getCustomRuntimeInfoPlist().isFile() && macConfig.getCustomRuntimeInfoPlist().canRead()){
182+
FileUtils.copyFileToFile(macConfig.getCustomRuntimeInfoPlist(), infoPlistFile);
183+
} else {
184+
VelocityUtils.render("mac/RuntimeInfo.plist.vtl", infoPlistFile, this);
185+
XMLUtils.prettify(infoPlistFile);
186+
}
187+
Logger.info("RuntimeInfo.plist file created in " + infoPlistFile.getAbsolutePath());
188+
}
189+
160190
private void codesign() throws Exception {
161191
if (!Platform.mac.isCurrentPlatform()) {
162192
Logger.warn("Generated app could not be signed due to current platform is " + Platform.getCurrentPlatform());
@@ -167,6 +197,18 @@ private void codesign() throws Exception {
167197
}
168198
}
169199

200+
private void notarize() throws Exception {
201+
if (!Platform.mac.isCurrentPlatform()) {
202+
Logger.warn("Generated app could not be notarized due to current platform is " + Platform.getCurrentPlatform());
203+
} else if (!getMacConfig().isCodesignApp()) {
204+
Logger.warn("App codesigning disabled. Cannot notarize unsigned app");
205+
} else if (!getMacConfig().isNotarizeApp()) {
206+
Logger.warn("App notarization disabled");
207+
} else {
208+
notarize(this.macConfig.getKeyChainProfile(), this.appFile);
209+
}
210+
}
211+
170212
private void processProvisionProfileFile() throws Exception {
171213
if (macConfig.getProvisionProfile() != null && macConfig.getProvisionProfile().isFile() && macConfig.getProvisionProfile().canRead()) {
172214
// file name must be 'embedded.provisionprofile'
@@ -197,7 +239,7 @@ private void codesign(String developerId, File entitlements, File appFile) throw
197239

198240
entitlements = prepareEntitlementFile(entitlements);
199241

200-
manualDeepSign(appFile, developerId, entitlements);
242+
signAppBundle(appFile, developerId, entitlements);
201243

202244
}
203245

@@ -213,39 +255,50 @@ private File prepareEntitlementFile(File entitlements) throws Exception {
213255
return entitlements;
214256
}
215257

216-
private void manualDeepSign(File appFolder, String developerCertificateName, File entitlements) throws IOException, CommandLineException {
217-
218-
// codesign each file in app
219-
List<Object> findCommandArgs = new ArrayList<>();
220-
findCommandArgs.add(appFolder);
221-
findCommandArgs.add("-depth"); // execute 'codesign' in 'reverse order', i.e., deepest files first
222-
findCommandArgs.add("-type");
223-
findCommandArgs.add("f"); // filter for files only
224-
findCommandArgs.add("-exec");
225-
findCommandArgs.add("codesign");
226-
findCommandArgs.add("-f");
227-
addHardenedCodesign(findCommandArgs);
228-
findCommandArgs.add("-s");
229-
findCommandArgs.add(developerCertificateName);
230-
findCommandArgs.add("--entitlements");
231-
findCommandArgs.add(entitlements);
232-
findCommandArgs.add("{}");
233-
findCommandArgs.add("\\;");
234-
CommandUtils.execute("find", findCommandArgs);
258+
private void signAppBundle(File appFolder, String developerCertificateName, File entitlements) throws IOException, CommandLineException {
259+
// Sign all embedded executables and dynamic libraries
260+
// Structure and order adapted from the JRE's jpackage
261+
try (Stream<Path> stream = Files.walk(appFolder.toPath())) {
262+
stream.filter(p -> Files.isRegularFile(p)
263+
&& (Files.isExecutable(p) || p.toString().endsWith(".dylib"))
264+
&& !(p.toString().contains("dylib.dSYM/Contents"))
265+
&& !(p.equals(this.executable.toPath()))
266+
).forEach(p -> {
267+
if (Files.isSymbolicLink(p)) {
268+
Logger.debug("Skipping signing symlink: " + p);
269+
} else {
270+
try {
271+
codesign(Files.isExecutable(p) ? entitlements : null, developerCertificateName, p.toFile());
272+
} catch (IOException | CommandLineException e) {
273+
throw new RuntimeException(e);
274+
}
275+
}
276+
});
277+
}
278+
279+
// sign the JRE itself after signing all its contents
280+
codesign(developerCertificateName, jreBundleFolder);
235281

236282
// make sure the executable is signed last
237283
codesign(entitlements, developerCertificateName, this.executable);
238284

239285
// finally, sign the top level directory
240286
codesign(entitlements, developerCertificateName, appFolder);
241287
}
288+
289+
private void codesign(String developerCertificateName, File file) throws IOException, CommandLineException {
290+
codesign(null, developerCertificateName, file);
291+
}
242292

243293
private void codesign(File entitlements, String developerCertificateName, File file) throws IOException, CommandLineException {
244294
List<Object> arguments = new ArrayList<>();
245295
arguments.add("-f");
246-
addHardenedCodesign(arguments);
247-
arguments.add("--entitlements");
248-
arguments.add(entitlements);
296+
if(entitlements != null) {
297+
addHardenedCodesign(arguments);
298+
arguments.add("--entitlements");
299+
arguments.add(entitlements);
300+
}
301+
arguments.add("--timestamp");
249302
arguments.add("-s");
250303
arguments.add(developerCertificateName);
251304
arguments.add(file);
@@ -263,4 +316,44 @@ private void addHardenedCodesign(Collection<Object> args){
263316
}
264317
}
265318

319+
private void notarize(String keyChainProfile, File appFile) throws IOException, CommandLineException {
320+
Path zippedApp = null;
321+
try {
322+
zippedApp = zipApp(appFile);
323+
List<Object> notarizeArgs = new ArrayList<>();
324+
notarizeArgs.add("notarytool");
325+
notarizeArgs.add("submit");
326+
notarizeArgs.add(zippedApp.toString());
327+
notarizeArgs.add("--wait");
328+
notarizeArgs.add("--keychain-profile");
329+
notarizeArgs.add(keyChainProfile);
330+
CommandUtils.execute("xcrun", notarizeArgs);
331+
} finally {
332+
if(zippedApp != null) {
333+
Files.deleteIfExists(zippedApp);
334+
}
335+
}
336+
337+
List<Object> stapleArgs = new ArrayList<>();
338+
stapleArgs.add("stapler");
339+
stapleArgs.add("staple");
340+
stapleArgs.add(appFile);
341+
CommandUtils.execute("xcrun", stapleArgs);
342+
}
343+
344+
private Path zipApp(File appFile) throws IOException {
345+
Path zipPath = assetsFolder.toPath().resolve(appFile.getName() + "-notarization.zip");
346+
try(ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipPath))) {
347+
Path sourcePath = appFile.toPath();
348+
Files.walkFileTree(sourcePath, new SimpleFileVisitor<Path>() {
349+
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
350+
zos.putNextEntry(new ZipEntry(sourcePath.getParent().relativize(file).toString()));
351+
Files.copy(file, zos);
352+
zos.closeEntry();
353+
return FileVisitResult.CONTINUE;
354+
}
355+
});
356+
}
357+
return zipPath;
358+
}
266359
}

src/main/resources/mac/Info.plist.vtl

+1-1
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@
128128
<dict>
129129
#if ($info.bundleJre)
130130
<key>JAVA_HOME</key>
131-
<string>Contents/PlugIns/${info.jreDirectoryName}/Contents/Home</string>
131+
<string>Contents/PlugIns/${info.jreDirectoryName}.jre/Contents/Home</string>
132132
#end
133133
#if($info.envPath)
134134
<key>PATH</key>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>CFBundleDevelopmentRegion</key>
6+
<string>English</string>
7+
<key>CFBundleExecutable</key>
8+
<string></string>
9+
<key>CFBundleIdentifier</key>
10+
<string>${info.macConfig.appId}.runtime.java</string>
11+
<key>CFBundleInfoDictionaryVersion</key>
12+
<string>7.0</string>
13+
<key>CFBundleName</key>
14+
<string>Java Runtime Image</string>
15+
<key>CFBundlePackageType</key>
16+
<string>BNDL</string>
17+
<key>CFBundleShortVersionString</key>
18+
<string>${info.version}</string>
19+
<key>CFBundleSignature</key>
20+
<string>????</string>
21+
<key>CFBundleVersion</key>
22+
<string>${info.version}</string>
23+
</dict>
24+
</plist>

0 commit comments

Comments
 (0)