50c7c5a722
When signapk.jar is invoked by scripts like sign_target_files_apks.py, there is no console as signapk is invoked using popen(). To support signing of APKs using software keys with passwords, we need to read the password from stdin if there is no console. Change-Id: Icf69ba1e58bf1f91979eaf1d3b91cb202782e8fd Signed-off-by: adattatr <anisha.dattatraya.kulkarni@intel.com> Signed-off-by: Brad Geltz <brad.geltz@intel.com>
963 lines
37 KiB
Java
963 lines
37 KiB
Java
/*
|
|
* Copyright (C) 2008 The Android Open Source Project
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package com.android.signapk;
|
|
|
|
import org.bouncycastle.asn1.ASN1InputStream;
|
|
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
|
import org.bouncycastle.asn1.DEROutputStream;
|
|
import org.bouncycastle.asn1.cms.CMSObjectIdentifiers;
|
|
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
|
|
import org.bouncycastle.cert.jcajce.JcaCertStore;
|
|
import org.bouncycastle.cms.CMSException;
|
|
import org.bouncycastle.cms.CMSProcessableByteArray;
|
|
import org.bouncycastle.cms.CMSSignedData;
|
|
import org.bouncycastle.cms.CMSSignedDataGenerator;
|
|
import org.bouncycastle.cms.CMSTypedData;
|
|
import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder;
|
|
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
|
import org.bouncycastle.operator.ContentSigner;
|
|
import org.bouncycastle.operator.OperatorCreationException;
|
|
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
|
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
|
|
import org.bouncycastle.util.encoders.Base64;
|
|
|
|
import java.io.Console;
|
|
import java.io.BufferedReader;
|
|
import java.io.ByteArrayInputStream;
|
|
import java.io.ByteArrayOutputStream;
|
|
import java.io.DataInputStream;
|
|
import java.io.File;
|
|
import java.io.FileInputStream;
|
|
import java.io.FileOutputStream;
|
|
import java.io.FilterOutputStream;
|
|
import java.io.IOException;
|
|
import java.io.InputStream;
|
|
import java.io.InputStreamReader;
|
|
import java.io.OutputStream;
|
|
import java.io.PrintStream;
|
|
import java.lang.reflect.Constructor;
|
|
import java.security.DigestOutputStream;
|
|
import java.security.GeneralSecurityException;
|
|
import java.security.Key;
|
|
import java.security.KeyFactory;
|
|
import java.security.MessageDigest;
|
|
import java.security.PrivateKey;
|
|
import java.security.Provider;
|
|
import java.security.Security;
|
|
import java.security.cert.CertificateEncodingException;
|
|
import java.security.cert.CertificateFactory;
|
|
import java.security.cert.X509Certificate;
|
|
import java.security.spec.InvalidKeySpecException;
|
|
import java.security.spec.PKCS8EncodedKeySpec;
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.Enumeration;
|
|
import java.util.Locale;
|
|
import java.util.Map;
|
|
import java.util.TreeMap;
|
|
import java.util.jar.Attributes;
|
|
import java.util.jar.JarEntry;
|
|
import java.util.jar.JarFile;
|
|
import java.util.jar.JarOutputStream;
|
|
import java.util.jar.Manifest;
|
|
import java.util.regex.Pattern;
|
|
import javax.crypto.Cipher;
|
|
import javax.crypto.EncryptedPrivateKeyInfo;
|
|
import javax.crypto.SecretKeyFactory;
|
|
import javax.crypto.spec.PBEKeySpec;
|
|
|
|
/**
|
|
* HISTORICAL NOTE:
|
|
*
|
|
* Prior to the keylimepie release, SignApk ignored the signature
|
|
* algorithm specified in the certificate and always used SHA1withRSA.
|
|
*
|
|
* Starting with JB-MR2, the platform supports SHA256withRSA, so we use
|
|
* the signature algorithm in the certificate to select which to use
|
|
* (SHA256withRSA or SHA1withRSA). Also in JB-MR2, EC keys are supported.
|
|
*
|
|
* Because there are old keys still in use whose certificate actually
|
|
* says "MD5withRSA", we treat these as though they say "SHA1withRSA"
|
|
* for compatibility with older releases. This can be changed by
|
|
* altering the getAlgorithm() function below.
|
|
*/
|
|
|
|
|
|
/**
|
|
* Command line tool to sign JAR files (including APKs and OTA updates) in a way
|
|
* compatible with the mincrypt verifier, using EC or RSA keys and SHA1 or
|
|
* SHA-256 (see historical note).
|
|
*/
|
|
class SignApk {
|
|
private static final String CERT_SF_NAME = "META-INF/CERT.SF";
|
|
private static final String CERT_SIG_NAME = "META-INF/CERT.%s";
|
|
private static final String CERT_SF_MULTI_NAME = "META-INF/CERT%d.SF";
|
|
private static final String CERT_SIG_MULTI_NAME = "META-INF/CERT%d.%s";
|
|
|
|
private static final String OTACERT_NAME = "META-INF/com/android/otacert";
|
|
|
|
private static Provider sBouncyCastleProvider;
|
|
|
|
// bitmasks for which hash algorithms we need the manifest to include.
|
|
private static final int USE_SHA1 = 1;
|
|
private static final int USE_SHA256 = 2;
|
|
|
|
/**
|
|
* Return one of USE_SHA1 or USE_SHA256 according to the signature
|
|
* algorithm specified in the cert.
|
|
*/
|
|
private static int getDigestAlgorithm(X509Certificate cert) {
|
|
String sigAlg = cert.getSigAlgName().toUpperCase(Locale.US);
|
|
if ("SHA1WITHRSA".equals(sigAlg) ||
|
|
"MD5WITHRSA".equals(sigAlg)) { // see "HISTORICAL NOTE" above.
|
|
return USE_SHA1;
|
|
} else if (sigAlg.startsWith("SHA256WITH")) {
|
|
return USE_SHA256;
|
|
} else {
|
|
throw new IllegalArgumentException("unsupported signature algorithm \"" + sigAlg +
|
|
"\" in cert [" + cert.getSubjectDN());
|
|
}
|
|
}
|
|
|
|
/** Returns the expected signature algorithm for this key type. */
|
|
private static String getSignatureAlgorithm(X509Certificate cert) {
|
|
String sigAlg = cert.getSigAlgName().toUpperCase(Locale.US);
|
|
String keyType = cert.getPublicKey().getAlgorithm().toUpperCase(Locale.US);
|
|
if ("RSA".equalsIgnoreCase(keyType)) {
|
|
if (getDigestAlgorithm(cert) == USE_SHA256) {
|
|
return "SHA256withRSA";
|
|
} else {
|
|
return "SHA1withRSA";
|
|
}
|
|
} else if ("EC".equalsIgnoreCase(keyType)) {
|
|
return "SHA256withECDSA";
|
|
} 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(File file)
|
|
throws IOException, GeneralSecurityException {
|
|
FileInputStream input = new FileInputStream(file);
|
|
try {
|
|
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
|
return (X509Certificate) cf.generateCertificate(input);
|
|
} finally {
|
|
input.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* If a console doesn't exist, reads the password from stdin
|
|
* If a console exists, reads the password from console and returns it as a string.
|
|
*
|
|
* @param keyFile The file containing the private key. Used to prompt the user.
|
|
*/
|
|
private static String readPassword(File keyFile) {
|
|
Console console;
|
|
char[] pwd;
|
|
if ((console = System.console()) == null) {
|
|
System.out.print("Enter password for " + keyFile + " (password will not be hidden): ");
|
|
System.out.flush();
|
|
BufferedReader stdin = new BufferedReader(new InputStreamReader(System.in));
|
|
try {
|
|
return stdin.readLine();
|
|
} catch (IOException ex) {
|
|
return null;
|
|
}
|
|
} else {
|
|
if ((pwd = console.readPassword("[%s]", "Enter password for " + keyFile)) != null) {
|
|
return String.valueOf(pwd);
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decrypt an encrypted PKCS#8 format private key.
|
|
*
|
|
* Based on ghstark's post on Aug 6, 2006 at
|
|
* http://forums.sun.com/thread.jspa?threadID=758133&messageID=4330949
|
|
*
|
|
* @param encryptedPrivateKey The raw data of the private key
|
|
* @param keyFile The file containing the private key
|
|
*/
|
|
private static PKCS8EncodedKeySpec decryptPrivateKey(byte[] encryptedPrivateKey, File keyFile)
|
|
throws GeneralSecurityException {
|
|
EncryptedPrivateKeyInfo epkInfo;
|
|
try {
|
|
epkInfo = new EncryptedPrivateKeyInfo(encryptedPrivateKey);
|
|
} catch (IOException ex) {
|
|
// Probably not an encrypted key.
|
|
return null;
|
|
}
|
|
|
|
char[] password = readPassword(keyFile).toCharArray();
|
|
|
|
SecretKeyFactory skFactory = SecretKeyFactory.getInstance(epkInfo.getAlgName());
|
|
Key key = skFactory.generateSecret(new PBEKeySpec(password));
|
|
|
|
Cipher cipher = Cipher.getInstance(epkInfo.getAlgName());
|
|
cipher.init(Cipher.DECRYPT_MODE, key, epkInfo.getAlgParameters());
|
|
|
|
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(File file)
|
|
throws IOException, GeneralSecurityException {
|
|
DataInputStream input = new DataInputStream(new FileInputStream(file));
|
|
try {
|
|
byte[] bytes = new byte[(int) file.length()];
|
|
input.read(bytes);
|
|
|
|
/* Check to see if this is in an EncryptedPrivateKeyInfo structure. */
|
|
PKCS8EncodedKeySpec spec = decryptPrivateKey(bytes, file);
|
|
if (spec == null) {
|
|
spec = new PKCS8EncodedKeySpec(bytes);
|
|
}
|
|
|
|
/*
|
|
* Now it's in a PKCS#8 PrivateKeyInfo structure. Read its Algorithm
|
|
* OID and use that to construct a KeyFactory.
|
|
*/
|
|
ASN1InputStream bIn = new ASN1InputStream(new ByteArrayInputStream(spec.getEncoded()));
|
|
PrivateKeyInfo pki = PrivateKeyInfo.getInstance(bIn.readObject());
|
|
String algOid = pki.getPrivateKeyAlgorithm().getAlgorithm().getId();
|
|
|
|
return KeyFactory.getInstance(algOid).generatePrivate(spec);
|
|
} finally {
|
|
input.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add the hash(es) of every file to the manifest, creating it if
|
|
* necessary.
|
|
*/
|
|
private static Manifest addDigestsToManifest(JarFile jar, int hashes)
|
|
throws IOException, GeneralSecurityException {
|
|
Manifest input = jar.getManifest();
|
|
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)");
|
|
}
|
|
|
|
MessageDigest md_sha1 = null;
|
|
MessageDigest md_sha256 = null;
|
|
if ((hashes & USE_SHA1) != 0) {
|
|
md_sha1 = MessageDigest.getInstance("SHA1");
|
|
}
|
|
if ((hashes & USE_SHA256) != 0) {
|
|
md_sha256 = MessageDigest.getInstance("SHA256");
|
|
}
|
|
|
|
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();
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Add a copy of the public key to the archive; this should
|
|
* exactly match one of the files in
|
|
* /system/etc/security/otacerts.zip on the device. (The same
|
|
* cert can be extracted from the CERT.RSA file but this is much
|
|
* easier to get at.)
|
|
*/
|
|
private static void addOtacert(JarOutputStream outputJar,
|
|
File publicKeyFile,
|
|
long timestamp,
|
|
Manifest manifest,
|
|
int hash)
|
|
throws IOException, GeneralSecurityException {
|
|
MessageDigest md = MessageDigest.getInstance(hash == USE_SHA1 ? "SHA1" : "SHA256");
|
|
|
|
JarEntry je = new JarEntry(OTACERT_NAME);
|
|
je.setTime(timestamp);
|
|
outputJar.putNextEntry(je);
|
|
FileInputStream input = new FileInputStream(publicKeyFile);
|
|
byte[] b = new byte[4096];
|
|
int read;
|
|
while ((read = input.read(b)) != -1) {
|
|
outputJar.write(b, 0, read);
|
|
md.update(b, 0, read);
|
|
}
|
|
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
|
|
* written.
|
|
*/
|
|
private static class CountOutputStream extends FilterOutputStream {
|
|
private int mCount;
|
|
|
|
public CountOutputStream(OutputStream out) {
|
|
super(out);
|
|
mCount = 0;
|
|
}
|
|
|
|
@Override
|
|
public void write(int b) throws IOException {
|
|
super.write(b);
|
|
mCount++;
|
|
}
|
|
|
|
@Override
|
|
public void write(byte[] b, int off, int len) throws IOException {
|
|
super.write(b, off, len);
|
|
mCount += len;
|
|
}
|
|
|
|
public int size() {
|
|
return mCount;
|
|
}
|
|
}
|
|
|
|
/** Write a .SF file with a digest of the specified manifest. */
|
|
private static void writeSignatureFile(Manifest manifest, OutputStream out,
|
|
int hash)
|
|
throws IOException, GeneralSecurityException {
|
|
Manifest sf = new Manifest();
|
|
Attributes main = sf.getMainAttributes();
|
|
main.putValue("Signature-Version", "1.0");
|
|
main.putValue("Created-By", "1.0 (Android SignApk)");
|
|
|
|
MessageDigest md = MessageDigest.getInstance(
|
|
hash == USE_SHA256 ? "SHA256" : "SHA1");
|
|
PrintStream print = new PrintStream(
|
|
new DigestOutputStream(new ByteArrayOutputStream(), md),
|
|
true, "UTF-8");
|
|
|
|
// Digest of the entire manifest
|
|
manifest.write(print);
|
|
print.flush();
|
|
main.putValue(hash == USE_SHA256 ? "SHA-256-Digest-Manifest" : "SHA1-Digest-Manifest",
|
|
new String(Base64.encode(md.digest()), "ASCII"));
|
|
|
|
Map<String, Attributes> entries = manifest.getEntries();
|
|
for (Map.Entry<String, Attributes> entry : entries.entrySet()) {
|
|
// Digest of the manifest stanza for this entry.
|
|
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 void writeSignatureBlock(
|
|
CMSTypedData data, X509Certificate publicKey, PrivateKey privateKey,
|
|
OutputStream out)
|
|
throws IOException,
|
|
CertificateEncodingException,
|
|
OperatorCreationException,
|
|
CMSException {
|
|
ArrayList<X509Certificate> certList = new ArrayList<X509Certificate>(1);
|
|
certList.add(publicKey);
|
|
JcaCertStore certs = new JcaCertStore(certList);
|
|
|
|
CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
|
|
ContentSigner signer = new JcaContentSignerBuilder(getSignatureAlgorithm(publicKey))
|
|
.setProvider(sBouncyCastleProvider)
|
|
.build(privateKey);
|
|
gen.addSignerInfoGenerator(
|
|
new JcaSignerInfoGeneratorBuilder(
|
|
new JcaDigestCalculatorProviderBuilder()
|
|
.setProvider(sBouncyCastleProvider)
|
|
.build())
|
|
.setDirectSignature(true)
|
|
.build(signer, publicKey));
|
|
gen.addCertificates(certs);
|
|
CMSSignedData sigData = gen.generate(data, false);
|
|
|
|
ASN1InputStream asn1 = new ASN1InputStream(sigData.getEncoded());
|
|
DEROutputStream dos = new DEROutputStream(out);
|
|
dos.writeObject(asn1.readObject());
|
|
}
|
|
|
|
/**
|
|
* Copy all the files in a manifest from input to output. We set
|
|
* the modification times in the output to a fixed time, so as to
|
|
* reduce variation in the output file and make incremental OTAs
|
|
* more efficient.
|
|
*/
|
|
private static void copyFiles(Manifest manifest, JarFile in, JarOutputStream out,
|
|
long timestamp, int alignment) 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);
|
|
|
|
boolean firstEntry = true;
|
|
long offset = 0L;
|
|
|
|
// We do the copy in two passes -- first copying all the
|
|
// entries that are STORED, then copying all the entries that
|
|
// have any other compression flag (which in practice means
|
|
// DEFLATED). This groups all the stored entries together at
|
|
// the start of the file and makes it easier to do alignment
|
|
// on them (since only stored entries are aligned).
|
|
|
|
for (String name : names) {
|
|
JarEntry inEntry = in.getJarEntry(name);
|
|
JarEntry outEntry = null;
|
|
if (inEntry.getMethod() != JarEntry.STORED) continue;
|
|
// Preserve the STORED method of the input entry.
|
|
outEntry = new JarEntry(inEntry);
|
|
outEntry.setTime(timestamp);
|
|
|
|
// 'offset' is the offset into the file at which we expect
|
|
// the file data to begin. This is the value we need to
|
|
// make a multiple of 'alignement'.
|
|
offset += JarFile.LOCHDR + outEntry.getName().length();
|
|
if (firstEntry) {
|
|
// The first entry in a jar file has an extra field of
|
|
// four bytes that you can't get rid of; any extra
|
|
// data you specify in the JarEntry is appended to
|
|
// these forced four bytes. This is JAR_MAGIC in
|
|
// JarOutputStream; the bytes are 0xfeca0000.
|
|
offset += 4;
|
|
firstEntry = false;
|
|
}
|
|
if (alignment > 0 && (offset % alignment != 0)) {
|
|
// Set the "extra data" of the entry to between 1 and
|
|
// alignment-1 bytes, to make the file data begin at
|
|
// an aligned offset.
|
|
int needed = alignment - (int)(offset % alignment);
|
|
outEntry.setExtra(new byte[needed]);
|
|
offset += needed;
|
|
}
|
|
|
|
out.putNextEntry(outEntry);
|
|
|
|
InputStream data = in.getInputStream(inEntry);
|
|
while ((num = data.read(buffer)) > 0) {
|
|
out.write(buffer, 0, num);
|
|
offset += num;
|
|
}
|
|
out.flush();
|
|
}
|
|
|
|
// Copy all the non-STORED entries. We don't attempt to
|
|
// maintain the 'offset' variable past this point; we don't do
|
|
// alignment on these entries.
|
|
|
|
for (String name : names) {
|
|
JarEntry inEntry = in.getJarEntry(name);
|
|
JarEntry outEntry = null;
|
|
if (inEntry.getMethod() == JarEntry.STORED) continue;
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
private static class WholeFileSignerOutputStream extends FilterOutputStream {
|
|
private boolean closing = false;
|
|
private ByteArrayOutputStream footer = new ByteArrayOutputStream();
|
|
private OutputStream tee;
|
|
|
|
public WholeFileSignerOutputStream(OutputStream out, OutputStream tee) {
|
|
super(out);
|
|
this.tee = tee;
|
|
}
|
|
|
|
public void notifyClosing() {
|
|
closing = true;
|
|
}
|
|
|
|
public void finish() throws IOException {
|
|
closing = false;
|
|
|
|
byte[] data = footer.toByteArray();
|
|
if (data.length < 2)
|
|
throw new IOException("Less than two bytes written to footer");
|
|
write(data, 0, data.length - 2);
|
|
}
|
|
|
|
public byte[] getTail() {
|
|
return footer.toByteArray();
|
|
}
|
|
|
|
@Override
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static class CMSSigner implements CMSTypedData {
|
|
private JarFile inputJar;
|
|
private File publicKeyFile;
|
|
private X509Certificate publicKey;
|
|
private PrivateKey privateKey;
|
|
private String outputFile;
|
|
private OutputStream outputStream;
|
|
private final ASN1ObjectIdentifier type;
|
|
private WholeFileSignerOutputStream signer;
|
|
|
|
public CMSSigner(JarFile inputJar, File 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());
|
|
}
|
|
|
|
/**
|
|
* This should actually return byte[] or something similar, but nothing
|
|
* actually checks it currently.
|
|
*/
|
|
public Object getContent() {
|
|
return this;
|
|
}
|
|
|
|
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, 0);
|
|
addOtacert(outputJar, publicKeyFile, timestamp, manifest, hash);
|
|
|
|
signFile(manifest, inputJar,
|
|
new X509Certificate[]{ publicKey },
|
|
new PrivateKey[]{ 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 {
|
|
SignApk.writeSignatureBlock(this, publicKey, privateKey, temp);
|
|
}
|
|
|
|
public WholeFileSignerOutputStream getSigner() {
|
|
return signer;
|
|
}
|
|
}
|
|
|
|
private static void signWholeFile(JarFile inputJar, File publicKeyFile,
|
|
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);
|
|
}
|
|
|
|
private static void signFile(Manifest manifest, JarFile inputJar,
|
|
X509Certificate[] publicKey, PrivateKey[] privateKey,
|
|
JarOutputStream outputJar)
|
|
throws Exception {
|
|
// Assume the certificate is valid for at least an hour.
|
|
long timestamp = publicKey[0].getNotBefore().getTime() + 3600L * 1000;
|
|
|
|
// MANIFEST.MF
|
|
JarEntry je = new JarEntry(JarFile.MANIFEST_NAME);
|
|
je.setTime(timestamp);
|
|
outputJar.putNextEntry(je);
|
|
manifest.write(outputJar);
|
|
|
|
int numKeys = publicKey.length;
|
|
for (int k = 0; k < numKeys; ++k) {
|
|
// CERT.SF / CERT#.SF
|
|
je = new JarEntry(numKeys == 1 ? CERT_SF_NAME :
|
|
(String.format(CERT_SF_MULTI_NAME, k)));
|
|
je.setTime(timestamp);
|
|
outputJar.putNextEntry(je);
|
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
|
writeSignatureFile(manifest, baos, getDigestAlgorithm(publicKey[k]));
|
|
byte[] signedData = baos.toByteArray();
|
|
outputJar.write(signedData);
|
|
|
|
// CERT.{EC,RSA} / CERT#.{EC,RSA}
|
|
final String keyType = publicKey[k].getPublicKey().getAlgorithm();
|
|
je = new JarEntry(numKeys == 1 ?
|
|
(String.format(CERT_SIG_NAME, keyType)) :
|
|
(String.format(CERT_SIG_MULTI_NAME, k, keyType)));
|
|
je.setTime(timestamp);
|
|
outputJar.putNextEntry(je);
|
|
writeSignatureBlock(new CMSProcessableByteArray(signedData),
|
|
publicKey[k], privateKey[k], outputJar);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tries to load a JSE Provider by class name. This is for custom PrivateKey
|
|
* types that might be stored in PKCS#11-like storage.
|
|
*/
|
|
private static void loadProviderIfNecessary(String providerClassName) {
|
|
if (providerClassName == null) {
|
|
return;
|
|
}
|
|
|
|
final Class<?> klass;
|
|
try {
|
|
final ClassLoader sysLoader = ClassLoader.getSystemClassLoader();
|
|
if (sysLoader != null) {
|
|
klass = sysLoader.loadClass(providerClassName);
|
|
} else {
|
|
klass = Class.forName(providerClassName);
|
|
}
|
|
} catch (ClassNotFoundException e) {
|
|
e.printStackTrace();
|
|
System.exit(1);
|
|
return;
|
|
}
|
|
|
|
Constructor<?> constructor = null;
|
|
for (Constructor<?> c : klass.getConstructors()) {
|
|
if (c.getParameterTypes().length == 0) {
|
|
constructor = c;
|
|
break;
|
|
}
|
|
}
|
|
if (constructor == null) {
|
|
System.err.println("No zero-arg constructor found for " + providerClassName);
|
|
System.exit(1);
|
|
return;
|
|
}
|
|
|
|
final Object o;
|
|
try {
|
|
o = constructor.newInstance();
|
|
} catch (Exception e) {
|
|
e.printStackTrace();
|
|
System.exit(1);
|
|
return;
|
|
}
|
|
if (!(o instanceof Provider)) {
|
|
System.err.println("Not a Provider class: " + providerClassName);
|
|
System.exit(1);
|
|
}
|
|
|
|
Security.insertProviderAt((Provider) o, 1);
|
|
}
|
|
|
|
private static void usage() {
|
|
System.err.println("Usage: signapk [-w] " +
|
|
"[-a <alignment>] " +
|
|
"[-providerClass <className>] " +
|
|
"publickey.x509[.pem] privatekey.pk8 " +
|
|
"[publickey2.x509[.pem] privatekey2.pk8 ...] " +
|
|
"input.jar output.jar");
|
|
System.exit(2);
|
|
}
|
|
|
|
public static void main(String[] args) {
|
|
if (args.length < 4) usage();
|
|
|
|
sBouncyCastleProvider = new BouncyCastleProvider();
|
|
Security.addProvider(sBouncyCastleProvider);
|
|
|
|
boolean signWholeFile = false;
|
|
String providerClass = null;
|
|
String providerArg = null;
|
|
int alignment = 4;
|
|
|
|
int argstart = 0;
|
|
while (argstart < args.length && args[argstart].startsWith("-")) {
|
|
if ("-w".equals(args[argstart])) {
|
|
signWholeFile = true;
|
|
++argstart;
|
|
} else if ("-providerClass".equals(args[argstart])) {
|
|
if (argstart + 1 >= args.length) {
|
|
usage();
|
|
}
|
|
providerClass = args[++argstart];
|
|
++argstart;
|
|
} else if ("-a".equals(args[argstart])) {
|
|
alignment = Integer.parseInt(args[++argstart]);
|
|
++argstart;
|
|
} else {
|
|
usage();
|
|
}
|
|
}
|
|
|
|
if ((args.length - argstart) % 2 == 1) usage();
|
|
int numKeys = ((args.length - argstart) / 2) - 1;
|
|
if (signWholeFile && numKeys > 1) {
|
|
System.err.println("Only one key may be used with -w.");
|
|
System.exit(2);
|
|
}
|
|
|
|
loadProviderIfNecessary(providerClass);
|
|
|
|
String inputFilename = args[args.length-2];
|
|
String outputFilename = args[args.length-1];
|
|
|
|
JarFile inputJar = null;
|
|
FileOutputStream outputFile = null;
|
|
int hashes = 0;
|
|
|
|
try {
|
|
File firstPublicKeyFile = new File(args[argstart+0]);
|
|
|
|
X509Certificate[] publicKey = new X509Certificate[numKeys];
|
|
try {
|
|
for (int i = 0; i < numKeys; ++i) {
|
|
int argNum = argstart + i*2;
|
|
publicKey[i] = readPublicKey(new File(args[argNum]));
|
|
hashes |= getDigestAlgorithm(publicKey[i]);
|
|
}
|
|
} catch (IllegalArgumentException e) {
|
|
System.err.println(e);
|
|
System.exit(1);
|
|
}
|
|
|
|
// 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[0].getNotBefore().getTime() + 3600L * 1000;
|
|
|
|
PrivateKey[] privateKey = new PrivateKey[numKeys];
|
|
for (int i = 0; i < numKeys; ++i) {
|
|
int argNum = argstart + i*2 + 1;
|
|
privateKey[i] = readPrivateKey(new File(args[argNum]));
|
|
}
|
|
inputJar = new JarFile(new File(inputFilename), false); // Don't verify.
|
|
|
|
outputFile = new FileOutputStream(outputFilename);
|
|
|
|
|
|
if (signWholeFile) {
|
|
SignApk.signWholeFile(inputJar, firstPublicKeyFile,
|
|
publicKey[0], privateKey[0], outputFile);
|
|
} else {
|
|
JarOutputStream outputJar = new JarOutputStream(outputFile);
|
|
|
|
// 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 = addDigestsToManifest(inputJar, hashes);
|
|
copyFiles(manifest, inputJar, outputJar, timestamp, alignment);
|
|
signFile(manifest, inputJar, publicKey, privateKey, outputJar);
|
|
outputJar.close();
|
|
}
|
|
} catch (Exception e) {
|
|
e.printStackTrace();
|
|
System.exit(1);
|
|
} finally {
|
|
try {
|
|
if (inputJar != null) inputJar.close();
|
|
if (outputFile != null) outputFile.close();
|
|
} catch (IOException e) {
|
|
e.printStackTrace();
|
|
System.exit(1);
|
|
}
|
|
}
|
|
}
|
|
}
|