/* * Copyright (C) 2016 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.apksigner; import java.io.ByteArrayOutputStream; import java.io.Console; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.PushbackInputStream; import java.lang.reflect.Method; import java.nio.ByteBuffer; import java.nio.CharBuffer; import java.nio.charset.Charset; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Retriever of passwords based on password specs supported by {@code apksigner} tool. * * <p>apksigner supports retrieving multiple passwords from the same source (e.g., file, standard * input) which adds the need to keep some sources open across password retrievals. This class * addresses the need. * * <p>To use this retriever, construct a new instance, use {@link #getPasswords(String, String, * Charset...)} to retrieve passwords, and then invoke {@link #close()} on the instance when done, * enabling the instance to release any held resources. */ public class PasswordRetriever implements AutoCloseable { public static final String SPEC_STDIN = "stdin"; /** Character encoding used by the console or {@code null} if not known. */ private final Charset mConsoleEncoding; private final Map<File, InputStream> mFileInputStreams = new HashMap<>(); private boolean mClosed; public PasswordRetriever() { mConsoleEncoding = getConsoleEncoding(); } /** * Returns the passwords described by the provided spec. The reason there may be more than one * password is compatibility with {@code keytool} and {@code jarsigner} which in certain cases * use the form of passwords encoded using the console's character encoding or the JVM default * encoding. * * <p>Supported specs: * <ul> * <li><em>stdin</em> -- read password as a line from console, if available, or standard * input if console is not available</li> * <li><em>pass:password</em> -- password specified inside the spec, starting after * {@code pass:}</li> * <li><em>file:path</em> -- read password as a line from the specified file</li> * <li><em>env:name</em> -- password is in the specified environment variable</li> * </ul> * * <p>When the same file (including standard input) is used for providing multiple passwords, * the passwords are read from the file one line at a time. * * @param additionalPwdEncodings additional encodings for converting the password into KeyStore * or PKCS #8 encrypted key password. These encoding are used in addition to using the * password verbatim or encoded using JVM default character encoding. A useful encoding * to provide is the console character encoding on Windows machines where the console * may be different from the JVM default encoding. Unfortunately, there is no public API * to obtain the console's character encoding. */ public List<char[]> getPasswords( String spec, String description, Charset... additionalPwdEncodings) throws IOException { // IMPLEMENTATION NOTE: Java KeyStore and PBEKeySpec APIs take passwords as arrays of // Unicode characters (char[]). Unfortunately, it appears that Sun/Oracle keytool and // jarsigner in some cases use passwords which are the encoded form obtained using the // console's character encoding. For example, if the encoding is UTF-8, keytool and // jarsigner will use the password which is obtained by upcasting each byte of the UTF-8 // encoded form to char. This occurs only when the password is read from stdin/console, and // does not occur when the password is read from a command-line parameter. // There are other tools which use the Java KeyStore API correctly. // Thus, for each password spec, a valid password is typically one of these three: // * Unicode characters, // * characters (upcast bytes) obtained from encoding the password using the console's // character encoding of the console used on the environment where the KeyStore was // created, // * characters (upcast bytes) obtained from encoding the password using the JVM's default // character encoding of the machine where the KeyStore was created. // // For a sample password "\u0061\u0062\u00a1\u00e4\u044e\u0031": // On Windows 10 with English US as the UI language, IBM437 is used as console encoding and // windows-1252 is used as the JVM default encoding: // * keytool -genkey -v -keystore native.jks -keyalg RSA -keysize 2048 -validity 10000 // -alias test // generates a keystore and key which decrypt only with // "\u0061\u0062\u00ad\u0084\u003f\u0031" // * keytool -genkey -v -keystore native.jks -keyalg RSA -keysize 2048 -validity 10000 // -alias test -storepass <pass here> // generates a keystore and key which decrypt only with // "\u0061\u0062\u00a1\u00e4\u003f\u0031" // On modern OSX/Linux UTF-8 is used as the console and JVM default encoding: // * keytool -genkey -v -keystore native.jks -keyalg RSA -keysize 2048 -validity 10000 // -alias test // generates a keystore and key which decrypt only with // "\u0061\u0062\u00c2\u00a1\u00c3\u00a4\u00d1\u008e\u0031" // * keytool -genkey -v -keystore native.jks -keyalg RSA -keysize 2048 -validity 10000 // -alias test -storepass <pass here> // generates a keystore and key which decrypt only with // "\u0061\u0062\u00a1\u00e4\u044e\u0031" // // We optimize for the case where the KeyStore was created on the same machine where // apksigner is executed. Thus, we can assume the JVM default encoding used for creating the // KeyStore is the same as the current JVM's default encoding. We can make a similar // assumption about the console's encoding. However, there is no public API for obtaining // the console's character encoding. Prior to Java 9, we could cheat by using Reflection API // to access Console.encoding field. However, in the official Java 9 JVM this field is not // only inaccessible, but results in warnings being spewed to stdout during access attempts. // As a result, we cannot auto-detect the console's encoding and thus rely on the user to // explicitly provide it to apksigner as a command-line parameter (and passed into this // method as additionalPwdEncodings), if the password is using non-ASCII characters. assertNotClosed(); if (spec.startsWith("pass:")) { char[] pwd = spec.substring("pass:".length()).toCharArray(); return getPasswords(pwd, additionalPwdEncodings); } else if (SPEC_STDIN.equals(spec)) { Console console = System.console(); if (console != null) { // Reading from console char[] pwd = console.readPassword(description + ": "); if (pwd == null) { throw new IOException("Failed to read " + description + ": console closed"); } return getPasswords(pwd, additionalPwdEncodings); } else { // Console not available -- reading from standard input System.out.println(description + ": "); byte[] encodedPwd = readEncodedPassword(System.in); if (encodedPwd.length == 0) { throw new IOException( "Failed to read " + description + ": standard input closed"); } // By default, textual input obtained via standard input is supposed to be decoded // using the in JVM default character encoding. return getPasswords(encodedPwd, Charset.defaultCharset(), additionalPwdEncodings); } } else if (spec.startsWith("file:")) { String name = spec.substring("file:".length()); File file = new File(name).getCanonicalFile(); InputStream in = mFileInputStreams.get(file); if (in == null) { in = new FileInputStream(file); mFileInputStreams.put(file, in); } byte[] encodedPwd = readEncodedPassword(in); if (encodedPwd.length == 0) { throw new IOException( "Failed to read " + description + " : end of file reached in " + file); } // By default, textual input from files is supposed to be treated as encoded using JVM's // default character encoding. return getPasswords(encodedPwd, Charset.defaultCharset(), additionalPwdEncodings); } else if (spec.startsWith("env:")) { String name = spec.substring("env:".length()); String value = System.getenv(name); if (value == null) { throw new IOException( "Failed to read " + description + ": environment variable " + value + " not specified"); } return getPasswords(value.toCharArray(), additionalPwdEncodings); } else { throw new IOException("Unsupported password spec for " + description + ": " + spec); } } /** * Returns the provided password and all password variants derived from the password. The * resulting list is guaranteed to contain at least one element. */ private List<char[]> getPasswords(char[] pwd, Charset... additionalEncodings) { List<char[]> passwords = new ArrayList<>(3); addPasswords(passwords, pwd, additionalEncodings); return passwords; } /** * Returns the provided password and all password variants derived from the password. The * resulting list is guaranteed to contain at least one element. * * @param encodedPwd password encoded using {@code encodingForDecoding}. */ private List<char[]> getPasswords( byte[] encodedPwd, Charset encodingForDecoding, Charset... additionalEncodings) { List<char[]> passwords = new ArrayList<>(4); // Decode password and add it and its variants to the list try { char[] pwd = decodePassword(encodedPwd, encodingForDecoding); addPasswords(passwords, pwd, additionalEncodings); } catch (IOException ignored) {} // Add the original encoded form addPassword(passwords, castBytesToChars(encodedPwd)); return passwords; } /** * Adds the provided password and its variants to the provided list of passwords. * * <p>NOTE: This method adds only the passwords/variants which are not yet in the list. */ private void addPasswords(List<char[]> passwords, char[] pwd, Charset... additionalEncodings) { if ((additionalEncodings != null) && (additionalEncodings.length > 0)) { for (Charset encoding : additionalEncodings) { // Password encoded using provided encoding (usually the console's character // encoding) and upcast into char[] try { char[] encodedPwd = castBytesToChars(encodePassword(pwd, encoding)); addPassword(passwords, encodedPwd); } catch (IOException ignored) {} } } // Verbatim password addPassword(passwords, pwd); // Password encoded using the console encoding and upcast into char[] if (mConsoleEncoding != null) { try { char[] encodedPwd = castBytesToChars(encodePassword(pwd, mConsoleEncoding)); addPassword(passwords, encodedPwd); } catch (IOException ignored) {} } // Password encoded using the JVM default character encoding and upcast into char[] try { char[] encodedPwd = castBytesToChars(encodePassword(pwd, Charset.defaultCharset())); addPassword(passwords, encodedPwd); } catch (IOException ignored) {} } /** * Adds the provided password to the provided list. Does nothing if the password is already in * the list. */ private static void addPassword(List<char[]> passwords, char[] password) { for (char[] existingPassword : passwords) { if (Arrays.equals(password, existingPassword)) { return; } } passwords.add(password); } private static byte[] encodePassword(char[] pwd, Charset cs) throws IOException { ByteBuffer pwdBytes = cs.newEncoder() .onMalformedInput(CodingErrorAction.REPLACE) .onUnmappableCharacter(CodingErrorAction.REPLACE) .encode(CharBuffer.wrap(pwd)); byte[] encoded = new byte[pwdBytes.remaining()]; pwdBytes.get(encoded); return encoded; } private static char[] decodePassword(byte[] pwdBytes, Charset encoding) throws IOException { CharBuffer pwdChars = encoding.newDecoder() .onMalformedInput(CodingErrorAction.REPLACE) .onUnmappableCharacter(CodingErrorAction.REPLACE) .decode(ByteBuffer.wrap(pwdBytes)); char[] result = new char[pwdChars.remaining()]; pwdChars.get(result); return result; } /** * Upcasts each {@code byte} in the provided array of bytes to a {@code char} and returns the * resulting array of characters. */ private static char[] castBytesToChars(byte[] bytes) { if (bytes == null) { return null; } char[] chars = new char[bytes.length]; for (int i = 0; i < bytes.length; i++) { chars[i] = (char) (bytes[i] & 0xff); } return chars; } private static boolean isJava9OrHigherErrOnTheSideOfCaution() { // Before Java 9, this string is of major.minor form, such as "1.8" for Java 8. // From Java 9 onwards, this is a single number: major, such as "9" for Java 9. // See JEP 223: New Version-String Scheme. String versionString = System.getProperty("java.specification.version"); if (versionString == null) { // Better safe than sorry return true; } return !versionString.startsWith("1."); } /** * Returns the character encoding used by the console or {@code null} if the encoding is not * known. */ private static Charset getConsoleEncoding() { // IMPLEMENTATION NOTE: There is no public API for obtaining the console's character // encoding. We thus cheat by using implementation details of the most popular JVMs. // Unfortunately, this doesn't work on Java 9 JVMs where access to Console.encoding is // restricted by default and leads to spewing to stdout at runtime. if (isJava9OrHigherErrOnTheSideOfCaution()) { return null; } String consoleCharsetName = null; try { Method encodingMethod = Console.class.getDeclaredMethod("encoding"); encodingMethod.setAccessible(true); consoleCharsetName = (String) encodingMethod.invoke(null); } catch (ReflectiveOperationException ignored) { return null; } if (consoleCharsetName == null) { // Console encoding is the same as this JVM's default encoding return Charset.defaultCharset(); } try { return getCharsetByName(consoleCharsetName); } catch (IllegalArgumentException e) { return null; } } public static Charset getCharsetByName(String charsetName) throws IllegalArgumentException { // On Windows 10, cp65001 is the UTF-8 code page. For some reason, popular JVMs don't // have a mapping for cp65001... if ("cp65001".equalsIgnoreCase(charsetName)) { return StandardCharsets.UTF_8; } return Charset.forName(charsetName); } private static byte[] readEncodedPassword(InputStream in) throws IOException { ByteArrayOutputStream result = new ByteArrayOutputStream(); int b; while ((b = in.read()) != -1) { if (b == '\n') { break; } else if (b == '\r') { int next = in.read(); if ((next == -1) || (next == '\n')) { break; } if (!(in instanceof PushbackInputStream)) { in = new PushbackInputStream(in); } ((PushbackInputStream) in).unread(next); } result.write(b); } return result.toByteArray(); } private void assertNotClosed() { if (mClosed) { throw new IllegalStateException("Closed"); } } @Override public void close() { for (InputStream in : mFileInputStreams.values()) { try { in.close(); } catch (IOException ignored) {} } mFileInputStreams.clear(); mClosed = true; } }