SignApk.java revision b14c97621bb748d717fd9c91b21cb4d7cf0bd85e
1/* 2 * Copyright (C) 2008 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.signapk; 18 19import org.bouncycastle.asn1.ASN1InputStream; 20import org.bouncycastle.asn1.ASN1ObjectIdentifier; 21import org.bouncycastle.asn1.DEROutputStream; 22import org.bouncycastle.asn1.cms.CMSObjectIdentifiers; 23import org.bouncycastle.cert.jcajce.JcaCertStore; 24import org.bouncycastle.cms.CMSException; 25import org.bouncycastle.cms.CMSProcessableByteArray; 26import org.bouncycastle.cms.CMSSignedData; 27import org.bouncycastle.cms.CMSSignedDataGenerator; 28import org.bouncycastle.cms.CMSTypedData; 29import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder; 30import org.bouncycastle.jce.provider.BouncyCastleProvider; 31import org.bouncycastle.operator.ContentSigner; 32import org.bouncycastle.operator.OperatorCreationException; 33import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; 34import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; 35import org.bouncycastle.util.encoders.Base64; 36 37import java.io.BufferedReader; 38import java.io.ByteArrayOutputStream; 39import java.io.DataInputStream; 40import java.io.File; 41import java.io.FileInputStream; 42import java.io.FileOutputStream; 43import java.io.FilterOutputStream; 44import java.io.IOException; 45import java.io.InputStream; 46import java.io.InputStreamReader; 47import java.io.OutputStream; 48import java.io.PrintStream; 49import java.security.DigestOutputStream; 50import java.security.GeneralSecurityException; 51import java.security.Key; 52import java.security.KeyFactory; 53import java.security.MessageDigest; 54import java.security.PrivateKey; 55import java.security.Provider; 56import java.security.Security; 57import java.security.cert.CertificateEncodingException; 58import java.security.cert.CertificateFactory; 59import java.security.cert.X509Certificate; 60import java.security.spec.InvalidKeySpecException; 61import java.security.spec.KeySpec; 62import java.security.spec.PKCS8EncodedKeySpec; 63import java.util.ArrayList; 64import java.util.Collections; 65import java.util.Enumeration; 66import java.util.Map; 67import java.util.TreeMap; 68import java.util.jar.Attributes; 69import java.util.jar.JarEntry; 70import java.util.jar.JarFile; 71import java.util.jar.JarOutputStream; 72import java.util.jar.Manifest; 73import java.util.regex.Pattern; 74import javax.crypto.Cipher; 75import javax.crypto.EncryptedPrivateKeyInfo; 76import javax.crypto.SecretKeyFactory; 77import javax.crypto.spec.PBEKeySpec; 78 79/** 80 * Command line tool to sign JAR files (including APKs and OTA updates) in 81 * a way compatible with the mincrypt verifier, using SHA1 and RSA keys. 82 */ 83class SignApk { 84 private static final String CERT_SF_NAME = "META-INF/CERT.SF"; 85 private static final String CERT_RSA_NAME = "META-INF/CERT.RSA"; 86 private static final String CERT_SF_MULTI_NAME = "META-INF/CERT%d.SF"; 87 private static final String CERT_RSA_MULTI_NAME = "META-INF/CERT%d.RSA"; 88 89 private static final String OTACERT_NAME = "META-INF/com/android/otacert"; 90 91 private static Provider sBouncyCastleProvider; 92 93 // Files matching this pattern are not copied to the output. 94 private static Pattern stripPattern = 95 Pattern.compile("^(META-INF/((.*)[.](SF|RSA|DSA)|com/android/otacert))|(" + 96 Pattern.quote(JarFile.MANIFEST_NAME) + ")$"); 97 98 private static X509Certificate readPublicKey(File file) 99 throws IOException, GeneralSecurityException { 100 FileInputStream input = new FileInputStream(file); 101 try { 102 CertificateFactory cf = CertificateFactory.getInstance("X.509"); 103 return (X509Certificate) cf.generateCertificate(input); 104 } finally { 105 input.close(); 106 } 107 } 108 109 /** 110 * Reads the password from stdin and returns it as a string. 111 * 112 * @param keyFile The file containing the private key. Used to prompt the user. 113 */ 114 private static String readPassword(File keyFile) { 115 // TODO: use Console.readPassword() when it's available. 116 System.out.print("Enter password for " + keyFile + " (password will not be hidden): "); 117 System.out.flush(); 118 BufferedReader stdin = new BufferedReader(new InputStreamReader(System.in)); 119 try { 120 return stdin.readLine(); 121 } catch (IOException ex) { 122 return null; 123 } 124 } 125 126 /** 127 * Decrypt an encrypted PKCS 8 format private key. 128 * 129 * Based on ghstark's post on Aug 6, 2006 at 130 * http://forums.sun.com/thread.jspa?threadID=758133&messageID=4330949 131 * 132 * @param encryptedPrivateKey The raw data of the private key 133 * @param keyFile The file containing the private key 134 */ 135 private static KeySpec decryptPrivateKey(byte[] encryptedPrivateKey, File keyFile) 136 throws GeneralSecurityException { 137 EncryptedPrivateKeyInfo epkInfo; 138 try { 139 epkInfo = new EncryptedPrivateKeyInfo(encryptedPrivateKey); 140 } catch (IOException ex) { 141 // Probably not an encrypted key. 142 return null; 143 } 144 145 char[] password = readPassword(keyFile).toCharArray(); 146 147 SecretKeyFactory skFactory = SecretKeyFactory.getInstance(epkInfo.getAlgName()); 148 Key key = skFactory.generateSecret(new PBEKeySpec(password)); 149 150 Cipher cipher = Cipher.getInstance(epkInfo.getAlgName()); 151 cipher.init(Cipher.DECRYPT_MODE, key, epkInfo.getAlgParameters()); 152 153 try { 154 return epkInfo.getKeySpec(cipher); 155 } catch (InvalidKeySpecException ex) { 156 System.err.println("signapk: Password for " + keyFile + " may be bad."); 157 throw ex; 158 } 159 } 160 161 /** Read a PKCS 8 format private key. */ 162 private static PrivateKey readPrivateKey(File file) 163 throws IOException, GeneralSecurityException { 164 DataInputStream input = new DataInputStream(new FileInputStream(file)); 165 try { 166 byte[] bytes = new byte[(int) file.length()]; 167 input.read(bytes); 168 169 KeySpec spec = decryptPrivateKey(bytes, file); 170 if (spec == null) { 171 spec = new PKCS8EncodedKeySpec(bytes); 172 } 173 174 try { 175 return KeyFactory.getInstance("RSA").generatePrivate(spec); 176 } catch (InvalidKeySpecException ex) { 177 return KeyFactory.getInstance("DSA").generatePrivate(spec); 178 } 179 } finally { 180 input.close(); 181 } 182 } 183 184 /** Add the SHA1 of every file to the manifest, creating it if necessary. */ 185 private static Manifest addDigestsToManifest(JarFile jar) 186 throws IOException, GeneralSecurityException { 187 Manifest input = jar.getManifest(); 188 Manifest output = new Manifest(); 189 Attributes main = output.getMainAttributes(); 190 if (input != null) { 191 main.putAll(input.getMainAttributes()); 192 } else { 193 main.putValue("Manifest-Version", "1.0"); 194 main.putValue("Created-By", "1.0 (Android SignApk)"); 195 } 196 197 MessageDigest md = MessageDigest.getInstance("SHA1"); 198 byte[] buffer = new byte[4096]; 199 int num; 200 201 // We sort the input entries by name, and add them to the 202 // output manifest in sorted order. We expect that the output 203 // map will be deterministic. 204 205 TreeMap<String, JarEntry> byName = new TreeMap<String, JarEntry>(); 206 207 for (Enumeration<JarEntry> e = jar.entries(); e.hasMoreElements(); ) { 208 JarEntry entry = e.nextElement(); 209 byName.put(entry.getName(), entry); 210 } 211 212 for (JarEntry entry: byName.values()) { 213 String name = entry.getName(); 214 if (!entry.isDirectory() && 215 (stripPattern == null || !stripPattern.matcher(name).matches())) { 216 InputStream data = jar.getInputStream(entry); 217 while ((num = data.read(buffer)) > 0) { 218 md.update(buffer, 0, num); 219 } 220 221 Attributes attr = null; 222 if (input != null) attr = input.getAttributes(name); 223 attr = attr != null ? new Attributes(attr) : new Attributes(); 224 attr.putValue("SHA1-Digest", 225 new String(Base64.encode(md.digest()), "ASCII")); 226 output.getEntries().put(name, attr); 227 } 228 } 229 230 return output; 231 } 232 233 /** 234 * Add a copy of the public key to the archive; this should 235 * exactly match one of the files in 236 * /system/etc/security/otacerts.zip on the device. (The same 237 * cert can be extracted from the CERT.RSA file but this is much 238 * easier to get at.) 239 */ 240 private static void addOtacert(JarOutputStream outputJar, 241 File publicKeyFile, 242 long timestamp, 243 Manifest manifest) 244 throws IOException, GeneralSecurityException { 245 MessageDigest md = MessageDigest.getInstance("SHA1"); 246 247 JarEntry je = new JarEntry(OTACERT_NAME); 248 je.setTime(timestamp); 249 outputJar.putNextEntry(je); 250 FileInputStream input = new FileInputStream(publicKeyFile); 251 byte[] b = new byte[4096]; 252 int read; 253 while ((read = input.read(b)) != -1) { 254 outputJar.write(b, 0, read); 255 md.update(b, 0, read); 256 } 257 input.close(); 258 259 Attributes attr = new Attributes(); 260 attr.putValue("SHA1-Digest", 261 new String(Base64.encode(md.digest()), "ASCII")); 262 manifest.getEntries().put(OTACERT_NAME, attr); 263 } 264 265 266 /** Write to another stream and track how many bytes have been 267 * written. 268 */ 269 private static class CountOutputStream extends FilterOutputStream { 270 private int mCount; 271 272 public CountOutputStream(OutputStream out) { 273 super(out); 274 mCount = 0; 275 } 276 277 @Override 278 public void write(int b) throws IOException { 279 super.write(b); 280 mCount++; 281 } 282 283 @Override 284 public void write(byte[] b, int off, int len) throws IOException { 285 super.write(b, off, len); 286 mCount += len; 287 } 288 289 public int size() { 290 return mCount; 291 } 292 } 293 294 /** Write a .SF file with a digest of the specified manifest. */ 295 private static void writeSignatureFile(Manifest manifest, OutputStream out) 296 throws IOException, GeneralSecurityException { 297 Manifest sf = new Manifest(); 298 Attributes main = sf.getMainAttributes(); 299 main.putValue("Signature-Version", "1.0"); 300 main.putValue("Created-By", "1.0 (Android SignApk)"); 301 302 MessageDigest md = MessageDigest.getInstance("SHA1"); 303 PrintStream print = new PrintStream( 304 new DigestOutputStream(new ByteArrayOutputStream(), md), 305 true, "UTF-8"); 306 307 // Digest of the entire manifest 308 manifest.write(print); 309 print.flush(); 310 main.putValue("SHA1-Digest-Manifest", 311 new String(Base64.encode(md.digest()), "ASCII")); 312 313 Map<String, Attributes> entries = manifest.getEntries(); 314 for (Map.Entry<String, Attributes> entry : entries.entrySet()) { 315 // Digest of the manifest stanza for this entry. 316 print.print("Name: " + entry.getKey() + "\r\n"); 317 for (Map.Entry<Object, Object> att : entry.getValue().entrySet()) { 318 print.print(att.getKey() + ": " + att.getValue() + "\r\n"); 319 } 320 print.print("\r\n"); 321 print.flush(); 322 323 Attributes sfAttr = new Attributes(); 324 sfAttr.putValue("SHA1-Digest", 325 new String(Base64.encode(md.digest()), "ASCII")); 326 sf.getEntries().put(entry.getKey(), sfAttr); 327 } 328 329 CountOutputStream cout = new CountOutputStream(out); 330 sf.write(cout); 331 332 // A bug in the java.util.jar implementation of Android platforms 333 // up to version 1.6 will cause a spurious IOException to be thrown 334 // if the length of the signature file is a multiple of 1024 bytes. 335 // As a workaround, add an extra CRLF in this case. 336 if ((cout.size() % 1024) == 0) { 337 cout.write('\r'); 338 cout.write('\n'); 339 } 340 } 341 342 private static class CMSByteArraySlice implements CMSTypedData { 343 private final ASN1ObjectIdentifier type; 344 private final byte[] data; 345 private final int offset; 346 private final int length; 347 public CMSByteArraySlice(byte[] data, int offset, int length) { 348 this.data = data; 349 this.offset = offset; 350 this.length = length; 351 this.type = new ASN1ObjectIdentifier(CMSObjectIdentifiers.data.getId()); 352 } 353 354 public Object getContent() { 355 throw new UnsupportedOperationException(); 356 } 357 358 public ASN1ObjectIdentifier getContentType() { 359 return type; 360 } 361 362 public void write(OutputStream out) throws IOException { 363 out.write(data, offset, length); 364 } 365 } 366 367 /** Sign data and write the digital signature to 'out'. */ 368 private static void writeSignatureBlock( 369 CMSTypedData data, X509Certificate publicKey, PrivateKey privateKey, 370 OutputStream out) 371 throws IOException, 372 CertificateEncodingException, 373 OperatorCreationException, 374 CMSException { 375 ArrayList<X509Certificate> certList = new ArrayList<X509Certificate>(1); 376 certList.add(publicKey); 377 JcaCertStore certs = new JcaCertStore(certList); 378 379 CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); 380 ContentSigner sha1Signer = new JcaContentSignerBuilder("SHA1withRSA") 381 .setProvider(sBouncyCastleProvider) 382 .build(privateKey); 383 gen.addSignerInfoGenerator( 384 new JcaSignerInfoGeneratorBuilder( 385 new JcaDigestCalculatorProviderBuilder() 386 .setProvider(sBouncyCastleProvider) 387 .build()) 388 .setDirectSignature(true) 389 .build(sha1Signer, publicKey)); 390 gen.addCertificates(certs); 391 CMSSignedData sigData = gen.generate(data, false); 392 393 ASN1InputStream asn1 = new ASN1InputStream(sigData.getEncoded()); 394 DEROutputStream dos = new DEROutputStream(out); 395 dos.writeObject(asn1.readObject()); 396 } 397 398 private static void signWholeOutputFile(byte[] zipData, 399 OutputStream outputStream, 400 X509Certificate publicKey, 401 PrivateKey privateKey) 402 throws IOException, 403 CertificateEncodingException, 404 OperatorCreationException, 405 CMSException { 406 // For a zip with no archive comment, the 407 // end-of-central-directory record will be 22 bytes long, so 408 // we expect to find the EOCD marker 22 bytes from the end. 409 if (zipData[zipData.length-22] != 0x50 || 410 zipData[zipData.length-21] != 0x4b || 411 zipData[zipData.length-20] != 0x05 || 412 zipData[zipData.length-19] != 0x06) { 413 throw new IllegalArgumentException("zip data already has an archive comment"); 414 } 415 416 ByteArrayOutputStream temp = new ByteArrayOutputStream(); 417 418 // put a readable message and a null char at the start of the 419 // archive comment, so that tools that display the comment 420 // (hopefully) show something sensible. 421 // TODO: anything more useful we can put in this message? 422 byte[] message = "signed by SignApk".getBytes("UTF-8"); 423 temp.write(message); 424 temp.write(0); 425 426 writeSignatureBlock(new CMSByteArraySlice(zipData, 0, zipData.length-2), 427 publicKey, privateKey, temp); 428 int total_size = temp.size() + 6; 429 if (total_size > 0xffff) { 430 throw new IllegalArgumentException("signature is too big for ZIP file comment"); 431 } 432 // signature starts this many bytes from the end of the file 433 int signature_start = total_size - message.length - 1; 434 temp.write(signature_start & 0xff); 435 temp.write((signature_start >> 8) & 0xff); 436 // Why the 0xff bytes? In a zip file with no archive comment, 437 // bytes [-6:-2] of the file are the little-endian offset from 438 // the start of the file to the central directory. So for the 439 // two high bytes to be 0xff 0xff, the archive would have to 440 // be nearly 4GB in size. So it's unlikely that a real 441 // commentless archive would have 0xffs here, and lets us tell 442 // an old signed archive from a new one. 443 temp.write(0xff); 444 temp.write(0xff); 445 temp.write(total_size & 0xff); 446 temp.write((total_size >> 8) & 0xff); 447 temp.flush(); 448 449 // Signature verification checks that the EOCD header is the 450 // last such sequence in the file (to avoid minzip finding a 451 // fake EOCD appended after the signature in its scan). The 452 // odds of producing this sequence by chance are very low, but 453 // let's catch it here if it does. 454 byte[] b = temp.toByteArray(); 455 for (int i = 0; i < b.length-3; ++i) { 456 if (b[i] == 0x50 && b[i+1] == 0x4b && b[i+2] == 0x05 && b[i+3] == 0x06) { 457 throw new IllegalArgumentException("found spurious EOCD header at " + i); 458 } 459 } 460 461 outputStream.write(zipData, 0, zipData.length-2); 462 outputStream.write(total_size & 0xff); 463 outputStream.write((total_size >> 8) & 0xff); 464 temp.writeTo(outputStream); 465 } 466 467 /** 468 * Copy all the files in a manifest from input to output. We set 469 * the modification times in the output to a fixed time, so as to 470 * reduce variation in the output file and make incremental OTAs 471 * more efficient. 472 */ 473 private static void copyFiles(Manifest manifest, 474 JarFile in, JarOutputStream out, long timestamp) throws IOException { 475 byte[] buffer = new byte[4096]; 476 int num; 477 478 Map<String, Attributes> entries = manifest.getEntries(); 479 ArrayList<String> names = new ArrayList<String>(entries.keySet()); 480 Collections.sort(names); 481 for (String name : names) { 482 JarEntry inEntry = in.getJarEntry(name); 483 JarEntry outEntry = null; 484 if (inEntry.getMethod() == JarEntry.STORED) { 485 // Preserve the STORED method of the input entry. 486 outEntry = new JarEntry(inEntry); 487 } else { 488 // Create a new entry so that the compressed len is recomputed. 489 outEntry = new JarEntry(name); 490 } 491 outEntry.setTime(timestamp); 492 out.putNextEntry(outEntry); 493 494 InputStream data = in.getInputStream(inEntry); 495 while ((num = data.read(buffer)) > 0) { 496 out.write(buffer, 0, num); 497 } 498 out.flush(); 499 } 500 } 501 502 private static void usage() { 503 System.err.println("Usage: signapk [-w] " + 504 "publickey.x509[.pem] privatekey.pk8 " + 505 "[publickey2.x509[.pem] privatekey2.pk8 ...] " + 506 "input.jar output.jar"); 507 System.exit(2); 508 } 509 510 public static void main(String[] args) { 511 if (args.length < 4) usage(); 512 513 sBouncyCastleProvider = new BouncyCastleProvider(); 514 Security.addProvider(sBouncyCastleProvider); 515 516 boolean signWholeFile = false; 517 int argstart = 0; 518 if (args[0].equals("-w")) { 519 signWholeFile = true; 520 argstart = 1; 521 } 522 523 if ((args.length - argstart) % 2 == 1) usage(); 524 int numKeys = ((args.length - argstart) / 2) - 1; 525 if (signWholeFile && numKeys > 1) { 526 System.err.println("Only one key may be used with -w."); 527 System.exit(2); 528 } 529 530 String inputFilename = args[args.length-2]; 531 String outputFilename = args[args.length-1]; 532 533 JarFile inputJar = null; 534 JarOutputStream outputJar = null; 535 FileOutputStream outputFile = null; 536 537 try { 538 File firstPublicKeyFile = new File(args[argstart+0]); 539 540 X509Certificate[] publicKey = new X509Certificate[numKeys]; 541 for (int i = 0; i < numKeys; ++i) { 542 int argNum = argstart + i*2; 543 publicKey[i] = readPublicKey(new File(args[argNum])); 544 } 545 546 // Set the ZIP file timestamp to the starting valid time 547 // of the 0th certificate plus one hour (to match what 548 // we've historically done). 549 long timestamp = publicKey[0].getNotBefore().getTime() + 3600L * 1000; 550 551 PrivateKey[] privateKey = new PrivateKey[numKeys]; 552 for (int i = 0; i < numKeys; ++i) { 553 int argNum = argstart + i*2 + 1; 554 privateKey[i] = readPrivateKey(new File(args[argNum])); 555 } 556 inputJar = new JarFile(new File(inputFilename), false); // Don't verify. 557 558 OutputStream outputStream = null; 559 if (signWholeFile) { 560 outputStream = new ByteArrayOutputStream(); 561 } else { 562 outputStream = outputFile = new FileOutputStream(outputFilename); 563 } 564 outputJar = new JarOutputStream(outputStream); 565 566 // For signing .apks, use the maximum compression to make 567 // them as small as possible (since they live forever on 568 // the system partition). For OTA packages, use the 569 // default compression level, which is much much faster 570 // and produces output that is only a tiny bit larger 571 // (~0.1% on full OTA packages I tested). 572 if (!signWholeFile) { 573 outputJar.setLevel(9); 574 } 575 576 JarEntry je; 577 578 Manifest manifest = addDigestsToManifest(inputJar); 579 580 // Everything else 581 copyFiles(manifest, inputJar, outputJar, timestamp); 582 583 // otacert 584 if (signWholeFile) { 585 addOtacert(outputJar, firstPublicKeyFile, timestamp, manifest); 586 } 587 588 // MANIFEST.MF 589 je = new JarEntry(JarFile.MANIFEST_NAME); 590 je.setTime(timestamp); 591 outputJar.putNextEntry(je); 592 manifest.write(outputJar); 593 594 // In the case of multiple keys, all the .SF files will be 595 // identical, but as far as I can tell the jarsigner docs 596 // don't allow there to be just one copy in the zipfile; 597 // there hase to be one per .RSA file. 598 599 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 600 writeSignatureFile(manifest, baos); 601 byte[] signedData = baos.toByteArray(); 602 603 for (int k = 0; k < numKeys; ++k) { 604 // CERT.SF / CERT#.SF 605 je = new JarEntry(numKeys == 1 ? CERT_SF_NAME : 606 (String.format(CERT_SF_MULTI_NAME, k))); 607 je.setTime(timestamp); 608 outputJar.putNextEntry(je); 609 outputJar.write(signedData); 610 611 // CERT.RSA / CERT#.RSA 612 je = new JarEntry(numKeys == 1 ? CERT_RSA_NAME : 613 (String.format(CERT_RSA_MULTI_NAME, k))); 614 je.setTime(timestamp); 615 outputJar.putNextEntry(je); 616 writeSignatureBlock(new CMSProcessableByteArray(signedData), 617 publicKey[k], privateKey[k], outputJar); 618 } 619 620 outputJar.close(); 621 outputJar = null; 622 outputStream.flush(); 623 624 if (signWholeFile) { 625 outputFile = new FileOutputStream(outputFilename); 626 signWholeOutputFile(((ByteArrayOutputStream)outputStream).toByteArray(), 627 outputFile, publicKey[0], privateKey[0]); 628 } 629 } catch (Exception e) { 630 e.printStackTrace(); 631 System.exit(1); 632 } finally { 633 try { 634 if (inputJar != null) inputJar.close(); 635 if (outputFile != null) outputFile.close(); 636 } catch (IOException e) { 637 e.printStackTrace(); 638 System.exit(1); 639 } 640 } 641 } 642} 643