Commit d1c939f4 authored by topjohnwu's avatar topjohnwu

Use temporary files to process zips

Fix #96
parent 21b11f1b
...@@ -12,6 +12,10 @@ import com.topjohnwu.magisk.utils.Shell; ...@@ -12,6 +12,10 @@ import com.topjohnwu.magisk.utils.Shell;
import com.topjohnwu.magisk.utils.Utils; import com.topjohnwu.magisk.utils.Utils;
import com.topjohnwu.magisk.utils.ZipUtils; import com.topjohnwu.magisk.utils.ZipUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.OutputStream; import java.io.OutputStream;
public class ProcessRepoZip extends ParallelTask<Void, Void, Boolean> { public class ProcessRepoZip extends ParallelTask<Void, Void, Boolean> {
...@@ -33,26 +37,59 @@ public class ProcessRepoZip extends ParallelTask<Void, Void, Boolean> { ...@@ -33,26 +37,59 @@ public class ProcessRepoZip extends ParallelTask<Void, Void, Boolean> {
@Override @Override
protected Boolean doInBackground(Void... params) { protected Boolean doInBackground(Void... params) {
// Create a buffer in memory for input/output
ByteArrayInOutStream buffer = new ByteArrayInOutStream(); FileInputStream in;
FileOutputStream out;
try { try {
// First remove top folder (the folder with the repo name) in Github source zip
ZipUtils.removeTopFolder(activity.getContentResolver().openInputStream(mUri), buffer);
// Then sign the zip for the first time // Create temp file
ZipUtils.signZip(activity, buffer.getInputStream(), buffer, false); File temp1 = new File(magiskManager.getCacheDir(), "1.zip");
File temp2 = new File(magiskManager.getCacheDir(), "2.zip");
if (magiskManager.getCacheDir().mkdirs()) {
temp1.createNewFile();
temp2.createNewFile();
}
out = new FileOutputStream(temp1);
// First remove top folder in Github source zip, Uri -> temp1
ZipUtils.removeTopFolder(activity.getContentResolver().openInputStream(mUri), out);
out.flush();
out.close();
out = new FileOutputStream(temp2);
// Adjust the zip to prevent unzip issues // Then sign the zip for the first time, temp1 -> temp2
ZipUtils.adjustZip(buffer); ZipUtils.signZip(activity, temp1, out, false);
out.flush();
out.close();
// Finally, sign the whole zip file again // Adjust the zip to prevent unzip issues, temp2 -> temp2
ZipUtils.signZip(activity, buffer.getInputStream(), buffer, true); ZipUtils.adjustZip(temp2);
// Write it back to the downloaded zip out = new FileOutputStream(temp1);
try (OutputStream out = activity.getContentResolver().openOutputStream(mUri)) {
buffer.writeTo(out); // Finally, sign the whole zip file again, temp2 -> temp1
ZipUtils.signZip(activity, temp2, out, true);
out.flush();
out.close();
in = new FileInputStream(temp1);
// Write it back to the downloaded zip, temp1 -> Uri
try (OutputStream target = activity.getContentResolver().openOutputStream(mUri)) {
byte[] buffer = new byte[4096];
int length;
if (target == null) throw new FileNotFoundException();
while ((length = in.read(buffer)) > 0)
target.write(buffer, 0, length);
} }
// Delete the temp file
temp1.delete();
temp2.delete();
return true; return true;
} catch (Exception e) { } catch (Exception e) {
Logger.error("ProcessRepoZip: Error!"); Logger.error("ProcessRepoZip: Error!");
......
...@@ -76,6 +76,11 @@ public class ZipUtils { ...@@ -76,6 +76,11 @@ public class ZipUtils {
private static final int USE_SHA1 = 1; private static final int USE_SHA1 = 1;
private static final int USE_SHA256 = 2; private static final int USE_SHA256 = 2;
// Files matching this pattern are not copied to the output.
private static Pattern stripPattern =
Pattern.compile("^(META-INF/((.*)[.](SF|RSA|DSA|EC)|com/android/otacert))|(" +
Pattern.quote(JarFile.MANIFEST_NAME) + ")$");
static { static {
System.loadLibrary("zipadjust"); System.loadLibrary("zipadjust");
sBouncyCastleProvider = new BouncyCastleProvider(); sBouncyCastleProvider = new BouncyCastleProvider();
...@@ -84,11 +89,17 @@ public class ZipUtils { ...@@ -84,11 +89,17 @@ public class ZipUtils {
public native static byte[] zipAdjust(byte[] bytes, int size); public native static byte[] zipAdjust(byte[] bytes, int size);
public native static void zipAdjust(String filename);
// Wrapper function for the JNI function // Wrapper function for the JNI function
public static void adjustZip(ByteArrayInOutStream buffer) { public static void adjustZip(ByteArrayInOutStream buffer) {
buffer.setBuffer(zipAdjust(buffer.toByteArray(), buffer.size())); buffer.setBuffer(zipAdjust(buffer.toByteArray(), buffer.size()));
} }
public static void adjustZip(File file) {
zipAdjust(file.getPath());
}
public static void removeTopFolder(InputStream in, OutputStream out) throws IOException { public static void removeTopFolder(InputStream in, OutputStream out) throws IOException {
try { try {
JarInputStream source = new JarInputStream(in); JarInputStream source = new JarInputStream(in);
...@@ -186,24 +197,23 @@ public class ZipUtils { ...@@ -186,24 +197,23 @@ public class ZipUtils {
} }
} }
public static void signZip(Context context, InputStream inputStream, public static void signZip(Context context, File input,
OutputStream outputStream, boolean signWholeFile) throws Exception { OutputStream outputStream, boolean signWholeFile) throws Exception {
JarMap inputJar; JarFile inputJar = new JarFile(input);
int hashes = 0; int hashes = 0;
try { try {
X509Certificate publicKey = readPublicKey(context.getAssets().open(PUBLIC_KEY_NAME)); X509Certificate publicKey = GeneralUtils.readPublicKey(context.getAssets().open(PUBLIC_KEY_NAME));
hashes |= getDigestAlgorithm(publicKey); hashes |= FileUtils.getDigestAlgorithm(publicKey);
// Set the ZIP file timestamp to the starting valid time // Set the ZIP file timestamp to the starting valid time
// of the 0th certificate plus one hour (to match what // of the 0th certificate plus one hour (to match what
// we've historically done). // we've historically done).
long timestamp = publicKey.getNotBefore().getTime() + 3600L * 1000; long timestamp = publicKey.getNotBefore().getTime() + 3600L * 1000;
PrivateKey privateKey = readPrivateKey(context.getAssets().open(PRIVATE_KEY_NAME)); PrivateKey privateKey = GeneralUtils.readPrivateKey(context.getAssets().open(PRIVATE_KEY_NAME));
inputJar = new JarMap(new JarInputStream(inputStream));
if (signWholeFile) { if (signWholeFile) {
if (!"RSA".equalsIgnoreCase(privateKey.getAlgorithm())) { if (!"RSA".equalsIgnoreCase(privateKey.getAlgorithm())) {
throw new IOException("Cannot sign OTA packages with non-RSA keys"); throw new IOException("Cannot sign OTA packages with non-RSA keys");
} }
signWholeFile(inputJar, context.getAssets().open(PUBLIC_KEY_NAME), FileUtils.signWholeFile(inputJar, context.getAssets().open(PUBLIC_KEY_NAME),
publicKey, privateKey, outputStream); publicKey, privateKey, outputStream);
} else { } else {
JarOutputStream outputJar = new JarOutputStream(outputStream); JarOutputStream outputJar = new JarOutputStream(outputStream);
...@@ -214,9 +224,9 @@ public class ZipUtils { ...@@ -214,9 +224,9 @@ public class ZipUtils {
// and produces output that is only a tiny bit larger // and produces output that is only a tiny bit larger
// (~0.1% on full OTA packages I tested). // (~0.1% on full OTA packages I tested).
outputJar.setLevel(9); outputJar.setLevel(9);
Manifest manifest = addDigestsToManifest(inputJar, hashes); Manifest manifest = FileUtils.addDigestsToManifest(inputJar, hashes);
copyFiles(manifest, inputJar, outputJar, timestamp); FileUtils.copyFiles(manifest, inputJar, outputJar, timestamp);
signFile(manifest, inputJar, publicKey, privateKey, outputJar); GeneralUtils.signFile(manifest, publicKey, privateKey, outputJar);
outputJar.close(); outputJar.close();
} }
} catch (Exception e) { } catch (Exception e) {
...@@ -225,557 +235,822 @@ public class ZipUtils { ...@@ -225,557 +235,822 @@ public class ZipUtils {
} }
} }
public static class JarMap extends TreeMap<String, Pair<JarEntry, ByteArrayOutputStream> > { public static void signZip(Context context, InputStream inputStream,
OutputStream outputStream, boolean signWholeFile) throws Exception {
StreamUtils.JarMap inputJar;
int hashes = 0;
try {
X509Certificate publicKey = GeneralUtils.readPublicKey(context.getAssets().open(PUBLIC_KEY_NAME));
hashes |= FileUtils.getDigestAlgorithm(publicKey);
// Set the ZIP file timestamp to the starting valid time
// of the 0th certificate plus one hour (to match what
// we've historically done).
long timestamp = publicKey.getNotBefore().getTime() + 3600L * 1000;
PrivateKey privateKey = GeneralUtils.readPrivateKey(context.getAssets().open(PRIVATE_KEY_NAME));
inputJar = new StreamUtils.JarMap(new JarInputStream(inputStream));
if (signWholeFile) {
if (!"RSA".equalsIgnoreCase(privateKey.getAlgorithm())) {
throw new IOException("Cannot sign OTA packages with non-RSA keys");
}
StreamUtils.signWholeFile(inputJar, context.getAssets().open(PUBLIC_KEY_NAME),
publicKey, privateKey, outputStream);
} else {
JarOutputStream outputJar = new JarOutputStream(outputStream);
// For signing .apks, use the maximum compression to make
// them as small as possible (since they live forever on
// the system partition). For OTA packages, use the
// default compression level, which is much much faster
// and produces output that is only a tiny bit larger
// (~0.1% on full OTA packages I tested).
outputJar.setLevel(9);
Manifest manifest = StreamUtils.addDigestsToManifest(inputJar, hashes);
StreamUtils.copyFiles(manifest, inputJar, outputJar, timestamp);
GeneralUtils.signFile(manifest, publicKey, privateKey, outputJar);
outputJar.close();
}
} catch (Exception e) {
e.printStackTrace();
throw e;
}
}
private Manifest manifest; // This class host general functions
public static class GeneralUtils {
public JarMap(JarInputStream in) throws IOException { /** Returns the expected signature algorithm for this key type. */
super(); private static String getSignatureAlgorithm(X509Certificate cert) {
manifest = in.getManifest(); String sigAlg = cert.getSigAlgName().toUpperCase(Locale.US);
byte[] buffer = new byte[4096]; String keyType = cert.getPublicKey().getAlgorithm().toUpperCase(Locale.US);
int num; if ("RSA".equalsIgnoreCase(keyType)) {
JarEntry entry; if (FileUtils.getDigestAlgorithm(cert) == USE_SHA256) {
while ((entry = in.getNextJarEntry()) != null) { return "SHA256withRSA";
ByteArrayOutputStream stream = new ByteArrayOutputStream(); } else {
while ((num = in.read(buffer)) > 0) { return "SHA1withRSA";
stream.write(buffer, 0, num);
} }
put(entry.getName(), entry, stream); } else if ("DSA".equalsIgnoreCase(keyType)) {
return "SHA256withDSA";
} else if ("EC".equalsIgnoreCase(keyType)) {
return "SHA256withECDSA";
} else {
throw new IllegalArgumentException("unsupported key type: " + keyType);
} }
in.close();
} }
public JarEntry getJarEntry(String name) { private static X509Certificate readPublicKey(InputStream input)
return get(name).first; throws IOException, GeneralSecurityException {
} try {
public ByteArrayOutputStream getStream(String name) { CertificateFactory cf = CertificateFactory.getInstance("X.509");
return get(name).second; return (X509Certificate) cf.generateCertificate(input);
} } finally {
public void put(String name, JarEntry entry, ByteArrayOutputStream stream) { input.close();
put(name, new Pair<>(entry, stream)); }
}
public Manifest getManifest() {
return manifest;
}
public Enumeration<JarEntry> entries() {
Iterator<Map.Entry<String, Pair<JarEntry, ByteArrayOutputStream> >> i = entrySet().iterator();
ArrayList<JarEntry> list = new ArrayList<>();
while (i.hasNext())
list.add(i.next().getValue().first);
return Collections.enumeration(list);
} }
}
/** /**
* Return one of USE_SHA1 or USE_SHA256 according to the signature * Decrypt an encrypted PKCS 8 format private key.
* algorithm specified in the cert. *
*/ * Based on ghstark's post on Aug 6, 2006 at
private static int getDigestAlgorithm(X509Certificate cert) { * http://forums.sun.com/thread.jspa?threadID=758133&messageID=4330949
String sigAlg = cert.getSigAlgName().toUpperCase(Locale.US); *
if ("SHA1WITHRSA".equals(sigAlg) || * @param encryptedPrivateKey The raw data of the private key
"MD5WITHRSA".equals(sigAlg)) { // see "HISTORICAL NOTE" above. * @param keyFile The file containing the private key
return USE_SHA1; */
} else if (sigAlg.startsWith("SHA256WITH")) { private static KeySpec decryptPrivateKey(byte[] encryptedPrivateKey, File keyFile)
return USE_SHA256; throws GeneralSecurityException {
} else { EncryptedPrivateKeyInfo epkInfo;
throw new IllegalArgumentException("unsupported signature algorithm \"" + sigAlg + try {
"\" in cert [" + cert.getSubjectDN()); epkInfo = new EncryptedPrivateKeyInfo(encryptedPrivateKey);
} } catch (IOException ex) {
} // Probably not an encrypted key.
/** Returns the expected signature algorithm for this key type. */ return null;
private static String getSignatureAlgorithm(X509Certificate cert) { }
String sigAlg = cert.getSigAlgName().toUpperCase(Locale.US); // We no longer have console, so need to use another way to input password
String keyType = cert.getPublicKey().getAlgorithm().toUpperCase(Locale.US); // This function is left here if needed in the future, so no use for now
if ("RSA".equalsIgnoreCase(keyType)) { char[] password = new char[0];
if (getDigestAlgorithm(cert) == USE_SHA256) { SecretKeyFactory skFactory = SecretKeyFactory.getInstance(epkInfo.getAlgName());
return "SHA256withRSA"; Key key = skFactory.generateSecret(new PBEKeySpec(password));
} else { Cipher cipher = Cipher.getInstance(epkInfo.getAlgName());
return "SHA1withRSA"; cipher.init(Cipher.DECRYPT_MODE, key, epkInfo.getAlgParameters());
} try {
} else if ("DSA".equalsIgnoreCase(keyType)) { return epkInfo.getKeySpec(cipher);
return "SHA256withDSA"; } catch (InvalidKeySpecException ex) {
} else if ("EC".equalsIgnoreCase(keyType)) { System.err.println("signapk: Password for " + keyFile + " may be bad.");
return "SHA256withECDSA"; throw ex;
} else { }
throw new IllegalArgumentException("unsupported key type: " + keyType);
}
}
// Files matching this pattern are not copied to the output.
private static Pattern stripPattern =
Pattern.compile("^(META-INF/((.*)[.](SF|RSA|DSA|EC)|com/android/otacert))|(" +
Pattern.quote(JarFile.MANIFEST_NAME) + ")$");
private static X509Certificate readPublicKey(InputStream input)
throws IOException, GeneralSecurityException {
try {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
return (X509Certificate) cf.generateCertificate(input);
} finally {
input.close();
} }
}
/** /** Read a PKCS 8 format private key. */
* Decrypt an encrypted PKCS 8 format private key. private static PrivateKey readPrivateKey(InputStream input)
* throws IOException, GeneralSecurityException {
* Based on ghstark's post on Aug 6, 2006 at try {
* http://forums.sun.com/thread.jspa?threadID=758133&messageID=4330949 byte[] buffer = new byte[4096];
* int size = input.read(buffer);
* @param encryptedPrivateKey The raw data of the private key byte[] bytes = Arrays.copyOf(buffer, size);
* @param keyFile The file containing the private key KeySpec spec = new PKCS8EncodedKeySpec(bytes);
*/ PrivateKey key;
private static KeySpec decryptPrivateKey(byte[] encryptedPrivateKey, File keyFile) key = decodeAsKeyType(spec, "RSA");
throws GeneralSecurityException { if (key != null) {
EncryptedPrivateKeyInfo epkInfo; return key;
try { }
epkInfo = new EncryptedPrivateKeyInfo(encryptedPrivateKey); key = decodeAsKeyType(spec, "DSA");
} catch (IOException ex) { if (key != null) {
// Probably not an encrypted key. return key;
return null; }
key = decodeAsKeyType(spec, "EC");
if (key != null) {
return key;
}
throw new NoSuchAlgorithmException("Must be an RSA, DSA, or EC key");
} finally {
input.close();
}
} }
// We no longer have console, so need to use another way to input password
// This function is left here if needed in the future, so no use for now private static PrivateKey decodeAsKeyType(KeySpec spec, String keyType)
char[] password = new char[0]; throws GeneralSecurityException {
SecretKeyFactory skFactory = SecretKeyFactory.getInstance(epkInfo.getAlgName()); try {
Key key = skFactory.generateSecret(new PBEKeySpec(password)); return KeyFactory.getInstance(keyType).generatePrivate(spec);
Cipher cipher = Cipher.getInstance(epkInfo.getAlgName()); } catch (InvalidKeySpecException e) {
cipher.init(Cipher.DECRYPT_MODE, key, epkInfo.getAlgParameters()); return null;
try { }
return epkInfo.getKeySpec(cipher);
} catch (InvalidKeySpecException ex) {
System.err.println("signapk: Password for " + keyFile + " may be bad.");
throw ex;
} }
}
/** Read a PKCS 8 format private key. */ /**
private static PrivateKey readPrivateKey(InputStream input) * Add a copy of the public key to the archive; this should
throws IOException, GeneralSecurityException { * exactly match one of the files in
try { * /system/etc/security/otacerts.zip on the device. (The same
byte[] buffer = new byte[4096]; * cert can be extracted from the CERT.RSA file but this is much
int size = input.read(buffer); * easier to get at.)
byte[] bytes = Arrays.copyOf(buffer, size); */
KeySpec spec = new PKCS8EncodedKeySpec(bytes); private static void addOtacert(JarOutputStream outputJar,
PrivateKey key; InputStream input,
key = decodeAsKeyType(spec, "RSA"); long timestamp,
if (key != null) { Manifest manifest,
return key; int hash)
} throws IOException, GeneralSecurityException {
key = decodeAsKeyType(spec, "DSA"); MessageDigest md = MessageDigest.getInstance(hash == USE_SHA1 ? "SHA1" : "SHA256");
if (key != null) { JarEntry je = new JarEntry(OTACERT_NAME);
return key; je.setTime(timestamp);
} outputJar.putNextEntry(je);
key = decodeAsKeyType(spec, "EC"); byte[] b = new byte[4096];
if (key != null) { int read;
return key; while ((read = input.read(b)) != -1) {
} outputJar.write(b, 0, read);
throw new NoSuchAlgorithmException("Must be an RSA, DSA, or EC key"); md.update(b, 0, read);
} finally { }
input.close(); input.close();
Attributes attr = new Attributes();
attr.putValue(hash == USE_SHA1 ? "SHA1-Digest" : "SHA-256-Digest",
new String(Base64.encode(md.digest()), "ASCII"));
manifest.getEntries().put(OTACERT_NAME, attr);
} }
}
private static PrivateKey decodeAsKeyType(KeySpec spec, String keyType)
throws GeneralSecurityException {
try {
return KeyFactory.getInstance(keyType).generatePrivate(spec);
} catch (InvalidKeySpecException e) {
return null;
}
}
/** /** Write a .SF file with a digest of the specified manifest. */
* Add the hash(es) of every file to the manifest, creating it if private static void writeSignatureFile(Manifest manifest, OutputStream out,
* necessary. int hash)
*/ throws IOException, GeneralSecurityException {
private static Manifest addDigestsToManifest(JarMap jar, int hashes) Manifest sf = new Manifest();
throws IOException, GeneralSecurityException { Attributes main = sf.getMainAttributes();
Manifest input = jar.getManifest(); main.putValue("Signature-Version", "1.0");
Manifest output = new Manifest();
Attributes main = output.getMainAttributes();
if (input != null) {
main.putAll(input.getMainAttributes());
} else {
main.putValue("Manifest-Version", "1.0");
main.putValue("Created-By", "1.0 (Android SignApk)"); main.putValue("Created-By", "1.0 (Android SignApk)");
} MessageDigest md = MessageDigest.getInstance(
MessageDigest md_sha1 = null; hash == USE_SHA256 ? "SHA256" : "SHA1");
MessageDigest md_sha256 = null; PrintStream print = new PrintStream(
if ((hashes & USE_SHA1) != 0) { new DigestOutputStream(new ByteArrayOutputStream(), md),
md_sha1 = MessageDigest.getInstance("SHA1"); true, "UTF-8");
} // Digest of the entire manifest
if ((hashes & USE_SHA256) != 0) { manifest.write(print);
md_sha256 = MessageDigest.getInstance("SHA256"); print.flush();
} main.putValue(hash == USE_SHA256 ? "SHA-256-Digest-Manifest" : "SHA1-Digest-Manifest",
// We sort the input entries by name, and add them to the new String(Base64.encode(md.digest()), "ASCII"));
// output manifest in sorted order. We expect that the output Map<String, Attributes> entries = manifest.getEntries();
// map will be deterministic. for (Map.Entry<String, Attributes> entry : entries.entrySet()) {
/* JarMap is a TreeMap, so it's already sorted */ // Digest of the manifest stanza for this entry.
for (String name : jar.keySet()) { print.print("Name: " + entry.getKey() + "\r\n");
JarEntry entry = jar.getJarEntry(name); for (Map.Entry<Object, Object> att : entry.getValue().entrySet()) {
if (!entry.isDirectory() && print.print(att.getKey() + ": " + att.getValue() + "\r\n");
(stripPattern == null || !stripPattern.matcher(name).matches())) {
byte[] buffer = jar.getStream(name).toByteArray();
if (md_sha1 != null) md_sha1.update(buffer, 0, buffer.length);
if (md_sha256 != null) md_sha256.update(buffer, 0, buffer.length);
Attributes attr = null;
if (input != null) attr = input.getAttributes(name);
attr = attr != null ? new Attributes(attr) : new Attributes();
if (md_sha1 != null) {
attr.putValue("SHA1-Digest",
new String(Base64.encode(md_sha1.digest()), "ASCII"));
}
if (md_sha256 != null) {
attr.putValue("SHA-256-Digest",
new String(Base64.encode(md_sha256.digest()), "ASCII"));
} }
output.getEntries().put(name, attr); print.print("\r\n");
print.flush();
Attributes sfAttr = new Attributes();
sfAttr.putValue(hash == USE_SHA256 ? "SHA-256-Digest" : "SHA1-Digest-Manifest",
new String(Base64.encode(md.digest()), "ASCII"));
sf.getEntries().put(entry.getKey(), sfAttr);
}
CountOutputStream cout = new CountOutputStream(out);
sf.write(cout);
// A bug in the java.util.jar implementation of Android platforms
// up to version 1.6 will cause a spurious IOException to be thrown
// if the length of the signature file is a multiple of 1024 bytes.
// As a workaround, add an extra CRLF in this case.
if ((cout.size() % 1024) == 0) {
cout.write('\r');
cout.write('\n');
} }
} }
return output;
}
/** /** Sign data and write the digital signature to 'out'. */
* Add a copy of the public key to the archive; this should private static void writeSignatureBlock(
* exactly match one of the files in CMSTypedData data, X509Certificate publicKey, PrivateKey privateKey, OutputStream out)
* /system/etc/security/otacerts.zip on the device. (The same throws IOException, CertificateEncodingException, OperatorCreationException, CMSException {
* cert can be extracted from the CERT.RSA file but this is much ArrayList<X509Certificate> certList = new ArrayList<X509Certificate>(1);
* easier to get at.) certList.add(publicKey);
*/ JcaCertStore certs = new JcaCertStore(certList);
private static void addOtacert(JarOutputStream outputJar, CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
InputStream input, ContentSigner signer = new JcaContentSignerBuilder(getSignatureAlgorithm(publicKey))
long timestamp, .setProvider(sBouncyCastleProvider)
Manifest manifest, .build(privateKey);
int hash) gen.addSignerInfoGenerator(
throws IOException, GeneralSecurityException { new JcaSignerInfoGeneratorBuilder(
MessageDigest md = MessageDigest.getInstance(hash == USE_SHA1 ? "SHA1" : "SHA256"); new JcaDigestCalculatorProviderBuilder()
JarEntry je = new JarEntry(OTACERT_NAME); .setProvider(sBouncyCastleProvider)
je.setTime(timestamp); .build())
outputJar.putNextEntry(je); .setDirectSignature(true)
byte[] b = new byte[4096]; .build(signer, publicKey));
int read; gen.addCertificates(certs);
while ((read = input.read(b)) != -1) { CMSSignedData sigData = gen.generate(data, false);
outputJar.write(b, 0, read); try (ASN1InputStream asn1 = new ASN1InputStream(sigData.getEncoded())) {
md.update(b, 0, read); DEROutputStream dos = new DEROutputStream(out);
dos.writeObject(asn1.readObject());
}
} }
input.close();
Attributes attr = new Attributes();
attr.putValue(hash == USE_SHA1 ? "SHA1-Digest" : "SHA-256-Digest",
new String(Base64.encode(md.digest()), "ASCII"));
manifest.getEntries().put(OTACERT_NAME, attr);
}
/** Write to another stream and track how many bytes have been private static void signFile(Manifest manifest,
* written. X509Certificate publicKey, PrivateKey privateKey,
*/ JarOutputStream outputJar)
private static class CountOutputStream extends FilterOutputStream { throws Exception {
private int mCount; // Assume the certificate is valid for at least an hour.
public CountOutputStream(OutputStream out) { long timestamp = publicKey.getNotBefore().getTime() + 3600L * 1000;
super(out); // MANIFEST.MF
mCount = 0; JarEntry je = new JarEntry(JarFile.MANIFEST_NAME);
} je.setTime(timestamp);
@Override outputJar.putNextEntry(je);
public void write(int b) throws IOException { manifest.write(outputJar);
super.write(b); // CERT.SF / CERT#.SF
mCount++; je = new JarEntry(CERT_SF_NAME);
} je.setTime(timestamp);
@Override outputJar.putNextEntry(je);
public void write(byte[] b, int off, int len) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream();
super.write(b, off, len); writeSignatureFile(manifest, baos, FileUtils.getDigestAlgorithm(publicKey));
mCount += len; byte[] signedData = baos.toByteArray();
} outputJar.write(signedData);
public int size() { // CERT.{DSA,EC,RSA} / CERT#.{DSA,EC,RSA}
return mCount; je = new JarEntry((String.format(CERT_SIG_NAME, privateKey.getAlgorithm())));
je.setTime(timestamp);
outputJar.putNextEntry(je);
writeSignatureBlock(new CMSProcessableByteArray(signedData),
publicKey, privateKey, outputJar);
} }
}
/** Write a .SF file with a digest of the specified manifest. */ /** Write to another stream and track how many bytes have been
private static void writeSignatureFile(Manifest manifest, OutputStream out, * written.
int hash) */
throws IOException, GeneralSecurityException { private static class CountOutputStream extends FilterOutputStream {
Manifest sf = new Manifest(); private int mCount;
Attributes main = sf.getMainAttributes(); public CountOutputStream(OutputStream out) {
main.putValue("Signature-Version", "1.0"); super(out);
main.putValue("Created-By", "1.0 (Android SignApk)"); mCount = 0;
MessageDigest md = MessageDigest.getInstance( }
hash == USE_SHA256 ? "SHA256" : "SHA1"); @Override
PrintStream print = new PrintStream( public void write(int b) throws IOException {
new DigestOutputStream(new ByteArrayOutputStream(), md), super.write(b);
true, "UTF-8"); mCount++;
// Digest of the entire manifest }
manifest.write(print); @Override
print.flush(); public void write(byte[] b, int off, int len) throws IOException {
main.putValue(hash == USE_SHA256 ? "SHA-256-Digest-Manifest" : "SHA1-Digest-Manifest", super.write(b, off, len);
new String(Base64.encode(md.digest()), "ASCII")); mCount += len;
Map<String, Attributes> entries = manifest.getEntries(); }
for (Map.Entry<String, Attributes> entry : entries.entrySet()) { public int size() {
// Digest of the manifest stanza for this entry. return mCount;
print.print("Name: " + entry.getKey() + "\r\n"); }
for (Map.Entry<Object, Object> att : entry.getValue().entrySet()) {
print.print(att.getKey() + ": " + att.getValue() + "\r\n");
}
print.print("\r\n");
print.flush();
Attributes sfAttr = new Attributes();
sfAttr.putValue(hash == USE_SHA256 ? "SHA-256-Digest" : "SHA1-Digest-Manifest",
new String(Base64.encode(md.digest()), "ASCII"));
sf.getEntries().put(entry.getKey(), sfAttr);
}
CountOutputStream cout = new CountOutputStream(out);
sf.write(cout);
// A bug in the java.util.jar implementation of Android platforms
// up to version 1.6 will cause a spurious IOException to be thrown
// if the length of the signature file is a multiple of 1024 bytes.
// As a workaround, add an extra CRLF in this case.
if ((cout.size() % 1024) == 0) {
cout.write('\r');
cout.write('\n');
} }
}
/** Sign data and write the digital signature to 'out'. */ private static class WholeFileSignerOutputStream extends FilterOutputStream {
private static void writeSignatureBlock( private boolean closing = false;
CMSTypedData data, X509Certificate publicKey, PrivateKey privateKey, OutputStream out) private ByteArrayOutputStream footer = new ByteArrayOutputStream();
throws IOException, CertificateEncodingException, OperatorCreationException, CMSException { private OutputStream tee;
ArrayList<X509Certificate> certList = new ArrayList<X509Certificate>(1); public WholeFileSignerOutputStream(OutputStream out, OutputStream tee) {
certList.add(publicKey); super(out);
JcaCertStore certs = new JcaCertStore(certList); this.tee = tee;
CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); }
ContentSigner signer = new JcaContentSignerBuilder(getSignatureAlgorithm(publicKey)) public void notifyClosing() {
.setProvider(sBouncyCastleProvider) closing = true;
.build(privateKey); }
gen.addSignerInfoGenerator( public void finish() throws IOException {
new JcaSignerInfoGeneratorBuilder( closing = false;
new JcaDigestCalculatorProviderBuilder() byte[] data = footer.toByteArray();
.setProvider(sBouncyCastleProvider) if (data.length < 2)
.build()) throw new IOException("Less than two bytes written to footer");
.setDirectSignature(true) write(data, 0, data.length - 2);
.build(signer, publicKey)); }
gen.addCertificates(certs); public byte[] getTail() {
CMSSignedData sigData = gen.generate(data, false); return footer.toByteArray();
try (ASN1InputStream asn1 = new ASN1InputStream(sigData.getEncoded())) { }
DEROutputStream dos = new DEROutputStream(out); @Override
dos.writeObject(asn1.readObject()); public void write(byte[] b) throws IOException {
write(b, 0, b.length);
}
@Override
public void write(byte[] b, int off, int len) throws IOException {
if (closing) {
// if the jar is about to close, save the footer that will be written
footer.write(b, off, len);
}
else {
// write to both output streams. out is the CMSTypedData signer and tee is the file.
out.write(b, off, len);
tee.write(b, off, len);
}
}
@Override
public void write(int b) throws IOException {
if (closing) {
// if the jar is about to close, save the footer that will be written
footer.write(b);
}
else {
// write to both output streams. out is the CMSTypedData signer and tee is the file.
out.write(b);
tee.write(b);
}
}
} }
} }
/**
* Copy all the files in a manifest from input to output. We set // This class host functions that consumes JarFiles
* the modification times in the output to a fixed time, so as to public static class FileUtils {
* reduce variation in the output file and make incremental OTAs
* more efficient. /**
*/ * Return one of USE_SHA1 or USE_SHA256 according to the signature
private static void copyFiles(Manifest manifest, * algorithm specified in the cert.
JarMap in, JarOutputStream out, long timestamp) throws IOException { */
Map<String, Attributes> entries = manifest.getEntries(); private static int getDigestAlgorithm(X509Certificate cert) {
ArrayList<String> names = new ArrayList<>(entries.keySet()); String sigAlg = cert.getSigAlgName().toUpperCase(Locale.US);
Collections.sort(names); if ("SHA1WITHRSA".equals(sigAlg) ||
for (String name : names) { "MD5WITHRSA".equals(sigAlg)) { // see "HISTORICAL NOTE" above.
JarEntry inEntry = in.getJarEntry(name); return USE_SHA1;
JarEntry outEntry; } else if (sigAlg.startsWith("SHA256WITH")) {
if (inEntry.getMethod() == JarEntry.STORED) { return USE_SHA256;
// Preserve the STORED method of the input entry.
outEntry = new JarEntry(inEntry);
} else { } else {
// Create a new entry so that the compressed len is recomputed. throw new IllegalArgumentException("unsupported signature algorithm \"" + sigAlg +
outEntry = new JarEntry(name); "\" in cert [" + cert.getSubjectDN());
} }
outEntry.setTime(timestamp);
out.putNextEntry(outEntry);
in.getStream(name).writeTo(out);
out.flush();
} }
}
private static class WholeFileSignerOutputStream extends FilterOutputStream { private static Manifest addDigestsToManifest(JarFile jar, int hashes)
private boolean closing = false; throws IOException, GeneralSecurityException {
private ByteArrayOutputStream footer = new ByteArrayOutputStream(); Manifest input = jar.getManifest();
private OutputStream tee; Manifest output = new Manifest();
public WholeFileSignerOutputStream(OutputStream out, OutputStream tee) { Attributes main = output.getMainAttributes();
super(out); if (input != null) {
this.tee = tee; main.putAll(input.getMainAttributes());
} } else {
public void notifyClosing() { main.putValue("Manifest-Version", "1.0");
closing = true; main.putValue("Created-By", "1.0 (Android SignApk)");
} }
public void finish() throws IOException { MessageDigest md_sha1 = null;
closing = false; MessageDigest md_sha256 = null;
byte[] data = footer.toByteArray(); if ((hashes & USE_SHA1) != 0) {
if (data.length < 2) md_sha1 = MessageDigest.getInstance("SHA1");
throw new IOException("Less than two bytes written to footer"); }
write(data, 0, data.length - 2); if ((hashes & USE_SHA256) != 0) {
} md_sha256 = MessageDigest.getInstance("SHA256");
public byte[] getTail() { }
return footer.toByteArray(); byte[] buffer = new byte[4096];
int num;
// We sort the input entries by name, and add them to the
// output manifest in sorted order. We expect that the output
// map will be deterministic.
TreeMap<String, JarEntry> byName = new TreeMap<String, JarEntry>();
for (Enumeration<JarEntry> e = jar.entries(); e.hasMoreElements(); ) {
JarEntry entry = e.nextElement();
byName.put(entry.getName(), entry);
}
for (JarEntry entry: byName.values()) {
String name = entry.getName();
if (!entry.isDirectory() &&
(stripPattern == null || !stripPattern.matcher(name).matches())) {
InputStream data = jar.getInputStream(entry);
while ((num = data.read(buffer)) > 0) {
if (md_sha1 != null) md_sha1.update(buffer, 0, num);
if (md_sha256 != null) md_sha256.update(buffer, 0, num);
}
Attributes attr = null;
if (input != null) attr = input.getAttributes(name);
attr = attr != null ? new Attributes(attr) : new Attributes();
// Remove any previously computed digests from this entry's attributes.
for (Iterator<Object> i = attr.keySet().iterator(); i.hasNext();) {
Object key = i.next();
if (!(key instanceof Attributes.Name)) {
continue;
}
String attributeNameLowerCase =
((Attributes.Name) key).toString().toLowerCase(Locale.US);
if (attributeNameLowerCase.endsWith("-digest")) {
i.remove();
}
}
// Add SHA-1 digest if requested
if (md_sha1 != null) {
attr.putValue("SHA1-Digest",
new String(Base64.encode(md_sha1.digest()), "ASCII"));
}
// Add SHA-256 digest if requested
if (md_sha256 != null) {
attr.putValue("SHA-256-Digest",
new String(Base64.encode(md_sha256.digest()), "ASCII"));
}
output.getEntries().put(name, attr);
}
}
return output;
} }
@Override
public void write(byte[] b) throws IOException { private static void copyFiles(Manifest manifest,
write(b, 0, b.length); JarFile in, JarOutputStream out, long timestamp) throws IOException {
byte[] buffer = new byte[4096];
int num;
Map<String, Attributes> entries = manifest.getEntries();
ArrayList<String> names = new ArrayList<String>(entries.keySet());
Collections.sort(names);
for (String name : names) {
JarEntry inEntry = in.getJarEntry(name);
JarEntry outEntry = null;
if (inEntry.getMethod() == JarEntry.STORED) {
// Preserve the STORED method of the input entry.
outEntry = new JarEntry(inEntry);
} else {
// Create a new entry so that the compressed len is recomputed.
outEntry = new JarEntry(name);
}
outEntry.setTime(timestamp);
out.putNextEntry(outEntry);
InputStream data = in.getInputStream(inEntry);
while ((num = data.read(buffer)) > 0) {
out.write(buffer, 0, num);
}
out.flush();
}
} }
@Override
public void write(byte[] b, int off, int len) throws IOException { private static void signWholeFile(JarFile inputJar, InputStream publicKeyFile,
if (closing) { X509Certificate publicKey, PrivateKey privateKey,
// if the jar is about to close, save the footer that will be written OutputStream outputStream) throws Exception {
footer.write(b, off, len); CMSSigner cmsOut = new CMSSigner(inputJar, publicKeyFile,
publicKey, privateKey, outputStream);
ByteArrayOutputStream temp = new ByteArrayOutputStream();
// put a readable message and a null char at the start of the
// archive comment, so that tools that display the comment
// (hopefully) show something sensible.
// TODO: anything more useful we can put in this message?
byte[] message = "signed by SignApk".getBytes("UTF-8");
temp.write(message);
temp.write(0);
cmsOut.writeSignatureBlock(temp);
byte[] zipData = cmsOut.getSigner().getTail();
// For a zip with no archive comment, the
// end-of-central-directory record will be 22 bytes long, so
// we expect to find the EOCD marker 22 bytes from the end.
if (zipData[zipData.length-22] != 0x50 ||
zipData[zipData.length-21] != 0x4b ||
zipData[zipData.length-20] != 0x05 ||
zipData[zipData.length-19] != 0x06) {
throw new IllegalArgumentException("zip data already has an archive comment");
}
int total_size = temp.size() + 6;
if (total_size > 0xffff) {
throw new IllegalArgumentException("signature is too big for ZIP file comment");
} }
else { // signature starts this many bytes from the end of the file
// write to both output streams. out is the CMSTypedData signer and tee is the file. int signature_start = total_size - message.length - 1;
out.write(b, off, len); temp.write(signature_start & 0xff);
tee.write(b, off, len); temp.write((signature_start >> 8) & 0xff);
// Why the 0xff bytes? In a zip file with no archive comment,
// bytes [-6:-2] of the file are the little-endian offset from
// the start of the file to the central directory. So for the
// two high bytes to be 0xff 0xff, the archive would have to
// be nearly 4GB in size. So it's unlikely that a real
// commentless archive would have 0xffs here, and lets us tell
// an old signed archive from a new one.
temp.write(0xff);
temp.write(0xff);
temp.write(total_size & 0xff);
temp.write((total_size >> 8) & 0xff);
temp.flush();
// Signature verification checks that the EOCD header is the
// last such sequence in the file (to avoid minzip finding a
// fake EOCD appended after the signature in its scan). The
// odds of producing this sequence by chance are very low, but
// let's catch it here if it does.
byte[] b = temp.toByteArray();
for (int i = 0; i < b.length-3; ++i) {
if (b[i] == 0x50 && b[i+1] == 0x4b && b[i+2] == 0x05 && b[i+3] == 0x06) {
throw new IllegalArgumentException("found spurious EOCD header at " + i);
}
} }
outputStream.write(total_size & 0xff);
outputStream.write((total_size >> 8) & 0xff);
temp.writeTo(outputStream);
} }
@Override
public void write(int b) throws IOException { private static class CMSSigner implements CMSTypedData {
if (closing) { private JarFile inputJar;
// if the jar is about to close, save the footer that will be written private InputStream publicKeyFile;
footer.write(b); private X509Certificate publicKey;
private PrivateKey privateKey;
private OutputStream outputStream;
private final ASN1ObjectIdentifier type;
private GeneralUtils.WholeFileSignerOutputStream signer;
public CMSSigner(JarFile inputJar, InputStream publicKeyFile,
X509Certificate publicKey, PrivateKey privateKey,
OutputStream outputStream) {
this.inputJar = inputJar;
this.publicKeyFile = publicKeyFile;
this.publicKey = publicKey;
this.privateKey = privateKey;
this.outputStream = outputStream;
this.type = new ASN1ObjectIdentifier(CMSObjectIdentifiers.data.getId());
}
public Object getContent() {
// Not supported, but still don't crash or return null
return 1;
}
public ASN1ObjectIdentifier getContentType() {
return type;
}
public void write(OutputStream out) throws IOException {
try {
signer = new GeneralUtils.WholeFileSignerOutputStream(out, outputStream);
JarOutputStream outputJar = new JarOutputStream(signer);
int hash = getDigestAlgorithm(publicKey);
// Assume the certificate is valid for at least an hour.
long timestamp = publicKey.getNotBefore().getTime() + 3600L * 1000;
Manifest manifest = addDigestsToManifest(inputJar, hash);
copyFiles(manifest, inputJar, outputJar, timestamp);
// Don't add Otacert, it's not an OTA
// addOtacert(outputJar, publicKeyFile, timestamp, manifest, hash);
GeneralUtils.signFile(manifest, publicKey, privateKey, outputJar);
signer.notifyClosing();
outputJar.close();
signer.finish();
}
catch (Exception e) {
throw new IOException(e);
}
} }
else { public void writeSignatureBlock(ByteArrayOutputStream temp)
// write to both output streams. out is the CMSTypedData signer and tee is the file. throws IOException,
out.write(b); CertificateEncodingException,
tee.write(b); OperatorCreationException,
CMSException {
GeneralUtils.writeSignatureBlock(this, publicKey, privateKey, temp);
}
public GeneralUtils.WholeFileSignerOutputStream getSigner() {
return signer;
} }
} }
} }
private static class CMSSigner implements CMSTypedData { // This class host functions that consumes inputstreams
private JarMap inputJar; // Uses JarMap (virtual random access JarFile in memory)
private InputStream publicKeyFile; public static class StreamUtils {
private X509Certificate publicKey;
private PrivateKey privateKey; /**
private OutputStream outputStream; * Add the hash(es) of every file to the manifest, creating it if
private final ASN1ObjectIdentifier type; * necessary.
private WholeFileSignerOutputStream signer; */
public CMSSigner(JarMap inputJar, InputStream publicKeyFile, private static Manifest addDigestsToManifest(JarMap jar, int hashes)
X509Certificate publicKey, PrivateKey privateKey, throws IOException, GeneralSecurityException {
OutputStream outputStream) { Manifest input = jar.getManifest();
this.inputJar = inputJar; Manifest output = new Manifest();
this.publicKeyFile = publicKeyFile; Attributes main = output.getMainAttributes();
this.publicKey = publicKey; if (input != null) {
this.privateKey = privateKey; main.putAll(input.getMainAttributes());
this.outputStream = outputStream; } else {
this.type = new ASN1ObjectIdentifier(CMSObjectIdentifiers.data.getId()); main.putValue("Manifest-Version", "1.0");
} main.putValue("Created-By", "1.0 (Android SignApk)");
public Object getContent() {
// Not supported, but still don't crash or return null
return 1;
}
public ASN1ObjectIdentifier getContentType() {
return type;
}
public void write(OutputStream out) throws IOException {
try {
signer = new WholeFileSignerOutputStream(out, outputStream);
JarOutputStream outputJar = new JarOutputStream(signer);
int hash = getDigestAlgorithm(publicKey);
// Assume the certificate is valid for at least an hour.
long timestamp = publicKey.getNotBefore().getTime() + 3600L * 1000;
Manifest manifest = addDigestsToManifest(inputJar, hash);
copyFiles(manifest, inputJar, outputJar, timestamp);
// Don't add Otacert, it's not an OTA
// addOtacert(outputJar, publicKeyFile, timestamp, manifest, hash);
signFile(manifest, inputJar, publicKey, privateKey, outputJar);
signer.notifyClosing();
outputJar.close();
signer.finish();
} }
catch (Exception e) { MessageDigest md_sha1 = null;
throw new IOException(e); MessageDigest md_sha256 = null;
if ((hashes & USE_SHA1) != 0) {
md_sha1 = MessageDigest.getInstance("SHA1");
} }
if ((hashes & USE_SHA256) != 0) {
md_sha256 = MessageDigest.getInstance("SHA256");
}
// We sort the input entries by name, and add them to the
// output manifest in sorted order. We expect that the output
// map will be deterministic.
/* JarMap is a TreeMap, so it's already sorted */
for (String name : jar.keySet()) {
JarEntry entry = jar.getJarEntry(name);
if (!entry.isDirectory() &&
(stripPattern == null || !stripPattern.matcher(name).matches())) {
byte[] buffer = jar.getStream(name).toByteArray();
if (md_sha1 != null) md_sha1.update(buffer, 0, buffer.length);
if (md_sha256 != null) md_sha256.update(buffer, 0, buffer.length);
Attributes attr = null;
if (input != null) attr = input.getAttributes(name);
attr = attr != null ? new Attributes(attr) : new Attributes();
if (md_sha1 != null) {
attr.putValue("SHA1-Digest",
new String(Base64.encode(md_sha1.digest()), "ASCII"));
}
if (md_sha256 != null) {
attr.putValue("SHA-256-Digest",
new String(Base64.encode(md_sha256.digest()), "ASCII"));
}
output.getEntries().put(name, attr);
}
}
return output;
} }
public void writeSignatureBlock(ByteArrayOutputStream temp)
throws IOException,
CertificateEncodingException,
OperatorCreationException,
CMSException {
ZipUtils.writeSignatureBlock(this, publicKey, privateKey, temp);
}
public WholeFileSignerOutputStream getSigner() {
return signer;
}
}
private static void signWholeFile(JarMap inputJar, InputStream publicKeyFile, /**
X509Certificate publicKey, PrivateKey privateKey, * Copy all the files in a manifest from input to output. We set
OutputStream outputStream) throws Exception { * the modification times in the output to a fixed time, so as to
CMSSigner cmsOut = new CMSSigner(inputJar, publicKeyFile, * reduce variation in the output file and make incremental OTAs
publicKey, privateKey, outputStream); * more efficient.
ByteArrayOutputStream temp = new ByteArrayOutputStream(); */
// put a readable message and a null char at the start of the private static void copyFiles(Manifest manifest,
// archive comment, so that tools that display the comment JarMap in, JarOutputStream out, long timestamp) throws IOException {
// (hopefully) show something sensible. Map<String, Attributes> entries = manifest.getEntries();
// TODO: anything more useful we can put in this message? ArrayList<String> names = new ArrayList<>(entries.keySet());
byte[] message = "signed by SignApk".getBytes("UTF-8"); Collections.sort(names);
temp.write(message); for (String name : names) {
temp.write(0); JarEntry inEntry = in.getJarEntry(name);
cmsOut.writeSignatureBlock(temp); JarEntry outEntry;
byte[] zipData = cmsOut.getSigner().getTail(); if (inEntry.getMethod() == JarEntry.STORED) {
// For a zip with no archive comment, the // Preserve the STORED method of the input entry.
// end-of-central-directory record will be 22 bytes long, so outEntry = new JarEntry(inEntry);
// we expect to find the EOCD marker 22 bytes from the end. } else {
if (zipData[zipData.length-22] != 0x50 || // Create a new entry so that the compressed len is recomputed.
zipData[zipData.length-21] != 0x4b || outEntry = new JarEntry(name);
zipData[zipData.length-20] != 0x05 || }
zipData[zipData.length-19] != 0x06) { outEntry.setTime(timestamp);
throw new IllegalArgumentException("zip data already has an archive comment"); out.putNextEntry(outEntry);
in.getStream(name).writeTo(out);
out.flush();
}
} }
int total_size = temp.size() + 6;
if (total_size > 0xffff) { private static void signWholeFile(JarMap inputJar, InputStream publicKeyFile,
throw new IllegalArgumentException("signature is too big for ZIP file comment"); X509Certificate publicKey, PrivateKey privateKey,
OutputStream outputStream) throws Exception {
CMSSigner cmsOut = new CMSSigner(inputJar, publicKeyFile,
publicKey, privateKey, outputStream);
ByteArrayOutputStream temp = new ByteArrayOutputStream();
// put a readable message and a null char at the start of the
// archive comment, so that tools that display the comment
// (hopefully) show something sensible.
// TODO: anything more useful we can put in this message?
byte[] message = "signed by SignApk".getBytes("UTF-8");
temp.write(message);
temp.write(0);
cmsOut.writeSignatureBlock(temp);
byte[] zipData = cmsOut.getSigner().getTail();
// For a zip with no archive comment, the
// end-of-central-directory record will be 22 bytes long, so
// we expect to find the EOCD marker 22 bytes from the end.
if (zipData[zipData.length-22] != 0x50 ||
zipData[zipData.length-21] != 0x4b ||
zipData[zipData.length-20] != 0x05 ||
zipData[zipData.length-19] != 0x06) {
throw new IllegalArgumentException("zip data already has an archive comment");
}
int total_size = temp.size() + 6;
if (total_size > 0xffff) {
throw new IllegalArgumentException("signature is too big for ZIP file comment");
}
// signature starts this many bytes from the end of the file
int signature_start = total_size - message.length - 1;
temp.write(signature_start & 0xff);
temp.write((signature_start >> 8) & 0xff);
// Why the 0xff bytes? In a zip file with no archive comment,
// bytes [-6:-2] of the file are the little-endian offset from
// the start of the file to the central directory. So for the
// two high bytes to be 0xff 0xff, the archive would have to
// be nearly 4GB in size. So it's unlikely that a real
// commentless archive would have 0xffs here, and lets us tell
// an old signed archive from a new one.
temp.write(0xff);
temp.write(0xff);
temp.write(total_size & 0xff);
temp.write((total_size >> 8) & 0xff);
temp.flush();
// Signature verification checks that the EOCD header is the
// last such sequence in the file (to avoid minzip finding a
// fake EOCD appended after the signature in its scan). The
// odds of producing this sequence by chance are very low, but
// let's catch it here if it does.
byte[] b = temp.toByteArray();
for (int i = 0; i < b.length-3; ++i) {
if (b[i] == 0x50 && b[i+1] == 0x4b && b[i+2] == 0x05 && b[i+3] == 0x06) {
throw new IllegalArgumentException("found spurious EOCD header at " + i);
}
}
outputStream.write(total_size & 0xff);
outputStream.write((total_size >> 8) & 0xff);
temp.writeTo(outputStream);
} }
// signature starts this many bytes from the end of the file
int signature_start = total_size - message.length - 1; public static class JarMap extends TreeMap<String, Pair<JarEntry, ByteArrayOutputStream> > {
temp.write(signature_start & 0xff);
temp.write((signature_start >> 8) & 0xff); private Manifest manifest;
// Why the 0xff bytes? In a zip file with no archive comment,
// bytes [-6:-2] of the file are the little-endian offset from public JarMap(JarInputStream in) throws IOException {
// the start of the file to the central directory. So for the super();
// two high bytes to be 0xff 0xff, the archive would have to manifest = in.getManifest();
// be nearly 4GB in size. So it's unlikely that a real byte[] buffer = new byte[4096];
// commentless archive would have 0xffs here, and lets us tell int num;
// an old signed archive from a new one. JarEntry entry;
temp.write(0xff); while ((entry = in.getNextJarEntry()) != null) {
temp.write(0xff); ByteArrayOutputStream stream = new ByteArrayOutputStream();
temp.write(total_size & 0xff); while ((num = in.read(buffer)) > 0) {
temp.write((total_size >> 8) & 0xff); stream.write(buffer, 0, num);
temp.flush(); }
// Signature verification checks that the EOCD header is the put(entry.getName(), entry, stream);
// last such sequence in the file (to avoid minzip finding a }
// fake EOCD appended after the signature in its scan). The in.close();
// odds of producing this sequence by chance are very low, but }
// let's catch it here if it does.
byte[] b = temp.toByteArray(); public JarEntry getJarEntry(String name) {
for (int i = 0; i < b.length-3; ++i) { return get(name).first;
if (b[i] == 0x50 && b[i+1] == 0x4b && b[i+2] == 0x05 && b[i+3] == 0x06) { }
throw new IllegalArgumentException("found spurious EOCD header at " + i); public ByteArrayOutputStream getStream(String name) {
return get(name).second;
}
public void put(String name, JarEntry entry, ByteArrayOutputStream stream) {
put(name, new Pair<>(entry, stream));
}
public Manifest getManifest() {
return manifest;
}
public Enumeration<JarEntry> entries() {
Iterator<Entry<String, Pair<JarEntry, ByteArrayOutputStream> >> i = entrySet().iterator();
ArrayList<JarEntry> list = new ArrayList<>();
while (i.hasNext())
list.add(i.next().getValue().first);
return Collections.enumeration(list);
} }
} }
outputStream.write(total_size & 0xff);
outputStream.write((total_size >> 8) & 0xff);
temp.writeTo(outputStream);
}
private static void signFile(Manifest manifest, JarMap inputJar, private static class CMSSigner implements CMSTypedData {
X509Certificate publicKey, PrivateKey privateKey, private JarMap inputJar;
JarOutputStream outputJar) private InputStream publicKeyFile;
throws Exception { private X509Certificate publicKey;
// Assume the certificate is valid for at least an hour. private PrivateKey privateKey;
long timestamp = publicKey.getNotBefore().getTime() + 3600L * 1000; private OutputStream outputStream;
// MANIFEST.MF private final ASN1ObjectIdentifier type;
JarEntry je = new JarEntry(JarFile.MANIFEST_NAME); private GeneralUtils.WholeFileSignerOutputStream signer;
je.setTime(timestamp); public CMSSigner(JarMap inputJar, InputStream publicKeyFile,
outputJar.putNextEntry(je); X509Certificate publicKey, PrivateKey privateKey,
manifest.write(outputJar); OutputStream outputStream) {
// CERT.SF / CERT#.SF this.inputJar = inputJar;
je = new JarEntry(CERT_SF_NAME); this.publicKeyFile = publicKeyFile;
je.setTime(timestamp); this.publicKey = publicKey;
outputJar.putNextEntry(je); this.privateKey = privateKey;
ByteArrayOutputStream baos = new ByteArrayOutputStream(); this.outputStream = outputStream;
writeSignatureFile(manifest, baos, getDigestAlgorithm(publicKey)); this.type = new ASN1ObjectIdentifier(CMSObjectIdentifiers.data.getId());
byte[] signedData = baos.toByteArray(); }
outputJar.write(signedData); public Object getContent() {
// CERT.{DSA,EC,RSA} / CERT#.{DSA,EC,RSA} // Not supported, but still don't crash or return null
je = new JarEntry((String.format(CERT_SIG_NAME, privateKey.getAlgorithm()))); return 1;
je.setTime(timestamp); }
outputJar.putNextEntry(je); public ASN1ObjectIdentifier getContentType() {
writeSignatureBlock(new CMSProcessableByteArray(signedData), return type;
publicKey, privateKey, outputJar); }
public void write(OutputStream out) throws IOException {
try {
signer = new GeneralUtils.WholeFileSignerOutputStream(out, outputStream);
JarOutputStream outputJar = new JarOutputStream(signer);
int hash = FileUtils.getDigestAlgorithm(publicKey);
// Assume the certificate is valid for at least an hour.
long timestamp = publicKey.getNotBefore().getTime() + 3600L * 1000;
Manifest manifest = addDigestsToManifest(inputJar, hash);
copyFiles(manifest, inputJar, outputJar, timestamp);
// Don't add Otacert, it's not an OTA
// addOtacert(outputJar, publicKeyFile, timestamp, manifest, hash);
GeneralUtils.signFile(manifest, publicKey, privateKey, outputJar);
signer.notifyClosing();
outputJar.close();
signer.finish();
}
catch (Exception e) {
throw new IOException(e);
}
}
public void writeSignatureBlock(ByteArrayOutputStream temp)
throws IOException,
CertificateEncodingException,
OperatorCreationException,
CMSException {
GeneralUtils.writeSignatureBlock(this, publicKey, privateKey, temp);
}
public GeneralUtils.WholeFileSignerOutputStream getSigner() {
return signer;
}
}
} }
} }
...@@ -4,10 +4,15 @@ ...@@ -4,10 +4,15 @@
#include <jni.h> #include <jni.h>
#include <stdlib.h> #include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "zipadjust.h" #include "zipadjust.h"
JNIEXPORT jbyteArray JNICALL JNIEXPORT jbyteArray JNICALL
Java_com_topjohnwu_magisk_utils_ZipUtils_zipAdjust(JNIEnv *env, jclass type, jbyteArray jbytes, jint size) { Java_com_topjohnwu_magisk_utils_ZipUtils_zipAdjust___3BI(JNIEnv *env, jclass type,
jbyteArray jbytes, jint size) {
fin = (*env)->GetPrimitiveArrayCritical(env, jbytes, NULL); fin = (*env)->GetPrimitiveArrayCritical(env, jbytes, NULL);
insize = (size_t) size; insize = (size_t) size;
...@@ -21,3 +26,37 @@ Java_com_topjohnwu_magisk_utils_ZipUtils_zipAdjust(JNIEnv *env, jclass type, jby ...@@ -21,3 +26,37 @@ Java_com_topjohnwu_magisk_utils_ZipUtils_zipAdjust(JNIEnv *env, jclass type, jby
return ret; return ret;
} }
JNIEXPORT void JNICALL
Java_com_topjohnwu_magisk_utils_ZipUtils_zipAdjust__Ljava_lang_String_2(JNIEnv *env, jclass type, jstring name) {
const char *filename = (*env)->GetStringUTFChars(env, name, NULL);
int fd = open(filename, O_RDONLY);
if (fd < 0)
return;
// Load the file to memory
insize = lseek(fd, 0, SEEK_END);
lseek(fd, 0, SEEK_SET);
fin = malloc(insize);
read(fd, fin, insize);
zipadjust(0);
close(fd);
// Open file for output
fd = open(filename, O_WRONLY | O_TRUNC);
if (fd < 0)
return;
(*env)->ReleaseStringUTFChars(env, name, filename);
// Write back to file
lseek(fd, 0, SEEK_SET);
write(fd, fout, outsize);
close(fd);
free(fin);
free(fout);
}
#include <stdlib.h> #include <stdlib.h>
#include <stdio.h> #include <stdio.h>
#include <zlib.h> #include <zlib.h>
#include <android/log.h>
#include "zipadjust.h" #include "zipadjust.h"
#define LOG_TAG "zipadjust"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
size_t insize = 0, outsize = 0, alloc = 0; size_t insize = 0, outsize = 0, alloc = 0;
unsigned char *fin = NULL, *fout = NULL; unsigned char *fin = NULL, *fout = NULL;
......
#ifndef MAGISKMANAGER_ZIPADJUST_H_H #ifndef MAGISKMANAGER_ZIPADJUST_H_H
#define MAGISKMANAGER_ZIPADJUST_H_H #define MAGISKMANAGER_ZIPADJUST_H_H
#include <android/log.h>
int zipadjust(int decompress); int zipadjust(int decompress);
extern size_t insize, outsize, alloc; extern size_t insize, outsize, alloc;
extern unsigned char *fin, *fout; extern unsigned char *fin, *fout;
#define LOG_TAG "zipadjust"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
#endif //MAGISKMANAGER_ZIPADJUST_H_H #endif //MAGISKMANAGER_ZIPADJUST_H_H
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment