af482b62e5
Change signapk to not propagate other signatures to the output archive. Multiple signatures seem to confuse the package manager, as we saw with Maps, and other partners are checking in prebuilt APKs for google experience devices signed with random other things.
397 lines
14 KiB
Java
397 lines
14 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 sun.misc.BASE64Encoder;
|
|
import sun.security.pkcs.ContentInfo;
|
|
import sun.security.pkcs.PKCS7;
|
|
import sun.security.pkcs.SignerInfo;
|
|
import sun.security.x509.AlgorithmId;
|
|
import sun.security.x509.X500Name;
|
|
|
|
import java.io.BufferedReader;
|
|
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.security.AlgorithmParameters;
|
|
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.Signature;
|
|
import java.security.SignatureException;
|
|
import java.security.cert.Certificate;
|
|
import java.security.cert.CertificateFactory;
|
|
import java.security.cert.X509Certificate;
|
|
import java.security.spec.InvalidKeySpecException;
|
|
import java.security.spec.KeySpec;
|
|
import java.security.spec.PKCS8EncodedKeySpec;
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.Date;
|
|
import java.util.Enumeration;
|
|
import java.util.List;
|
|
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;
|
|
|
|
/**
|
|
* Command line tool to sign JAR files (including APKs and OTA updates) in
|
|
* a way compatible with the mincrypt verifier, using SHA1 and RSA keys.
|
|
*/
|
|
class SignApk {
|
|
private static final String CERT_SF_NAME = "META-INF/CERT.SF";
|
|
private static final String CERT_RSA_NAME = "META-INF/CERT.RSA";
|
|
|
|
// Files matching this pattern are not copied to the output.
|
|
private static Pattern stripPattern =
|
|
Pattern.compile("^META-INF/(.*)[.](SF|RSA|DSA)$");
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads the password from stdin 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) {
|
|
// TODO: use Console.readPassword() when it's available.
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 KeySpec 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);
|
|
|
|
KeySpec spec = decryptPrivateKey(bytes, file);
|
|
if (spec == null) {
|
|
spec = new PKCS8EncodedKeySpec(bytes);
|
|
}
|
|
|
|
try {
|
|
return KeyFactory.getInstance("RSA").generatePrivate(spec);
|
|
} catch (InvalidKeySpecException ex) {
|
|
return KeyFactory.getInstance("DSA").generatePrivate(spec);
|
|
}
|
|
} finally {
|
|
input.close();
|
|
}
|
|
}
|
|
|
|
/** Add the SHA1 of every file to the manifest, creating it if necessary. */
|
|
private static Manifest addDigestsToManifest(JarFile jar)
|
|
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)");
|
|
}
|
|
|
|
BASE64Encoder base64 = new BASE64Encoder();
|
|
MessageDigest md = MessageDigest.getInstance("SHA1");
|
|
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() && !name.equals(JarFile.MANIFEST_NAME) &&
|
|
!name.equals(CERT_SF_NAME) && !name.equals(CERT_RSA_NAME) &&
|
|
(stripPattern == null ||
|
|
!stripPattern.matcher(name).matches())) {
|
|
InputStream data = jar.getInputStream(entry);
|
|
while ((num = data.read(buffer)) > 0) {
|
|
md.update(buffer, 0, num);
|
|
}
|
|
|
|
Attributes attr = null;
|
|
if (input != null) attr = input.getAttributes(name);
|
|
attr = attr != null ? new Attributes(attr) : new Attributes();
|
|
attr.putValue("SHA1-Digest", base64.encode(md.digest()));
|
|
output.getEntries().put(name, attr);
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
/** Write to another stream and also feed it to the Signature object. */
|
|
private static class SignatureOutputStream extends FilterOutputStream {
|
|
private Signature mSignature;
|
|
|
|
public SignatureOutputStream(OutputStream out, Signature sig) {
|
|
super(out);
|
|
mSignature = sig;
|
|
}
|
|
|
|
@Override
|
|
public void write(int b) throws IOException {
|
|
try {
|
|
mSignature.update((byte) b);
|
|
} catch (SignatureException e) {
|
|
throw new IOException("SignatureException: " + e);
|
|
}
|
|
super.write(b);
|
|
}
|
|
|
|
@Override
|
|
public void write(byte[] b, int off, int len) throws IOException {
|
|
try {
|
|
mSignature.update(b, off, len);
|
|
} catch (SignatureException e) {
|
|
throw new IOException("SignatureException: " + e);
|
|
}
|
|
super.write(b, off, len);
|
|
}
|
|
}
|
|
|
|
/** Write a .SF file with a digest the specified manifest. */
|
|
private static void writeSignatureFile(Manifest manifest, OutputStream out)
|
|
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)");
|
|
|
|
BASE64Encoder base64 = new BASE64Encoder();
|
|
MessageDigest md = MessageDigest.getInstance("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("SHA1-Digest-Manifest", base64.encode(md.digest()));
|
|
|
|
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("SHA1-Digest", base64.encode(md.digest()));
|
|
sf.getEntries().put(entry.getKey(), sfAttr);
|
|
}
|
|
|
|
sf.write(out);
|
|
}
|
|
|
|
/** Write a .RSA file with a digital signature. */
|
|
private static void writeSignatureBlock(
|
|
Signature signature, X509Certificate publicKey, OutputStream out)
|
|
throws IOException, GeneralSecurityException {
|
|
SignerInfo signerInfo = new SignerInfo(
|
|
new X500Name(publicKey.getIssuerX500Principal().getName()),
|
|
publicKey.getSerialNumber(),
|
|
AlgorithmId.get("SHA1"),
|
|
AlgorithmId.get("RSA"),
|
|
signature.sign());
|
|
|
|
PKCS7 pkcs7 = new PKCS7(
|
|
new AlgorithmId[] { AlgorithmId.get("SHA1") },
|
|
new ContentInfo(ContentInfo.DATA_OID, null),
|
|
new X509Certificate[] { publicKey },
|
|
new SignerInfo[] { signerInfo });
|
|
|
|
pkcs7.encodeSignedData(out);
|
|
}
|
|
|
|
/** Copy all the files in a manifest from input to output. */
|
|
private static void copyFiles(Manifest manifest,
|
|
JarFile in, JarOutputStream out) throws IOException {
|
|
byte[] buffer = new byte[4096];
|
|
int num;
|
|
|
|
Map<String, Attributes> entries = manifest.getEntries();
|
|
List<String> names = new ArrayList(entries.keySet());
|
|
Collections.sort(names);
|
|
for (String name : names) {
|
|
JarEntry inEntry = in.getJarEntry(name);
|
|
if (inEntry.getMethod() == JarEntry.STORED) {
|
|
// Preserve the STORED method of the input entry.
|
|
out.putNextEntry(new JarEntry(inEntry));
|
|
} else {
|
|
// Create a new entry so that the compressed len is recomputed.
|
|
JarEntry je = new JarEntry(name);
|
|
je.setTime(inEntry.getTime());
|
|
out.putNextEntry(je);
|
|
}
|
|
|
|
InputStream data = in.getInputStream(inEntry);
|
|
while ((num = data.read(buffer)) > 0) {
|
|
out.write(buffer, 0, num);
|
|
}
|
|
out.flush();
|
|
}
|
|
}
|
|
|
|
public static void main(String[] args) {
|
|
if (args.length != 4) {
|
|
System.err.println("Usage: signapk " +
|
|
"publickey.x509[.pem] privatekey.pk8 " +
|
|
"input.jar output.jar");
|
|
System.exit(2);
|
|
}
|
|
|
|
JarFile inputJar = null;
|
|
JarOutputStream outputJar = null;
|
|
|
|
try {
|
|
X509Certificate publicKey = readPublicKey(new File(args[0]));
|
|
|
|
// Assume the certificate is valid for at least an hour.
|
|
long timestamp = publicKey.getNotBefore().getTime() + 3600L * 1000;
|
|
|
|
PrivateKey privateKey = readPrivateKey(new File(args[1]));
|
|
inputJar = new JarFile(new File(args[2]), false); // Don't verify.
|
|
outputJar = new JarOutputStream(new FileOutputStream(args[3]));
|
|
outputJar.setLevel(9);
|
|
|
|
JarEntry je;
|
|
|
|
// MANIFEST.MF
|
|
Manifest manifest = addDigestsToManifest(inputJar);
|
|
je = new JarEntry(JarFile.MANIFEST_NAME);
|
|
je.setTime(timestamp);
|
|
outputJar.putNextEntry(je);
|
|
manifest.write(outputJar);
|
|
|
|
// CERT.SF
|
|
Signature signature = Signature.getInstance("SHA1withRSA");
|
|
signature.initSign(privateKey);
|
|
je = new JarEntry(CERT_SF_NAME);
|
|
je.setTime(timestamp);
|
|
outputJar.putNextEntry(je);
|
|
writeSignatureFile(manifest,
|
|
new SignatureOutputStream(outputJar, signature));
|
|
|
|
// CERT.RSA
|
|
je = new JarEntry(CERT_RSA_NAME);
|
|
je.setTime(timestamp);
|
|
outputJar.putNextEntry(je);
|
|
writeSignatureBlock(signature, publicKey, outputJar);
|
|
|
|
// Everything else
|
|
copyFiles(manifest, inputJar, outputJar);
|
|
} catch (Exception e) {
|
|
e.printStackTrace();
|
|
System.exit(1);
|
|
} finally {
|
|
try {
|
|
if (inputJar != null) inputJar.close();
|
|
if (outputJar != null) outputJar.close();
|
|
} catch (IOException e) {
|
|
e.printStackTrace();
|
|
System.exit(1);
|
|
}
|
|
}
|
|
}
|
|
}
|