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 17 package com.android.signapk; 18 19 import org.bouncycastle.asn1.ASN1InputStream; 20 import org.bouncycastle.asn1.ASN1ObjectIdentifier; 21 import org.bouncycastle.asn1.DEROutputStream; 22 import org.bouncycastle.asn1.cms.CMSObjectIdentifiers; 23 import org.bouncycastle.cert.jcajce.JcaCertStore; 24 import org.bouncycastle.cms.CMSException; 25 import org.bouncycastle.cms.CMSProcessableByteArray; 26 import org.bouncycastle.cms.CMSSignedData; 27 import org.bouncycastle.cms.CMSSignedDataGenerator; 28 import org.bouncycastle.cms.CMSTypedData; 29 import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder; 30 import org.bouncycastle.jce.provider.BouncyCastleProvider; 31 import org.bouncycastle.operator.ContentSigner; 32 import org.bouncycastle.operator.OperatorCreationException; 33 import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; 34 import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; 35 import org.bouncycastle.util.encoders.Base64; 36 37 import java.io.BufferedReader; 38 import java.io.ByteArrayOutputStream; 39 import java.io.DataInputStream; 40 import java.io.File; 41 import java.io.FileInputStream; 42 import java.io.FileOutputStream; 43 import java.io.FilterOutputStream; 44 import java.io.IOException; 45 import java.io.InputStream; 46 import java.io.InputStreamReader; 47 import java.io.OutputStream; 48 import java.io.PrintStream; 49 import java.security.DigestOutputStream; 50 import java.security.GeneralSecurityException; 51 import java.security.Key; 52 import java.security.KeyFactory; 53 import java.security.MessageDigest; 54 import java.security.PrivateKey; 55 import java.security.Provider; 56 import java.security.Security; 57 import java.security.cert.CertificateEncodingException; 58 import java.security.cert.CertificateFactory; 59 import java.security.cert.X509Certificate; 60 import java.security.spec.InvalidKeySpecException; 61 import java.security.spec.KeySpec; 62 import java.security.spec.PKCS8EncodedKeySpec; 63 import java.util.ArrayList; 64 import java.util.Collections; 65 import java.util.Enumeration; 66 import java.util.Map; 67 import java.util.TreeMap; 68 import java.util.jar.Attributes; 69 import java.util.jar.JarEntry; 70 import java.util.jar.JarFile; 71 import java.util.jar.JarOutputStream; 72 import java.util.jar.Manifest; 73 import java.util.regex.Pattern; 74 import javax.crypto.Cipher; 75 import javax.crypto.EncryptedPrivateKeyInfo; 76 import javax.crypto.SecretKeyFactory; 77 import 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 */ 83 class 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 87 private static final String OTACERT_NAME = "META-INF/com/android/otacert"; 88 89 private static Provider sBouncyCastleProvider; 90 91 // Files matching this pattern are not copied to the output. 92 private static Pattern stripPattern = 93 Pattern.compile("^META-INF/(.*)[.](SF|RSA|DSA)$"); 94 readPublicKey(File file)95 private static X509Certificate readPublicKey(File file) 96 throws IOException, GeneralSecurityException { 97 FileInputStream input = new FileInputStream(file); 98 try { 99 CertificateFactory cf = CertificateFactory.getInstance("X.509"); 100 return (X509Certificate) cf.generateCertificate(input); 101 } finally { 102 input.close(); 103 } 104 } 105 106 /** 107 * Reads the password from stdin and returns it as a string. 108 * 109 * @param keyFile The file containing the private key. Used to prompt the user. 110 */ readPassword(File keyFile)111 private static String readPassword(File keyFile) { 112 // TODO: use Console.readPassword() when it's available. 113 System.out.print("Enter password for " + keyFile + " (password will not be hidden): "); 114 System.out.flush(); 115 BufferedReader stdin = new BufferedReader(new InputStreamReader(System.in)); 116 try { 117 return stdin.readLine(); 118 } catch (IOException ex) { 119 return null; 120 } 121 } 122 123 /** 124 * Decrypt an encrypted PKCS 8 format private key. 125 * 126 * Based on ghstark's post on Aug 6, 2006 at 127 * http://forums.sun.com/thread.jspa?threadID=758133&messageID=4330949 128 * 129 * @param encryptedPrivateKey The raw data of the private key 130 * @param keyFile The file containing the private key 131 */ decryptPrivateKey(byte[] encryptedPrivateKey, File keyFile)132 private static KeySpec decryptPrivateKey(byte[] encryptedPrivateKey, File keyFile) 133 throws GeneralSecurityException { 134 EncryptedPrivateKeyInfo epkInfo; 135 try { 136 epkInfo = new EncryptedPrivateKeyInfo(encryptedPrivateKey); 137 } catch (IOException ex) { 138 // Probably not an encrypted key. 139 return null; 140 } 141 142 char[] password = readPassword(keyFile).toCharArray(); 143 144 SecretKeyFactory skFactory = SecretKeyFactory.getInstance(epkInfo.getAlgName()); 145 Key key = skFactory.generateSecret(new PBEKeySpec(password)); 146 147 Cipher cipher = Cipher.getInstance(epkInfo.getAlgName()); 148 cipher.init(Cipher.DECRYPT_MODE, key, epkInfo.getAlgParameters()); 149 150 try { 151 return epkInfo.getKeySpec(cipher); 152 } catch (InvalidKeySpecException ex) { 153 System.err.println("signapk: Password for " + keyFile + " may be bad."); 154 throw ex; 155 } 156 } 157 158 /** Read a PKCS 8 format private key. */ readPrivateKey(File file)159 private static PrivateKey readPrivateKey(File file) 160 throws IOException, GeneralSecurityException { 161 DataInputStream input = new DataInputStream(new FileInputStream(file)); 162 try { 163 byte[] bytes = new byte[(int) file.length()]; 164 input.read(bytes); 165 166 KeySpec spec = decryptPrivateKey(bytes, file); 167 if (spec == null) { 168 spec = new PKCS8EncodedKeySpec(bytes); 169 } 170 171 try { 172 return KeyFactory.getInstance("RSA").generatePrivate(spec); 173 } catch (InvalidKeySpecException ex) { 174 return KeyFactory.getInstance("DSA").generatePrivate(spec); 175 } 176 } finally { 177 input.close(); 178 } 179 } 180 181 /** Add the SHA1 of every file to the manifest, creating it if necessary. */ addDigestsToManifest(JarFile jar)182 private static Manifest addDigestsToManifest(JarFile jar) 183 throws IOException, GeneralSecurityException { 184 Manifest input = jar.getManifest(); 185 Manifest output = new Manifest(); 186 Attributes main = output.getMainAttributes(); 187 if (input != null) { 188 main.putAll(input.getMainAttributes()); 189 } else { 190 main.putValue("Manifest-Version", "1.0"); 191 main.putValue("Created-By", "1.0 (Android SignApk)"); 192 } 193 194 MessageDigest md = MessageDigest.getInstance("SHA1"); 195 byte[] buffer = new byte[4096]; 196 int num; 197 198 // We sort the input entries by name, and add them to the 199 // output manifest in sorted order. We expect that the output 200 // map will be deterministic. 201 202 TreeMap<String, JarEntry> byName = new TreeMap<String, JarEntry>(); 203 204 for (Enumeration<JarEntry> e = jar.entries(); e.hasMoreElements(); ) { 205 JarEntry entry = e.nextElement(); 206 byName.put(entry.getName(), entry); 207 } 208 209 for (JarEntry entry: byName.values()) { 210 String name = entry.getName(); 211 if (!entry.isDirectory() && !name.equals(JarFile.MANIFEST_NAME) && 212 !name.equals(CERT_SF_NAME) && !name.equals(CERT_RSA_NAME) && 213 !name.equals(OTACERT_NAME) && 214 (stripPattern == null || 215 !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 */ addOtacert(JarOutputStream outputJar, File publicKeyFile, long timestamp, Manifest manifest)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 CountOutputStream(OutputStream out)272 public CountOutputStream(OutputStream out) { 273 super(out); 274 mCount = 0; 275 } 276 277 @Override write(int b)278 public void write(int b) throws IOException { 279 super.write(b); 280 mCount++; 281 } 282 283 @Override write(byte[] b, int off, int len)284 public void write(byte[] b, int off, int len) throws IOException { 285 super.write(b, off, len); 286 mCount += len; 287 } 288 size()289 public int size() { 290 return mCount; 291 } 292 } 293 294 /** Write a .SF file with a digest of the specified manifest. */ writeSignatureFile(Manifest manifest, OutputStream out)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; CMSByteArraySlice(byte[] data, int offset, 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 getContent()354 public Object getContent() { 355 throw new UnsupportedOperationException(); 356 } 357 getContentType()358 public ASN1ObjectIdentifier getContentType() { 359 return type; 360 } 361 write(OutputStream out)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'. */ writeSignatureBlock( CMSTypedData data, X509Certificate publicKey, PrivateKey privateKey, OutputStream 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 signWholeOutputFile(byte[] zipData, OutputStream outputStream, X509Certificate publicKey, PrivateKey privateKey)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 */ copyFiles(Manifest manifest, JarFile in, JarOutputStream out, long timestamp)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 main(String[] args)502 public static void main(String[] args) { 503 if (args.length != 4 && args.length != 5) { 504 System.err.println("Usage: signapk [-w] " + 505 "publickey.x509[.pem] privatekey.pk8 " + 506 "input.jar output.jar"); 507 System.exit(2); 508 } 509 510 sBouncyCastleProvider = new BouncyCastleProvider(); 511 Security.addProvider(sBouncyCastleProvider); 512 513 boolean signWholeFile = false; 514 int argstart = 0; 515 if (args[0].equals("-w")) { 516 signWholeFile = true; 517 argstart = 1; 518 } 519 520 JarFile inputJar = null; 521 JarOutputStream outputJar = null; 522 FileOutputStream outputFile = null; 523 524 try { 525 File publicKeyFile = new File(args[argstart+0]); 526 X509Certificate publicKey = readPublicKey(publicKeyFile); 527 528 // Assume the certificate is valid for at least an hour. 529 long timestamp = publicKey.getNotBefore().getTime() + 3600L * 1000; 530 531 PrivateKey privateKey = readPrivateKey(new File(args[argstart+1])); 532 inputJar = new JarFile(new File(args[argstart+2]), false); // Don't verify. 533 534 OutputStream outputStream = null; 535 if (signWholeFile) { 536 outputStream = new ByteArrayOutputStream(); 537 } else { 538 outputStream = outputFile = new FileOutputStream(args[argstart+3]); 539 } 540 outputJar = new JarOutputStream(outputStream); 541 542 // For signing .apks, use the maximum compression to make 543 // them as small as possible (since they live forever on 544 // the system partition). For OTA packages, use the 545 // default compression level, which is much much faster 546 // and produces output that is only a tiny bit larger 547 // (~0.1% on full OTA packages I tested). 548 if (!signWholeFile) { 549 outputJar.setLevel(9); 550 } 551 552 JarEntry je; 553 554 Manifest manifest = addDigestsToManifest(inputJar); 555 556 // Everything else 557 copyFiles(manifest, inputJar, outputJar, timestamp); 558 559 // otacert 560 if (signWholeFile) { 561 addOtacert(outputJar, publicKeyFile, timestamp, manifest); 562 } 563 564 // MANIFEST.MF 565 je = new JarEntry(JarFile.MANIFEST_NAME); 566 je.setTime(timestamp); 567 outputJar.putNextEntry(je); 568 manifest.write(outputJar); 569 570 // CERT.SF 571 je = new JarEntry(CERT_SF_NAME); 572 je.setTime(timestamp); 573 outputJar.putNextEntry(je); 574 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 575 writeSignatureFile(manifest, baos); 576 byte[] signedData = baos.toByteArray(); 577 outputJar.write(signedData); 578 579 // CERT.RSA 580 je = new JarEntry(CERT_RSA_NAME); 581 je.setTime(timestamp); 582 outputJar.putNextEntry(je); 583 writeSignatureBlock(new CMSProcessableByteArray(signedData), 584 publicKey, privateKey, outputJar); 585 586 outputJar.close(); 587 outputJar = null; 588 outputStream.flush(); 589 590 if (signWholeFile) { 591 outputFile = new FileOutputStream(args[argstart+3]); 592 signWholeOutputFile(((ByteArrayOutputStream)outputStream).toByteArray(), 593 outputFile, publicKey, privateKey); 594 } 595 } catch (Exception e) { 596 e.printStackTrace(); 597 System.exit(1); 598 } finally { 599 try { 600 if (inputJar != null) inputJar.close(); 601 if (outputFile != null) outputFile.close(); 602 } catch (IOException e) { 603 e.printStackTrace(); 604 System.exit(1); 605 } 606 } 607 } 608 } 609