jdk-24/test/jdk/sun/security/tools/jarsigner/PreserveRawManifestEntryAndDigest.java

1015 lines
44 KiB
Java

/*
* Copyright (c) 2019, 2024, Oracle 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.
*
* 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 Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
import java.io.ByteArrayOutputStream;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Collections;
import java.util.stream.Collectors;
import java.util.function.Function;
import java.util.jar.Attributes;
import java.util.jar.Attributes.Name;
import java.util.jar.Manifest;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.zip.ZipFile;
import java.util.zip.ZipEntry;
import jdk.security.jarsigner.JarSigner;
import jdk.test.lib.process.OutputAnalyzer;
import jdk.test.lib.SecurityTools;
import jdk.test.lib.util.JarUtils;
import org.testng.annotations.BeforeTest;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.testng.Assert.*;
/**
* @test
* @bug 8217375 8267319
* @library /test/lib
* @modules jdk.jartool/sun.security.tools.jarsigner
* @run testng/timeout=1200 PreserveRawManifestEntryAndDigest
* @summary Verifies that JarSigner does not change manifest file entries
* in a binary view if its decoded map view does not change so that an
* unchanged (individual section) entry continues to produce the same digest.
* The same manifest (in terms of {@link Manifest#equals}) could be encoded
* with different line breaks ("{@code \r}", "{@code \n}", or "{@code \r\n}")
* or with arbitrary line break positions (as is also the case with the change
* of the default line width in JDK 11, bug 6372077) resulting in a different
* digest for manifest entries with identical values.
*
* <p>See also:<ul>
* <li>{@code oldsig.sh} and {@code diffend.sh} in
* {@code /test/jdk/sun/security/tools/jarsigner/}</li>
* <li>{@code Compatibility.java} in
* {@code /test/jdk/sun/security/tools/jarsigner/compatibility}</li>
* <li>{@link ReproduceRaw} testing relevant
* {@sun.security.util.ManifestDigester} api in much more detail</li>
* </ul>
*/
/*
* debug with "run testng" += "/othervm -Djava.security.debug=jar"
*/
public class PreserveRawManifestEntryAndDigest {
static final String KEYSTORE_FILENAME = "test.jks";
static final String FILENAME_INITIAL_CONTENTS = "initial-contents";
static final String FILENAME_UPDATED_CONTENTS = "updated-contents";
private static final String DEF_DIGEST_STR =
JarSigner.Builder.getDefaultDigestAlgorithm() + "-Digest";
/**
* @see sun.security.tools.jarsigner.Main#run
*/
static final int NOTSIGNEDBYALIASORALIASNOTINSTORE = 32;
@BeforeTest
public void prepareContentFiles() throws IOException {
Files.write(Path.of(FILENAME_INITIAL_CONTENTS),
FILENAME_INITIAL_CONTENTS.getBytes(UTF_8));
Files.write(Path.of(FILENAME_UPDATED_CONTENTS),
FILENAME_UPDATED_CONTENTS.getBytes(UTF_8));
}
@BeforeTest
public void prepareCertificates() throws Exception {
SecurityTools.keytool("-genkeypair -keyalg DSA -keystore "
+ KEYSTORE_FILENAME + " -storepass changeit -keypass changeit"
+ " -alias a -dname CN=A").shouldHaveExitValue(0);
SecurityTools.keytool("-genkeypair -keyalg DSA -keystore "
+ KEYSTORE_FILENAME + " -storepass changeit -keypass changeit"
+ " -alias b -dname CN=B").shouldHaveExitValue(0);
}
static class TeeOutputStream extends FilterOutputStream {
final OutputStream tee; // don't flush or close
public TeeOutputStream(OutputStream out, OutputStream tee) {
super(out);
this.tee = tee;
}
@Override
public void write(int b) throws IOException {
super.write(b);
tee.write(b);
}
}
/**
* runs jarsigner in its own child process and captures exit code and the
* output of stdout and stderr, as opposed to {@link #karsignerMain}
*/
OutputAnalyzer jarsignerProc(String args) throws Exception {
long start = System.currentTimeMillis();
try {
return SecurityTools.jarsigner(args);
} finally {
long end = System.currentTimeMillis();
System.out.println("jarsignerProc duration [ms]: " + (end - start));
}
}
/**
* assume non-zero exit code would call System.exit but is faster than
* {@link #jarsignerProc}
*/
void jarsignerMain(String args) throws Exception {
long start = System.currentTimeMillis();
try {
new sun.security.tools.jarsigner.Main().run(args.split("\\s+"));
} finally {
long end = System.currentTimeMillis();
System.out.println("jarsignerMain duration [ms]: " + (end - start));
}
}
void createSignedJarA(String jarFilename, Manifest manifest,
String additionalJarsignerOptions, String dummyContentsFilename)
throws Exception {
JarUtils.createJarFile(Path.of(jarFilename), manifest, Path.of("."),
dummyContentsFilename == null ? new Path[]{} :
new Path[] { Path.of(dummyContentsFilename) });
jarsignerMain("-keystore " + KEYSTORE_FILENAME + " -storepass changeit"
+ (additionalJarsignerOptions == null ? "" :
" " + additionalJarsignerOptions) +
" -verbose -debug " + jarFilename + " a");
Utils.echoManifest(Utils.readJarManifestBytes(
jarFilename), "original signed jar by signer a");
// check assumption that jar is valid at this point
jarsignerMain("-verify -keystore " + KEYSTORE_FILENAME +
" -storepass changeit -verbose -debug " + jarFilename + " a");
}
void manipulateManifestSignAgainA(String srcJarFilename, String tmpFilename,
String dstJarFilename, String additionalJarsignerOptions,
Function<Manifest, byte[]> manifestManipulation) throws Exception {
Manifest mf;
try (JarFile jar = new JarFile(srcJarFilename)) {
mf = jar.getManifest();
}
byte[] manipulatedManifest = manifestManipulation.apply(mf);
Utils.echoManifest(manipulatedManifest, "manipulated manifest");
JarUtils.updateJar(srcJarFilename, tmpFilename, Map.of(
JarFile.MANIFEST_NAME, manipulatedManifest,
// add a fake sig-related file to trigger wasSigned in JarSigner
"META-INF/.SF", Name.SIGNATURE_VERSION + ": 1.0\r\n"));
jarsignerMain("-keystore " + KEYSTORE_FILENAME + " -storepass changeit"
+ (additionalJarsignerOptions == null ? "" :
" " + additionalJarsignerOptions) +
" -verbose -debug " + tmpFilename + " a");
// remove META-INF/.SF from signed jar again which would not validate
JarUtils.updateJar(tmpFilename, dstJarFilename,
Map.of("META-INF/.SF", false));
Utils.echoManifest(Utils.readJarManifestBytes(
dstJarFilename), "manipulated jar signed again with a");
// check assumption that jar is valid at this point
jarsignerMain("-verify -keystore " + KEYSTORE_FILENAME + " " +
"-storepass changeit -verbose -debug " + dstJarFilename + " a");
}
OutputAnalyzer signB(String jarFilename, String additionalJarsignerOptions,
int updateExitCodeVerifyA) throws Exception {
jarsignerMain("-keystore " + KEYSTORE_FILENAME + " -storepass changeit"
+ (additionalJarsignerOptions == null ? "" :
" " + additionalJarsignerOptions)
+ " -verbose -debug " + jarFilename + " b");
Utils.echoManifest(Utils.readJarManifestBytes(
jarFilename), "signed again with signer b");
// check assumption that jar is valid at this point with any alias
jarsignerMain("-verify -strict -keystore " + KEYSTORE_FILENAME +
" -storepass changeit -debug -verbose " + jarFilename);
// check assumption that jar is valid at this point with b just signed
jarsignerMain("-verify -strict -keystore " + KEYSTORE_FILENAME +
" -storepass changeit -debug -verbose " + jarFilename + " b");
// return result of verification of signature by a before update
return jarsignerProc("-verify -strict " + "-keystore " +
KEYSTORE_FILENAME + " -storepass changeit " + "-debug " +
"-verbose " + jarFilename + " a")
.shouldHaveExitValue(updateExitCodeVerifyA);
}
String[] fromFirstToSecondEmptyLine(String[] lines) {
int from = 0;
for (int i = 0; i < lines.length; i++) {
if ("".equals(lines[i])) {
from = i + 1;
break;
}
}
int to = lines.length - 1;
for (int i = from; i < lines.length; i++) {
if ("".equals(lines[i])) {
to = i - 1;
break;
}
}
return Arrays.copyOfRange(lines, from, to + 1);
}
/**
* @see "concise_jarsigner.sh"
*/
String[] getExpectedJarSignerOutputUpdatedContentNotValidatedBySignerA(
String firstAddedFilename, String secondAddedFilename) {
final String TS = ".{28,34}"; // matches a timestamp
List<String> expLines = new ArrayList<>();
expLines.add("s k *\\d+ " + TS + " META-INF/MANIFEST[.]MF");
expLines.add(" *\\d+ " + TS + " META-INF/B[.]SF");
expLines.add(" *\\d+ " + TS + " META-INF/B[.]DSA");
expLines.add(" *\\d+ " + TS + " META-INF/A[.]SF");
expLines.add(" *\\d+ " + TS + " META-INF/A[.]DSA");
if (firstAddedFilename != null) {
expLines.add("smk *\\d+ " + TS + " " + firstAddedFilename);
}
if (secondAddedFilename != null) {
expLines.add("smkX *\\d+ " + TS + " " + secondAddedFilename);
}
return expLines.toArray(new String[expLines.size()]);
}
void assertMatchByLines(String[] actLines, String[] expLines) {
for (int i = 0; i < actLines.length && i < expLines.length; i++) {
String actLine = actLines[i];
String expLine = expLines[i];
assertTrue(actLine.matches("^" + expLine + "$"),
"\"" + actLine + "\" should have matched \"" + expLine + "\"");
}
assertEquals(actLines.length, expLines.length);
}
String test(String name, Function<Manifest, byte[]> mm) throws Exception {
return test(name, FILENAME_INITIAL_CONTENTS, FILENAME_UPDATED_CONTENTS,
mm);
}
String test(String name,
String firstAddedFilename, String secondAddedFilename,
Function<Manifest, byte[]> mm) throws Exception {
return test(name, firstAddedFilename, secondAddedFilename, mm, null,
true, true);
}
/**
* Essentially, creates a first signed JAR file with a single contained
* file or without and a manipulation applied to its manifest signed by
* signer a and then signes it again with a different signer b.
* The jar file is signed twice with signer a in order to make the digests
* available to the manipulation function that might use it.
*
* @param name Prefix for the JAR filenames used throughout the test.
* @param firstAddedFilename Name of a file to add before the first
* signature by signer a or null. The name will also become the contents
* if not null.
* @param secondAddedFilename Name of a file to add after the first
* signature by signer a and before the second signature by signer b or
* null. The name will also become the contents if not null.
* @param manifestManipulation A callback hook to manipulate the manifest
* after the first signature by signer a and before the second signature by
* signer b.
* @param digestalg The digest algorithm name to be used or null for
* default.
* @param assertMainAttrsDigestsUnchanged Assert that the
* manifest main attributes digests have not changed. In any case the test
* also checks that the digests are still valid whether changed or not
* by {@code jarsigner -verify} which might use
* {@link ManifestDigester.Entry#digestWorkaround}
* @param assertFirstAddedFileDigestsUnchanged Assert that the
* digest of the file firstAddedFilename has not changed with the second
* signature. In any case the test checks that the digests are valid whether
* changed or not by {@code jarsigner -verify} which might use
* {@link ManifestDigester.Entry#digestWorkaround}
* @return The name of the resulting JAR file that has passed the common
* assertions ready for further examination
*/
String test(String name,
String firstAddedFilename, String secondAddedFilename,
Function<Manifest, byte[]> manifestManipulation,
String digestalg, boolean assertMainAttrsDigestsUnchanged,
boolean assertFirstAddedFileDigestsUnchanged)
throws Exception {
String digOpts = (digestalg != null ? "-digestalg " + digestalg : "");
String jarFilename1 = "test-" + name + "-step1.jar";
createSignedJarA(jarFilename1,
/* no manifest will let jarsigner create a default one */ null,
digOpts, firstAddedFilename);
// manipulate the manifest, write it back, and sign the jar again with
// the same certificate a as before overwriting the first signature
String jarFilename2 = "test-" + name + "-step2.jar";
String jarFilename3 = "test-" + name + "-step3.jar";
manipulateManifestSignAgainA(jarFilename1, jarFilename2, jarFilename3,
digOpts, manifestManipulation);
// add another file, sign it with the other certificate, and verify it
String jarFilename4 = "test-" + name + "-step4.jar";
JarUtils.updateJar(jarFilename3, jarFilename4,
secondAddedFilename != null ?
Map.of(secondAddedFilename, secondAddedFilename)
: Collections.EMPTY_MAP);
OutputAnalyzer o = signB(jarFilename4, digOpts,
secondAddedFilename != null ? NOTSIGNEDBYALIASORALIASNOTINSTORE : 0);
// check that secondAddedFilename is the only entry which is not signed
// by signer with alias "a" unless secondAddedFilename is null
assertMatchByLines(
fromFirstToSecondEmptyLine(o.getStdout().split("\\R")),
getExpectedJarSignerOutputUpdatedContentNotValidatedBySignerA(
firstAddedFilename, secondAddedFilename));
// double-check reading the files with a verifying JarFile
try (JarFile jar = new JarFile(jarFilename4, true)) {
if (firstAddedFilename != null) {
JarEntry je1 = jar.getJarEntry(firstAddedFilename);
jar.getInputStream(je1).readAllBytes();
assertTrue(je1.getCodeSigners().length > 0);
}
if (secondAddedFilename != null) {
JarEntry je2 = jar.getJarEntry(secondAddedFilename);
jar.getInputStream(je2).readAllBytes();
assertTrue(je2.getCodeSigners().length > 0);
}
}
// assert that the signature of firstAddedFilename signed by signer
// with alias "a" is not lost and its digest remains the same
try (ZipFile zip = new ZipFile(jarFilename4)) {
ZipEntry ea = zip.getEntry("META-INF/A.SF");
Manifest sfa = new Manifest(zip.getInputStream(ea));
ZipEntry eb = zip.getEntry("META-INF/B.SF");
Manifest sfb = new Manifest(zip.getInputStream(eb));
if (assertMainAttrsDigestsUnchanged) {
String mainAttrsDigKey = (digestalg != null ?
(digestalg + "-Digest") : DEF_DIGEST_STR) +
"-Manifest-Main-Attributes";
assertEquals(sfa.getMainAttributes().getValue(mainAttrsDigKey),
sfb.getMainAttributes().getValue(mainAttrsDigKey));
}
if (assertFirstAddedFileDigestsUnchanged) {
assertEquals(sfa.getAttributes(firstAddedFilename),
sfb.getAttributes(firstAddedFilename));
}
}
return jarFilename4;
}
/**
* Test that signing a jar with manifest entries with arbitrary line break
* positions in individual section headers does not destroy an existing
* signature<ol>
* <li>create two self-signed certificates</li>
* <li>sign a jar with at least one non-META-INF file in it with a JDK
* before 11 or place line breaks not at 72 bytes in an individual section
* header</li>
* <li>add a new file to the jar</li>
* <li>sign the jar with a JDK 11, 12, or 13 with bug 8217375 not yet
* resolved with a different signer</li>
* </ol>&rarr; first signature will not validate anymore even though it
* should.
*/
@Test
public void arbitraryLineBreaksSectionName() throws Exception {
test("arbitraryLineBreaksSectionName", m -> {
return (
Name.MANIFEST_VERSION + ": 1.0\r\n" +
"Created-By: " +
m.getMainAttributes().getValue("Created-By") + "\r\n" +
"\r\n" +
"Name: Test\r\n" +
" -\r\n" +
" Section\r\n" +
"Key: Value \r\n" +
"\r\n" +
"Name: " + FILENAME_INITIAL_CONTENTS.substring(0, 1) + "\r\n" +
" " + FILENAME_INITIAL_CONTENTS.substring(1, 8) + "\r\n" +
" " + FILENAME_INITIAL_CONTENTS.substring(8) + "\r\n" +
DEF_DIGEST_STR + ": " +
m.getAttributes(FILENAME_INITIAL_CONTENTS)
.getValue(DEF_DIGEST_STR) + "\r\n" +
"\r\n"
).getBytes(UTF_8);
});
}
/**
* Test that signing a jar with manifest entries with arbitrary line break
* positions in individual section headers does not destroy an existing
* signature<ol>
* <li>create two self-signed certificates</li>
* <li>sign a jar with at least one non-META-INF file in it with a JDK
* before 11 or place line breaks not at 72 bytes in an individual section
* header</li>
* <li>add a new file to the jar</li>
* <li>sign the jar with a JDK 11 or 12 with a different signer</li>
* </ol>&rarr; first signature will not validate anymore even though it
* should.
*/
@Test
public void arbitraryLineBreaksHeader() throws Exception {
test("arbitraryLineBreaksHeader", m -> {
String digest = m.getAttributes(FILENAME_INITIAL_CONTENTS)
.getValue(DEF_DIGEST_STR);
return (
Name.MANIFEST_VERSION + ": 1.0\r\n" +
"Created-By: " +
m.getMainAttributes().getValue("Created-By") + "\r\n" +
"\r\n" +
"Name: Test-Section\r\n" +
"Key: Value \r\n" +
" with\r\n" +
" strange \r\n" +
" line breaks.\r\n" +
"\r\n" +
"Name: " + FILENAME_INITIAL_CONTENTS + "\r\n" +
DEF_DIGEST_STR + ": " + digest.substring(0, 11) + "\r\n" +
" " + digest.substring(11) + "\r\n" +
"\r\n"
).getBytes(UTF_8);
});
}
/**
* Pre JDK 11, {@link Manifest#write(OutputStream)} would inject
* line breaks after 70 bytes instead of 72 bytes as mandated by
* the JAR File Specification.
*
* This method injects line breaks after 70 bytes to simulate pre
* JDK 11 manifests.
*/
static void injectLineBreaksAt70Bytes(StringBuffer line) {
int length = line.length();
if (length > 72) {
int index = 70;
while (index < length - 2) {
line.insert(index, "\r\n ");
index += 72;
length += 3;
}
}
return;
}
/**
* Test that signing a jar with manifest entries with line breaks at
* position where Manifest would not place them now anymore (72 instead of
* 70 bytes after line starts) does not destroy an existing signature<ol>
* <li>create two self-signed certificates</li>
* <li>simulate a manifest as it would have been written by a JDK before 11
* by re-positioning line breaks at 70 bytes (which makes a difference by
* digests that grow headers longer than 70 characters such as SHA-512 as
* opposed to default SHA-384, long file names, or manual editing)</li>
* <li>add a new file to the jar</li>
* <li>sign the jar with a JDK 11 or 12 with a different signer</li>
* </ol><p>&rarr;
* The first signature will not validate anymore even though it should.
*/
public void lineWidth70(String name, String digestalg) throws Exception {
Files.write(Path.of(name), name.getBytes(UTF_8));
test(name, name, FILENAME_UPDATED_CONTENTS, m -> {
// force a line break with a header exceeding line width limit
m.getEntries().put("Test-Section", new Attributes());
m.getAttributes("Test-Section").put(
Name.IMPLEMENTATION_VERSION, "1" + "0".repeat(100));
StringBuilder sb = new StringBuilder();
StringBuffer[] buf = new StringBuffer[] { null };
manifestToString(m).lines().forEach(line -> {
if (line.startsWith(" ")) {
buf[0].append(line.substring(1));
} else {
if (buf[0] != null) {
injectLineBreaksAt70Bytes(buf[0]);
sb.append(buf[0].toString());
sb.append("\r\n");
}
buf[0] = new StringBuffer();
buf[0].append(line);
}
});
injectLineBreaksAt70Bytes(buf[0]);
sb.append(buf[0].toString());
sb.append("\r\n");
return sb.toString().getBytes(UTF_8);
}, digestalg, false, false);
}
@Test
public void lineWidth70Filename() throws Exception {
lineWidth70(
"lineWidth70".repeat(6) /* 73 chars total with "Name: " */, null);
}
@Test
public void lineWidth70Digest() throws Exception {
lineWidth70("lineWidth70digest", "SHA-512");
}
/**
* Test that signing a jar with a manifest with line delimiter other than
* "{@code \r\n}" does not destroy an existing signature<ol>
* <li>create two self-signed certificates</li>
* <li>sign a jar with at least one non-META-INF file in it</li>
* <li>extract the manifest, and change its line delimiters
* (for example dos2unix)</li>
* <li>update the jar with the updated manifest</li>
* <li>sign it again with the same signer as before</li>
* <li>add a new file to the jar</li>
* <li>sign the jar with a JDK before 13 with a different signer<li>
* </ol><p>&rarr;
* The first signature will not validate anymore even though it should.
*/
public void lineBreak(String lineBreak) throws Exception {
test("lineBreak" + byteArrayToIntList(lineBreak.getBytes(UTF_8)).stream
().map(i -> "" + i).collect(Collectors.joining("")), m -> {
StringBuilder sb = new StringBuilder();
manifestToString(m).lines().forEach(l -> {
sb.append(l);
sb.append(lineBreak);
});
return sb.toString().getBytes(UTF_8);
});
}
@Test
public void lineBreakCr() throws Exception {
lineBreak("\r");
}
@Test
public void lineBreakLf() throws Exception {
lineBreak("\n");
}
@Test
public void lineBreakCrLf() throws Exception {
lineBreak("\r\n");
}
@Test
public void testAdjacentRepeatedSection() throws Exception {
test("adjacent", m -> {
return (manifestToString(m) +
"Name: " + FILENAME_INITIAL_CONTENTS + "\r\n" +
"Foo: Bar\r\n" +
"\r\n"
).getBytes(UTF_8);
});
}
@Test
public void testIntermittentRepeatedSection() throws Exception {
test("intermittent", m -> {
return (manifestToString(m) +
"Name: don't know\r\n" +
"Foo: Bar\r\n" +
"\r\n" +
"Name: " + FILENAME_INITIAL_CONTENTS + "\r\n" +
"Foo: Bar\r\n" +
"\r\n"
).getBytes(UTF_8);
});
}
@Test
public void testNameImmediatelyContinued() throws Exception {
test("testNameImmediatelyContinued", m -> {
// places a continuation line break and space at the first allowed
// position after ": " and before the first character of the value
return (manifestToString(m).replaceAll(FILENAME_INITIAL_CONTENTS,
"\r\n " + FILENAME_INITIAL_CONTENTS + "\r\nFoo: Bar")
).getBytes(UTF_8);
});
}
/*
* "malicious" '\r' after continuation line continued
*/
@Test
public void testNameContinuedContinuedWithCr() throws Exception {
test("testNameContinuedContinuedWithCr", m -> {
return (manifestToString(m).replaceAll(FILENAME_INITIAL_CONTENTS,
FILENAME_INITIAL_CONTENTS.substring(0, 1) + "\r\n " +
FILENAME_INITIAL_CONTENTS.substring(1, 4) + "\r " +
FILENAME_INITIAL_CONTENTS.substring(4) + "\r\n" +
"Foo: Bar")
).getBytes(UTF_8);
});
}
/*
* "malicious" '\r' after continued continuation line
*/
@Test
public void testNameContinuedContinuedEndingWithCr() throws Exception {
test("testNameContinuedContinuedEndingWithCr", m -> {
return (manifestToString(m).replaceAll(FILENAME_INITIAL_CONTENTS,
FILENAME_INITIAL_CONTENTS.substring(0, 1) + "\r\n " +
FILENAME_INITIAL_CONTENTS.substring(1, 4) + "\r\n " +
FILENAME_INITIAL_CONTENTS.substring(4) + "\r" + // no '\n'
"Foo: Bar")
).getBytes(UTF_8);
});
}
@DataProvider(name = "trailingSeqParams", parallel = true)
public static Object[][] trailingSeqParams() {
return new Object[][] {
{""},
{"\r"},
{"\n"},
{"\r\n"},
{"\r\r"},
{"\n\n"},
{"\n\r"},
{"\r\r\r"},
{"\r\r\n"},
{"\r\n\r"},
{"\r\n\n"},
{"\n\r\r"},
{"\n\r\n"},
{"\n\n\r"},
{"\n\n\n"},
{"\r\r\r\n"},
{"\r\r\n\r"},
{"\r\r\n\n"},
{"\r\n\r\r"},
{"\r\n\r\n"},
{"\r\n\n\r"},
{"\r\n\n\n"},
{"\n\r\r\n"},
{"\n\r\n\r"},
{"\n\r\n\n"},
{"\n\n\r\n"},
{"\r\r\n\r\n"},
{"\r\n\r\r\n"},
{"\r\n\r\n\r"},
{"\r\n\r\n\n"},
{"\r\n\n\r\n"},
{"\n\r\n\r\n"},
{"\r\n\r\n\r\n"},
{"\r\n\r\n\r\n\r\n"}
};
}
boolean isSufficientSectionDelimiter(String trailingSeq) {
if (trailingSeq.length() < 2) return false;
if (trailingSeq.startsWith("\r\n")) {
trailingSeq = trailingSeq.substring(2);
} else if (trailingSeq.startsWith("\r") ||
trailingSeq.startsWith("\n")) {
trailingSeq = trailingSeq.substring(1);
} else {
return false;
}
if (trailingSeq.startsWith("\r\n")) {
return true;
} else if (trailingSeq.startsWith("\r") ||
trailingSeq.startsWith("\n")) {
return true;
}
return false;
}
Function<Manifest, byte[]> replaceTrailingLineBreaksManipulation(
String trailingSeq) {
return m -> {
StringBuilder sb = new StringBuilder(manifestToString(m));
// cut off default trailing line break characters
while ("\r\n".contains(sb.substring(sb.length() - 1))) {
sb.deleteCharAt(sb.length() - 1);
}
// and instead add another trailing sequence
sb.append(trailingSeq);
return sb.toString().getBytes(UTF_8);
};
}
boolean abSigFilesEqual(String jarFilename,
Function<Manifest,Object> getter) throws IOException {
try (ZipFile zip = new ZipFile(jarFilename)) {
ZipEntry ea = zip.getEntry("META-INF/A.SF");
Manifest sfa = new Manifest(zip.getInputStream(ea));
ZipEntry eb = zip.getEntry("META-INF/B.SF");
Manifest sfb = new Manifest(zip.getInputStream(eb));
return getter.apply(sfa).equals(getter.apply(sfb));
}
}
/**
* Create a signed JAR file with a strange sequence of line breaks after
* the main attributes and no individual section and hence no file contained
* within the JAR file in order not to produce an individual section,
* then add no other file and sign it with a different signer.
* The manifest is not expected to be changed during the second signature.
*/
@Test(dataProvider = "trailingSeqParams")
public void emptyJarTrailingSeq(String trailingSeq) throws Exception {
String trailingSeqEscaped = byteArrayToIntList(trailingSeq.getBytes(
UTF_8)).stream().map(i -> "" + i).collect(Collectors.joining(""));
System.out.println("trailingSeq = " + trailingSeqEscaped);
if (trailingSeq.isEmpty()) {
return; // invalid manifest without trailing line break
}
test("emptyJarTrailingSeq" + trailingSeqEscaped, null, null,
replaceTrailingLineBreaksManipulation(trailingSeq));
// test called above already asserts by default that the main attributes
// digests have not changed.
}
/**
* Create a signed JAR file with a strange sequence of line breaks after
* the main attributes and no individual section and hence no file contained
* within the JAR file in order not to produce an individual section,
* then add another file and sign it with a different signer so that the
* originally trailing sequence after the main attributes might have to be
* completed to a full section delimiter or reproduced only partially
* before the new individual section with the added file digest can be
* appended. The main attributes digests are expected to change if the
* first signed trailing sequence did not contain a blank line and are not
* expected to change if superfluous parts of the trailing sequence were
* not reproduced. All digests are expected to validate either with digest
* or with digestWorkaround.
*/
@Test(dataProvider = "trailingSeqParams")
public void emptyJarTrailingSeqAddFile(String trailingSeq) throws Exception{
String trailingSeqEscaped = byteArrayToIntList(trailingSeq.getBytes(
UTF_8)).stream().map(i -> "" + i).collect(Collectors.joining(""));
System.out.println("trailingSeq = " + trailingSeqEscaped);
if (!isSufficientSectionDelimiter(trailingSeq)) {
return; // invalid manifest without trailing blank line
}
boolean expectUnchangedDigests =
isSufficientSectionDelimiter(trailingSeq);
System.out.println("expectUnchangedDigests = " + expectUnchangedDigests);
String jarFilename = test("emptyJarTrailingSeqAddFile" +
trailingSeqEscaped, null, FILENAME_UPDATED_CONTENTS,
replaceTrailingLineBreaksManipulation(trailingSeq),
null, expectUnchangedDigests, false);
// Check that the digests have changed only if another line break had
// to be added before a new individual section. That both also are valid
// with either digest or digestWorkaround has been checked by test
// before.
assertEquals(abSigFilesEqual(jarFilename, sf -> sf.getMainAttributes()
.getValue(DEF_DIGEST_STR + "-Manifest-Main-Attributes")),
expectUnchangedDigests);
}
/**
* Create a signed JAR file with a strange sequence of line breaks after
* the only individual section holding the digest of the only file contained
* within the JAR file,
* then add no other file and sign it with a different signer.
* The manifest is expected to be changed during the second signature only
* by removing superfluous line break characters which are not digested
* and the manifest entry digest is expected not to change.
* The individual section is expected to be reproduced without additional
* line breaks even if the trailing sequence does not properly delimit
* another section.
*/
@Test(dataProvider = "trailingSeqParams")
public void singleIndividualSectionTrailingSeq(String trailingSeq)
throws Exception {
String trailingSeqEscaped = byteArrayToIntList(trailingSeq.getBytes(
UTF_8)).stream().map(i -> "" + i).collect(Collectors.joining(""));
System.out.println("trailingSeq = " + trailingSeqEscaped);
if (trailingSeq.isEmpty()) {
return; // invalid manifest without trailing line break
}
String jarFilename = test("singleIndividualSectionTrailingSeq"
+ trailingSeqEscaped, FILENAME_INITIAL_CONTENTS, null,
replaceTrailingLineBreaksManipulation(trailingSeq));
assertTrue(abSigFilesEqual(jarFilename, sf -> sf.getAttributes(
FILENAME_INITIAL_CONTENTS).getValue(DEF_DIGEST_STR)));
}
/**
* Create a signed JAR file with a strange sequence of line breaks after
* the first individual section holding the digest of the only file
* contained within the JAR file and a second individual section with the
* same name to be both digested into the same entry digest,
* then add no other file and sign it with a different signer.
* The manifest is expected to be changed during the second signature
* by removing superfluous line break characters which are not digested
* anyway or if the trailingSeq is not a sufficient delimiter that both
* intially provided sections are treated as only one which is maybe not
* perfect but does at least not result in an invalid signed jar file.
*/
@Test(dataProvider = "trailingSeqParams")
public void firstIndividualSectionTrailingSeq(String trailingSeq)
throws Exception {
String trailingSeqEscaped = byteArrayToIntList(trailingSeq.getBytes(
UTF_8)).stream().map(i -> "" + i).collect(Collectors.joining(""));
System.out.println("trailingSeq = " + trailingSeqEscaped);
String jarFilename;
jarFilename = test("firstIndividualSectionTrailingSeq"
+ trailingSeqEscaped, FILENAME_INITIAL_CONTENTS, null, m -> {
StringBuilder sb = new StringBuilder(manifestToString(m));
// cut off default trailing line break characters
while ("\r\n".contains(sb.substring(sb.length() - 1))) {
sb.deleteCharAt(sb.length() - 1);
}
// and instead add another trailing sequence
sb.append(trailingSeq);
// now add another section with the same name assuming sb
// already contains one entry for FILENAME_INITIAL_CONTENTS
sb.append("Name: " + FILENAME_INITIAL_CONTENTS + "\r\n");
sb.append("Foo: Bar\r\n");
sb.append("\r\n");
return sb.toString().getBytes(UTF_8);
});
assertTrue(abSigFilesEqual(jarFilename, sf -> sf.getAttributes(
FILENAME_INITIAL_CONTENTS).getValue(DEF_DIGEST_STR)));
}
/**
* Create a signed JAR file with two individual sections for the same
* contained file (corresponding by name) the first of which properly
* delimited and the second of which followed by a strange sequence of
* line breaks both digested into the same entry digest,
* then add no other file and sign it with a different signer.
* The manifest is expected to be changed during the second signature
* by removing superfluous line break characters which are not digested
* anyway.
*/
@Test(dataProvider = "trailingSeqParams")
public void secondIndividualSectionTrailingSeq(String trailingSeq)
throws Exception {
String trailingSeqEscaped = byteArrayToIntList(trailingSeq.getBytes(
UTF_8)).stream().map(i -> "" + i).collect(Collectors.joining(""));
System.out.println("trailingSeq = " + trailingSeqEscaped);
String jarFilename = test("secondIndividualSectionTrailingSeq" +
trailingSeqEscaped, FILENAME_INITIAL_CONTENTS, null, m -> {
StringBuilder sb = new StringBuilder(manifestToString(m));
sb.append("Name: " + FILENAME_INITIAL_CONTENTS + "\r\n");
sb.append("Foo: Bar");
sb.append(trailingSeq);
return sb.toString().getBytes(UTF_8);
});
assertTrue(abSigFilesEqual(jarFilename, sf -> sf.getAttributes(
FILENAME_INITIAL_CONTENTS).getValue(DEF_DIGEST_STR)));
}
/**
* Create a signed JAR file with a strange sequence of line breaks after
* the only individual section holding the digest of the only file contained
* within the JAR file,
* then add another file and sign it with a different signer.
* The manifest is expected to be changed during the second signature by
* removing superfluous line break characters which are not digested
* anyway or adding another line break to complete to a proper section
* delimiter blank line.
* The first file entry digest is expected to change only if another
* line break has been added.
*/
@Test(dataProvider = "trailingSeqParams")
public void singleIndividualSectionTrailingSeqAddFile(String trailingSeq)
throws Exception {
String trailingSeqEscaped = byteArrayToIntList(trailingSeq.getBytes(
UTF_8)).stream().map(i -> "" + i).collect(Collectors.joining(""));
System.out.println("trailingSeq = " + trailingSeqEscaped);
if (!isSufficientSectionDelimiter(trailingSeq)) {
return; // invalid manifest without trailing blank line
}
String jarFilename = test("singleIndividualSectionTrailingSeqAddFile"
+ trailingSeqEscaped,
FILENAME_INITIAL_CONTENTS, FILENAME_UPDATED_CONTENTS,
replaceTrailingLineBreaksManipulation(trailingSeq),
null, true, true);
assertTrue(abSigFilesEqual(jarFilename, sf -> sf.getAttributes(
FILENAME_INITIAL_CONTENTS).getValue(DEF_DIGEST_STR)));
}
/**
* Create a signed JAR file with a strange sequence of line breaks after
* the first individual section holding the digest of the only file
* contained within the JAR file and a second individual section with the
* same name to be both digested into the same entry digest,
* then add another file and sign it with a different signer.
* The manifest is expected to be changed during the second signature
* by removing superfluous line break characters which are not digested
* anyway or if the trailingSeq is not a sufficient delimiter that both
* intially provided sections are treated as only one which is maybe not
* perfect but does at least not result in an invalid signed jar file.
*/
@Test(dataProvider = "trailingSeqParams")
public void firstIndividualSectionTrailingSeqAddFile(String trailingSeq)
throws Exception {
String trailingSeqEscaped = byteArrayToIntList(trailingSeq.getBytes(
UTF_8)).stream().map(i -> "" + i).collect(Collectors.joining(""));
System.out.println("trailingSeq = " + trailingSeqEscaped);
String jarFilename = test("firstIndividualSectionTrailingSeqAddFile"
+ trailingSeqEscaped,
FILENAME_INITIAL_CONTENTS, FILENAME_UPDATED_CONTENTS, m -> {
StringBuilder sb = new StringBuilder(manifestToString(m));
// cut off default trailing line break characters
while ("\r\n".contains(sb.substring(sb.length() - 1))) {
sb.deleteCharAt(sb.length() - 1);
}
// and instead add another trailing sequence
sb.append(trailingSeq);
// now add another section with the same name assuming sb
// already contains one entry for FILENAME_INITIAL_CONTENTS
sb.append("Name: " + FILENAME_INITIAL_CONTENTS + "\r\n");
sb.append("Foo: Bar\r\n");
sb.append("\r\n");
return sb.toString().getBytes(UTF_8);
});
assertTrue(abSigFilesEqual(jarFilename, sf -> sf.getAttributes(
FILENAME_INITIAL_CONTENTS).getValue(DEF_DIGEST_STR)));
}
/**
* Create a signed JAR file with two individual sections for the same
* contained file (corresponding by name) the first of which properly
* delimited and the second of which followed by a strange sequence of
* line breaks both digested into the same entry digest,
* then add another file and sign it with a different signer.
* The manifest is expected to be changed during the second signature
* by removing superfluous line break characters which are not digested
* anyway or by adding a proper section delimiter.
* The digests are expected to be changed only if another line break is
* added to properly delimit the next section both digests of which are
* expected to validate with either digest or digestWorkaround.
*/
@Test(dataProvider = "trailingSeqParams")
public void secondIndividualSectionTrailingSeqAddFile(String trailingSeq)
throws Exception {
String trailingSeqEscaped = byteArrayToIntList(trailingSeq.getBytes(
UTF_8)).stream().map(i -> "" + i).collect(Collectors.joining(""));
System.out.println("trailingSeq = " + trailingSeqEscaped);
if (!isSufficientSectionDelimiter(trailingSeq)) {
return; // invalid manifest without trailing blank line
}
String jarFilename = test("secondIndividualSectionTrailingSeqAddFile" +
trailingSeqEscaped,
FILENAME_INITIAL_CONTENTS, FILENAME_UPDATED_CONTENTS, m -> {
StringBuilder sb = new StringBuilder(manifestToString(m));
sb.append("Name: " + FILENAME_INITIAL_CONTENTS + "\r\n");
sb.append("Foo: Bar");
sb.append(trailingSeq);
return sb.toString().getBytes(UTF_8);
}, null, true, true);
assertTrue(abSigFilesEqual(jarFilename, sf -> sf.getAttributes(
FILENAME_INITIAL_CONTENTS).getValue(DEF_DIGEST_STR)));
}
String manifestToString(Manifest mf) {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
mf.write(out);
return new String(out.toByteArray(), UTF_8);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
static List<Integer> byteArrayToIntList(byte[] bytes) {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < bytes.length; i++) {
list.add((int) bytes[i]);
}
return list;
}
}