001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.apache.shiro.tools.hasher;
020
021import org.apache.commons.cli.*;
022import org.apache.shiro.authc.credential.DefaultPasswordService;
023import org.apache.shiro.codec.Base64;
024import org.apache.shiro.codec.Hex;
025import org.apache.shiro.crypto.SecureRandomNumberGenerator;
026import org.apache.shiro.crypto.UnknownAlgorithmException;
027import org.apache.shiro.crypto.hash.SimpleHash;
028import org.apache.shiro.crypto.hash.format.*;
029import org.apache.shiro.io.ResourceUtils;
030import org.apache.shiro.util.ByteSource;
031import org.apache.shiro.util.JavaEnvironment;
032import org.apache.shiro.util.StringUtils;
033
034import java.io.File;
035import java.io.IOException;
036import java.util.Arrays;
037
038/**
039 * Commandline line utility to hash data such as strings, passwords, resources (files, urls, etc).
040 * <p/>
041 * Usage:
042 * <pre>
043 * java -jar shiro-tools-hasher<em>-version</em>-cli.jar
044 * </pre>
045 * This will print out all supported options with documentation.
046 *
047 * @since 1.2
048 */
049public final class Hasher {
050
051    private static final String HEX_PREFIX = "0x";
052    private static final String DEFAULT_ALGORITHM_NAME = "MD5";
053    private static final String DEFAULT_PASSWORD_ALGORITHM_NAME = DefaultPasswordService.DEFAULT_HASH_ALGORITHM;
054    private static final int DEFAULT_GENERATED_SALT_SIZE = 128;
055    private static final int DEFAULT_NUM_ITERATIONS = 1;
056    private static final int DEFAULT_PASSWORD_NUM_ITERATIONS = DefaultPasswordService.DEFAULT_HASH_ITERATIONS;
057
058    private static final Option ALGORITHM = new Option("a", "algorithm", true, "hash algorithm name.  Defaults to SHA-256 when password hashing, MD5 otherwise.");
059    private static final Option DEBUG = new Option("d", "debug", false, "show additional error (stack trace) information.");
060    private static final Option FORMAT = new Option("f", "format", true, "hash output format.  Defaults to 'shiro1' when password hashing, 'hex' otherwise.  See below for more information.");
061    private static final Option HELP = new Option("help", "help", false, "show this help message.");
062    private static final Option ITERATIONS = new Option("i", "iterations", true, "number of hash iterations.  Defaults to " + DEFAULT_PASSWORD_NUM_ITERATIONS + " when password hashing, 1 otherwise.");
063    private static final Option PASSWORD = new Option("p", "password", false, "hash a password (disable typing echo)");
064    private static final Option PASSWORD_NC = new Option("pnc", "pnoconfirm", false, "hash a password (disable typing echo) but disable password confirmation prompt.");
065    private static final Option RESOURCE = new Option("r", "resource", false, "read and hash the resource located at <value>.  See below for more information.");
066    private static final Option SALT = new Option("s", "salt", true, "use the specified salt.  <arg> is plaintext.");
067    private static final Option SALT_BYTES = new Option("sb", "saltbytes", true, "use the specified salt bytes.  <arg> is hex or base64 encoded text.");
068    private static final Option SALT_GEN = new Option("gs", "gensalt", false, "generate and use a random salt. Defaults to true when password hashing, false otherwise.");
069    private static final Option NO_SALT_GEN = new Option("ngs", "nogensalt", false, "do NOT generate and use a random salt (valid during password hashing).");
070    private static final Option SALT_GEN_SIZE = new Option("gss", "gensaltsize", true, "the number of salt bits (not bytes!) to generate.  Defaults to 128.");
071
072    private static final String SALT_MUTEX_MSG = createMutexMessage(SALT, SALT_BYTES);
073
074    private static final HashFormatFactory HASH_FORMAT_FACTORY = new DefaultHashFormatFactory();
075
076    static {
077        ALGORITHM.setArgName("name");
078        SALT_GEN_SIZE.setArgName("numBits");
079        ITERATIONS.setArgName("num");
080        SALT.setArgName("sval");
081        SALT_BYTES.setArgName("encTxt");
082    }
083
084    public static void main(String[] args) {
085
086        CommandLineParser parser = new PosixParser();
087
088        Options options = new Options();
089        options.addOption(HELP).addOption(DEBUG).addOption(ALGORITHM).addOption(ITERATIONS);
090        options.addOption(RESOURCE).addOption(PASSWORD).addOption(PASSWORD_NC);
091        options.addOption(SALT).addOption(SALT_BYTES).addOption(SALT_GEN).addOption(SALT_GEN_SIZE).addOption(NO_SALT_GEN);
092        options.addOption(FORMAT);
093
094        boolean debug = false;
095        String algorithm = null; //user unspecified
096        int iterations = 0; //0 means unspecified by the end-user
097        boolean resource = false;
098        boolean password = false;
099        boolean passwordConfirm = true;
100        String saltString = null;
101        String saltBytesString = null;
102        boolean generateSalt = false;
103        int generatedSaltSize = DEFAULT_GENERATED_SALT_SIZE;
104
105        String formatString = null;
106
107        char[] passwordChars = null;
108
109        try {
110            CommandLine line = parser.parse(options, args);
111
112            if (line.hasOption(HELP.getOpt())) {
113                printHelpAndExit(options, null, debug, 0);
114            }
115            if (line.hasOption(DEBUG.getOpt())) {
116                debug = true;
117            }
118            if (line.hasOption(ALGORITHM.getOpt())) {
119                algorithm = line.getOptionValue(ALGORITHM.getOpt());
120            }
121            if (line.hasOption(ITERATIONS.getOpt())) {
122                iterations = getRequiredPositiveInt(line, ITERATIONS);
123            }
124            if (line.hasOption(PASSWORD.getOpt())) {
125                password = true;
126                generateSalt = true;
127            }
128            if (line.hasOption(RESOURCE.getOpt())) {
129                resource = true;
130            }
131            if (line.hasOption(PASSWORD_NC.getOpt())) {
132                password = true;
133                generateSalt = true;
134                passwordConfirm = false;
135            }
136            if (line.hasOption(SALT.getOpt())) {
137                saltString = line.getOptionValue(SALT.getOpt());
138            }
139            if (line.hasOption(SALT_BYTES.getOpt())) {
140                saltBytesString = line.getOptionValue(SALT_BYTES.getOpt());
141            }
142            if (line.hasOption(NO_SALT_GEN.getOpt())) {
143                generateSalt = false;
144            }
145            if (line.hasOption(SALT_GEN.getOpt())) {
146                generateSalt = true;
147            }
148            if (line.hasOption(SALT_GEN_SIZE.getOpt())) {
149                generateSalt = true;
150                generatedSaltSize = getRequiredPositiveInt(line, SALT_GEN_SIZE);
151                if (generatedSaltSize % 8 != 0) {
152                    throw new IllegalArgumentException("Generated salt size must be a multiple of 8 (e.g. 128, 192, 256, 512, etc).");
153                }
154            }
155            if (line.hasOption(FORMAT.getOpt())) {
156                formatString = line.getOptionValue(FORMAT.getOpt());
157            }
158
159            String sourceValue;
160
161            Object source;
162
163            if (password) {
164                passwordChars = readPassword(passwordConfirm);
165                source = passwordChars;
166            } else {
167                String[] remainingArgs = line.getArgs();
168                if (remainingArgs == null || remainingArgs.length != 1) {
169                    printHelpAndExit(options, null, debug, -1);
170                }
171
172                assert remainingArgs != null;
173                sourceValue = toString(remainingArgs);
174
175                if (resource) {
176                    if (!ResourceUtils.hasResourcePrefix(sourceValue)) {
177                        source = toFile(sourceValue);
178                    } else {
179                        source = ResourceUtils.getInputStreamForPath(sourceValue);
180                    }
181                } else {
182                    source = sourceValue;
183                }
184            }
185
186            if (algorithm == null) {
187                if (password) {
188                    algorithm = DEFAULT_PASSWORD_ALGORITHM_NAME;
189                } else {
190                    algorithm = DEFAULT_ALGORITHM_NAME;
191                }
192            }
193
194            if (iterations < DEFAULT_NUM_ITERATIONS) {
195                //Iterations were not specified.  Default to 350,000 when password hashing, and 1 for everything else:
196                if (password) {
197                    iterations = DEFAULT_PASSWORD_NUM_ITERATIONS;
198                } else {
199                    iterations = DEFAULT_NUM_ITERATIONS;
200                }
201            }
202
203            ByteSource salt = getSalt(saltString, saltBytesString, generateSalt, generatedSaltSize);
204
205            SimpleHash hash = new SimpleHash(algorithm, source, salt, iterations);
206
207            if (formatString == null) {
208                //Output format was not specified.  Default to 'shiro1' when password hashing, and 'hex' for
209                //everything else:
210                if (password) {
211                    formatString = Shiro1CryptFormat.class.getName();
212                } else {
213                    formatString = HexFormat.class.getName();
214                }
215            }
216
217            HashFormat format = HASH_FORMAT_FACTORY.getInstance(formatString);
218
219            if (format == null) {
220                throw new IllegalArgumentException("Unrecognized hash format '" + formatString + "'.");
221            }
222
223            String output = format.format(hash);
224
225            System.out.println(output);
226
227        } catch (IllegalArgumentException iae) {
228            exit(iae, debug);
229        } catch (UnknownAlgorithmException uae) {
230            exit(uae, debug);
231        } catch (IOException ioe) {
232            exit(ioe, debug);
233        } catch (Exception e) {
234            printHelpAndExit(options, e, debug, -1);
235        } finally {
236            if (passwordChars != null && passwordChars.length > 0) {
237                for (int i = 0; i < passwordChars.length; i++) {
238                    passwordChars[i] = ' ';
239                }
240            }
241        }
242    }
243
244    private static String createMutexMessage(Option... options) {
245        StringBuilder sb = new StringBuilder();
246        sb.append("The ");
247
248        for (int i = 0; i < options.length; i++) {
249            if (i > 0) {
250                sb.append(", ");
251            }
252            Option o = options[0];
253            sb.append("-").append(o.getOpt()).append("/--").append(o.getLongOpt());
254        }
255        sb.append(" and generated salt options are mutually exclusive.  Only one of them may be used at a time");
256        return sb.toString();
257    }
258
259    private static void exit(Exception e, boolean debug) {
260        printException(e, debug);
261        System.exit(-1);
262    }
263
264    private static int getRequiredPositiveInt(CommandLine line, Option option) {
265        String iterVal = line.getOptionValue(option.getOpt());
266        try {
267            return Integer.parseInt(iterVal);
268        } catch (NumberFormatException e) {
269            String msg = "'" + option.getLongOpt() + "' value must be a positive integer.";
270            throw new IllegalArgumentException(msg, e);
271        }
272    }
273
274    private static ByteSource getSalt(String saltString, String saltBytesString, boolean generateSalt, int generatedSaltSize) {
275
276        if (saltString != null) {
277            if (generateSalt || (saltBytesString != null)) {
278                throw new IllegalArgumentException(SALT_MUTEX_MSG);
279            }
280            return ByteSource.Util.bytes(saltString);
281        }
282
283        if (saltBytesString != null) {
284            if (generateSalt) {
285                throw new IllegalArgumentException(SALT_MUTEX_MSG);
286            }
287
288            String value = saltBytesString;
289            boolean base64 = true;
290            if (saltBytesString.startsWith(HEX_PREFIX)) {
291                //hex:
292                base64 = false;
293                value = value.substring(HEX_PREFIX.length());
294            }
295            byte[] bytes;
296            if (base64) {
297                bytes = Base64.decode(value);
298            } else {
299                bytes = Hex.decode(value);
300            }
301            return ByteSource.Util.bytes(bytes);
302        }
303
304        if (generateSalt) {
305            SecureRandomNumberGenerator generator = new SecureRandomNumberGenerator();
306            int byteSize = generatedSaltSize / 8; //generatedSaltSize is in *bits* - convert to byte size:
307            return generator.nextBytes(byteSize);
308        }
309
310        //no salt used:
311        return null;
312    }
313
314    private static void printException(Exception e, boolean debug) {
315        if (e != null) {
316            System.out.println();
317            if (debug) {
318                System.out.println("Error: ");
319                e.printStackTrace(System.out);
320                System.out.println(e.getMessage());
321
322            } else {
323                System.out.println("Error: " + e.getMessage());
324                System.out.println();
325                System.out.println("Specify -d or --debug for more information.");
326            }
327        }
328    }
329
330    private static void printHelp(Options options, Exception e, boolean debug) {
331        HelpFormatter help = new HelpFormatter();
332        String command = "java -jar shiro-tools-hasher-<version>.jar [options] [<value>]";
333        String header = "\nPrint a cryptographic hash (aka message digest) of the specified <value>.\n--\nOptions:";
334        String footer = "\n" +
335                "<value> is optional only when hashing passwords (see below).  It is\n" +
336                "required all other times." +
337                "\n\n" +
338                "Password Hashing:\n" +
339                "---------------------------------\n" +
340                "Specify the -p/--password option and DO NOT enter a <value>.  You will\n" +
341                "be prompted for a password and characters will not echo as you type." +
342                "\n\n" +
343                "Salting:\n" +
344                "---------------------------------\n" +
345                "Specifying a salt:" +
346                "\n\n" +
347                "You may specify a salt using the -s/--salt option followed by the salt\n" +
348                "value.  If the salt value is a base64 or hex string representing a\n" +
349                "byte array, you must specify the -sb/--saltbytes option to indicate this,\n" +
350                "otherwise the text value bytes will be used directly." +
351                "\n\n" +
352                "When using -sb/--saltbytes, the -s/--salt value is expected to be a\n" +
353                "base64-encoded string by default.  If the value is a hex-encoded string,\n" +
354                "you must prefix the string with 0x (zero x) to indicate a hex value." +
355                "\n\n" +
356                "Generating a salt:" +
357                "\n\n" +
358                "Use the -sg/--saltgenerated option if you don't want to specify a salt,\n" +
359                "but want a strong random salt to be generated and used during hashing.\n" +
360                "The generated salt size defaults to 128 bits.  You may specify\n" +
361                "a different size by using the -sgs/--saltgeneratedsize option followed by\n" +
362                "a positive integer (size is in bits, not bytes)." +
363                "\n\n" +
364                "Because a salt must be specified if computing the\n" +
365                "hash later, generated salts will be printed, defaulting to base64\n" +
366                "encoding.  If you prefer to use hex encoding, additionally use the\n" +
367                "-sgh/--saltgeneratedhex option." +
368                "\n\n" +
369                "Files, URLs and classpath resources:\n" +
370                "---------------------------------\n" +
371                "If using the -r/--resource option, the <value> represents a resource path.\n" +
372                "By default this is expected to be a file path, but you may specify\n" +
373                "classpath or URL resources by using the classpath: or url: prefix\n" +
374                "respectively." +
375                "\n\n" +
376                "Some examples:" +
377                "\n\n" +
378                "<command> -r fileInCurrentDirectory.txt\n" +
379                "<command> -r ../../relativePathFile.xml\n" +
380                "<command> -r ~/documents/myfile.pdf\n" +
381                "<command> -r /usr/local/logs/absolutePathFile.log\n" +
382                "<command> -r url:http://foo.com/page.html\n" +
383                "<command> -r classpath:/WEB-INF/lib/something.jar" +
384                "\n\n" +
385                "Output Format:\n" +
386                "---------------------------------\n" +
387                "Specify the -f/--format option followed by either 1) the format ID (as defined\n" +
388                "by the " + DefaultHashFormatFactory.class.getName() + "\n" +
389                "JavaDoc) or 2) the fully qualified " + HashFormat.class.getName() + "\n" +
390                "implementation class name to instantiate and use for formatting.\n\n" +
391                "The default output format is 'shiro1' which is a Modular Crypt Format (MCF)\n" +
392                "that shows all relevant information as a dollar-sign ($) delimited string.\n" +
393                "This format is ideal for use in Shiro's text-based user configuration (e.g.\n" +
394                "shiro.ini or a properties file).";
395
396        printException(e, debug);
397
398        System.out.println();
399        help.printHelp(command, header, options, null);
400        System.out.println(footer);
401    }
402
403    private static void printHelpAndExit(Options options, Exception e, boolean debug, int exitCode) {
404        printHelp(options, e, debug);
405        System.exit(exitCode);
406    }
407
408    private static char[] readPassword(boolean confirm) {
409        if (!JavaEnvironment.isAtLeastVersion16()) {
410            String msg = "Password hashing (prompt without echo) uses the java.io.Console to read passwords " +
411                    "safely.  This is only available on Java 1.6 platforms and later.";
412            throw new IllegalArgumentException(msg);
413        }
414        java.io.Console console = System.console();
415        if (console == null) {
416            throw new IllegalStateException("java.io.Console is not available on the current JVM.  Cannot read passwords.");
417        }
418        char[] first = console.readPassword("%s", "Password to hash: ");
419        if (first == null || first.length == 0) {
420            throw new IllegalArgumentException("No password specified.");
421        }
422        if (confirm) {
423            char[] second = console.readPassword("%s", "Password to hash (confirm): ");
424            if (!Arrays.equals(first, second)) {
425                String msg = "Password entries do not match.";
426                throw new IllegalArgumentException(msg);
427            }
428        }
429        return first;
430    }
431
432    private static File toFile(String path) {
433        String resolved = path;
434        if (path.startsWith("~/") || path.startsWith(("~\\"))) {
435            resolved = path.replaceFirst("\\~", System.getProperty("user.home"));
436        }
437        return new File(resolved);
438    }
439
440    private static String toString(String[] strings) {
441        int len = strings != null ? strings.length : 0;
442        if (len == 0) {
443            return null;
444        }
445        return StringUtils.toDelimitedString(strings, " ");
446    }
447}