From 2ea48416ae1d298cf600befb59b85c7937870904 Mon Sep 17 00:00:00 2001 From: Ronan Hanley Date: Mon, 8 Jun 2020 18:39:03 +0100 Subject: [PATCH 1/5] Implemented an "unmunger" to solve #45 * Started the munging rework * 'Unmunger' mostly working now, passes all original junit tests' * Added the MungeTable class * Added a JUnit test that passes * Added javadoc for new unmunging classes --- pom.xml | 4 +- .../nbvcxz/matching/DictionaryMatcher.java | 157 +++++++++++++----- .../gosimple/nbvcxz/matching/MungeTable.java | 80 +++++++++ .../nbvcxz/resources/Configuration.java | 17 +- .../resources/ConfigurationBuilder.java | 86 ++++++---- .../matching/DictionaryMatcherTest.java | 39 +++++ 6 files changed, 291 insertions(+), 92 deletions(-) create mode 100644 src/main/java/me/gosimple/nbvcxz/matching/MungeTable.java diff --git a/pom.xml b/pom.xml index 4ebd5a0..663e1bf 100644 --- a/pom.xml +++ b/pom.xml @@ -90,8 +90,8 @@ maven-compiler-plugin 3.8.1 - 1.7 - 1.7 + 8 + 8 diff --git a/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java b/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java index fab3689..b334103 100644 --- a/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java +++ b/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java @@ -5,10 +5,7 @@ import me.gosimple.nbvcxz.resources.Configuration; import me.gosimple.nbvcxz.resources.Dictionary; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.TreeMap; +import java.util.*; /** * Look for every part of the password that match an entry in our dictionaries @@ -18,69 +15,78 @@ public final class DictionaryMatcher implements PasswordMatcher { /** - * Removes all leet substitutions from the password and returns a list of plain text versions. + * Removes all munge substitutions from the password and returns a list of plain text versions. * * @param configuration the configuration file used to estimate entropy. - * @param password the password to translate from leet. - * @return a list of all combinations of possible leet translations for the password with all leet removed. + * @param password the password to unmunge. + * @return a list of all combinations of possible unmunged translations for the password with all munges removed. */ - private static List translateLeet(final Configuration configuration, final String password) + private static List getUnmungedVariations(final Configuration configuration, final String password) { - final List translations = new ArrayList(); - final TreeMap replacements = new TreeMap<>(); - - for (int i = 0; i < password.length(); i++) - { - final Character[] replacement = configuration.getLeetTable().get(password.charAt(i)); - if (replacement != null) - { - replacements.put(i, replacement); + MungeTable mungeTable = configuration.getMungeTable(); + PasswordChain chain = new PasswordChain(password); + + for (String mungeKey : mungeTable.getKeys()) { // keys sorted largest to smallest + for (int i = 0; i < chain.size(); i++) { + // remove this password part if it can be split up further + String unsplit = chain.removeIfUnsplit(i); + if (unsplit != null) { + // split password segment by the munge key + String[] splitParts = mungeTable.getKeyPattern(mungeKey).split(unsplit); + for (int j = 0; j < splitParts.length; j++) { + String sp = splitParts[j]; + // add the subs back into the chain, or just add the segment back if the munge key wasn't present + chain.add(i + j, mungeTable.getSubsOrOriginal(sp), sp.equals(mungeKey)); + } + } } } - // Do not bother continuing if we're going to replace every single character - if(replacements.keySet().size() == password.length()) - return translations; + final List translations = new ArrayList<>(); - if (replacements.size() > 0) + if (chain.size() > 1) { - final char[] password_char = new char[password.length()]; - for (int i = 0; i < password.length(); i++) - { - password_char[i] = password.charAt(i); - } - replaceAtIndex(replacements, replacements.firstKey(), password_char, translations); + // recursively generate all password permutations using the discovered munges + replaceAtIndex(chain.getParts(), 0, new int[chain.size()], translations); } return translations; } /** - * Internal function to recursively build the list of un-leet possibilities. + * Internal function to recursively build the list of possible unmunged password permutations. * - * @param replacements TreeMap of replacement index, and the possible characters at that index to be replaced - * @param current_index internal use for the function - * @param password a Character array of the original password - * @param final_passwords List of the final passwords to be filled + * @param replacements List of possible password replacement substrings + * @param index Next index to generate all permutations at + * @param usingIndexes Bijection of replacements, where usingIndexes[i] specifies the + * substring to use from replacements at the next iteration + * @param finalPasswords Full list of generated password permutations */ - private static void replaceAtIndex(final TreeMap replacements, Integer current_index, final char[] password, final List final_passwords) + private static void replaceAtIndex(final List replacements, int index, int[] usingIndexes, final List finalPasswords) { - for (final char replacement : replacements.get(current_index)) + if (index == replacements.size()) { + // reached the end; build the password using the current index permutation + StringBuilder passwordBuilder = new StringBuilder(); + for (int i = 0; i < replacements.size(); i++) { + passwordBuilder.append(replacements.get(i)[usingIndexes[i]]); + } + + finalPasswords.add(passwordBuilder.toString()); + return; + } + + // exhaust all of the substring permutations at the current index + for (int i = 0; i < replacements.get(index).length; i++) { - password[current_index] = replacement; - if (current_index.equals(replacements.lastKey())) + if (finalPasswords.size() < 100) { - final_passwords.add(new String(password)); + usingIndexes[index] = i; + replaceAtIndex(replacements, index + 1, usingIndexes, finalPasswords); } - else if (final_passwords.size() > 100) - { + else { // Give up if we've already made 100 replacements return; } - else - { - replaceAtIndex(replacements, replacements.higherKey(current_index), password, final_passwords); - } } } @@ -298,7 +304,7 @@ public List match(final Configuration configuration, final String passwor } // Only do unleet if it's different than the regular lower. - final List unleet_list = translateLeet(configuration, lower_part); + final List unleet_list = getUnmungedVariations(configuration, lower_part); for (final String unleet_part : unleet_list) { final Integer unleet_rank = dictionary.getDictonary().get(unleet_part); @@ -404,3 +410,66 @@ public List match(final Configuration configuration, final String passwor return matches; } } + +/** + * Represents password permutations, using a list of candidate substrings. For instance, the first permutation + * of the full password would be the 0th string from each part of the chain. This class is used to exhaust + * password munging combinations. + */ +class PasswordChain { + // substring candidates + private List parts; + // a bijection with the list above, where each index specifies if the candidate parts at an index have + // already been completely unmunged + private List converted; + + /** + * Creates a password chain using an initial String value. + */ + public PasswordChain(String originalPassword) { + this.parts = new LinkedList<>(); + this.converted = new LinkedList<>(); + + add(0, new String[] {originalPassword}, false); + } + + /** + * Adds a list of candidate substrings to a specific index in the chain. + * @param index Index to insert the candidates at + * @param subs Candidate substrings for that index + * @param converted Should be true if subs is the result of unmunging a part of the original password, false otherwise + */ + public void add(int index, String[] subs, boolean converted) { + parts.add(index, subs); + this.converted.add(index, converted); + } + + /** + * Removes a list of candidate substrings from the chain, only if it has yet to be split up. + * @param index Index of the candidate substrings + * @return The candidate substring (since the removed array was of length 1). Null if that index has been split already. + */ + public String removeIfUnsplit(int index) { + if (parts.get(index).length == 1 && !converted.get(index)) { // unsplit AND not yet unmunged + // return and remove the candidate part + converted.remove(index); + return parts.remove(index)[0]; + } + + return null; + } + + /** + * @return List of candidate substring parts + */ + public List getParts() { + return parts; + } + + /** + * @return Size of the chain + */ + public int size() { + return parts.size(); + } +} \ No newline at end of file diff --git a/src/main/java/me/gosimple/nbvcxz/matching/MungeTable.java b/src/main/java/me/gosimple/nbvcxz/matching/MungeTable.java new file mode 100644 index 0000000..f422de2 --- /dev/null +++ b/src/main/java/me/gosimple/nbvcxz/matching/MungeTable.java @@ -0,0 +1,80 @@ +package me.gosimple.nbvcxz.matching; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * A table mapping "munged" versions of a password substring to all the possible + * "unmunged" versions. For example, a password may contain a '1' that is used in place of an 'I'. + * This class was created to solve issue #45. It allows arbitrary substrings to be replaced with arbitrary strings. + * For example, "uu" can be used in place of 'w'. This class can also be used to do typical leetspeak substitutions, + * such as 4 -> A. + * + * See here for more on password munging: https://en.wikipedia.org/wiki/Munged_password + */ +public class MungeTable { + // regex used to split a password by a certain munge string (taking the place of %s) + private static final String splitRegex = "((?<=\\Q%s\\E)|(?=\\Q%s\\E))"; + // mapping of munged strings -> possible unmunged replacements + private Map table; + // keys from above, that should be sorted in order of descending key length + private List keys; + // map of munged strings -> replacement regex + private Map regexPatterns; + + public MungeTable() { + table = new HashMap<>(); + keys = new ArrayList<>(); + regexPatterns = new HashMap<>(); + } + + /** + * Adds a munged -> unmunged mapping to the table. + * @param key Munged string + * @param subs Possible unmunged replacements + * @return This object, for use with method chaining + */ + public MungeTable addSub(String key, String...subs) { + table.put(key, subs); + keys.add(key); + regexPatterns.put(key, Pattern.compile(String.format(splitRegex, key, key))); + return this; + } + + /** + * Sorts the munge keys in descending order, so that larger password munges + * are replaced first. Shuold be called after all subs are added using {@link MungeTable#addSub(String, String...)} + */ + public void sort() { + // sort keys by their length, descending + keys.sort( + (String k1, String k2) -> -Integer.compare(k1.length(), k2.length()) + ); + } + + /** + * @return List of all munge keys. Should be sorted using {@link MungeTable#sort()} + */ + public List getKeys() { + return keys; + } + + /** + * If the given key is in the table of password munges, returns the list + * of possible unmunges. Otherwise, just returns the given key. + */ + public String[] getSubsOrOriginal(String key) { + return table.getOrDefault(key, new String[] {key}); + } + + /** + * Returns the compiled regex pattern for a particular munged substring. This saves time, + * since the pattern doesn't have to be recompiled every time it is used. + */ + public Pattern getKeyPattern(String key) { + return regexPatterns.get(key); + } +} diff --git a/src/main/java/me/gosimple/nbvcxz/resources/Configuration.java b/src/main/java/me/gosimple/nbvcxz/resources/Configuration.java index 78dc8d7..a99ca18 100644 --- a/src/main/java/me/gosimple/nbvcxz/resources/Configuration.java +++ b/src/main/java/me/gosimple/nbvcxz/resources/Configuration.java @@ -1,9 +1,6 @@ package me.gosimple.nbvcxz.resources; -import me.gosimple.nbvcxz.matching.DictionaryMatcher; -import me.gosimple.nbvcxz.matching.PasswordMatcher; -import me.gosimple.nbvcxz.matching.SpacialMatcher; -import me.gosimple.nbvcxz.matching.YearMatcher; +import me.gosimple.nbvcxz.matching.*; import java.util.List; import java.util.Locale; @@ -22,7 +19,7 @@ public class Configuration private final Map guessTypes; private final List dictionaries; private final List adjacencyGraphs; - private final Map leetTable; + private final MungeTable mungeTable; private final Pattern yearPattern; private final Double minimumEntropy; private final Locale locale; @@ -36,20 +33,20 @@ public class Configuration * @param guessTypes Map of types of guesses, and associated guesses/sec * @param dictionaries List of {@link Dictionary} to use for the {@link DictionaryMatcher} * @param adjacencyGraphs List of adjacency graphs to be used by the {@link SpacialMatcher} - * @param leetTable Leet table for use with {@link DictionaryMatcher} + * @param mungeTable Munge table for use with {@link DictionaryMatcher} * @param yearPattern Regex {@link Pattern} for use with {@link YearMatcher} * @param minimumEntropy Minimum entropy value passwords should meet * @param locale Locale for localized text and feedback * @param distanceCalc Enable or disable levenshtein distance calculation for dictionary matches * @param combinationAlgorithmTimeout Timeout for the findBestMatches algorithm */ - public Configuration(List passwordMatchers, Map guessTypes, List dictionaries, List adjacencyGraphs, Map leetTable, Pattern yearPattern, Double minimumEntropy, Locale locale, boolean distanceCalc, long combinationAlgorithmTimeout) + public Configuration(List passwordMatchers, Map guessTypes, List dictionaries, List adjacencyGraphs, MungeTable mungeTable, Pattern yearPattern, Double minimumEntropy, Locale locale, boolean distanceCalc, long combinationAlgorithmTimeout) { this.passwordMatchers = passwordMatchers; this.guessTypes = guessTypes; this.dictionaries = dictionaries; this.adjacencyGraphs = adjacencyGraphs; - this.leetTable = leetTable; + this.mungeTable = mungeTable; this.yearPattern = yearPattern; this.minimumEntropy = minimumEntropy; this.locale = locale; @@ -94,9 +91,9 @@ public List getAdjacencyGraphs() /** * @return Leet table for use with {@link DictionaryMatcher} */ - public Map getLeetTable() + public MungeTable getMungeTable() { - return leetTable; + return mungeTable; } /** diff --git a/src/main/java/me/gosimple/nbvcxz/resources/ConfigurationBuilder.java b/src/main/java/me/gosimple/nbvcxz/resources/ConfigurationBuilder.java index 786bd53..09a5390 100644 --- a/src/main/java/me/gosimple/nbvcxz/resources/ConfigurationBuilder.java +++ b/src/main/java/me/gosimple/nbvcxz/resources/ConfigurationBuilder.java @@ -1,14 +1,7 @@ package me.gosimple.nbvcxz.resources; import me.gosimple.nbvcxz.Nbvcxz; -import me.gosimple.nbvcxz.matching.DateMatcher; -import me.gosimple.nbvcxz.matching.DictionaryMatcher; -import me.gosimple.nbvcxz.matching.PasswordMatcher; -import me.gosimple.nbvcxz.matching.RepeatMatcher; -import me.gosimple.nbvcxz.matching.SeparatorMatcher; -import me.gosimple.nbvcxz.matching.SequenceMatcher; -import me.gosimple.nbvcxz.matching.SpacialMatcher; -import me.gosimple.nbvcxz.matching.YearMatcher; +import me.gosimple.nbvcxz.matching.*; import me.gosimple.nbvcxz.matching.match.Match; import java.math.BigDecimal; @@ -33,7 +26,7 @@ public class ConfigurationBuilder private static final List defaultDictionaries = new ArrayList<>(); private static final List defaultPasswordMatchers = new ArrayList<>(); private static final List defaultAdjacencyGraphs = new ArrayList<>(); - private static final Map defaultLeetTable = new HashMap<>(); + private static final MungeTable defaultMungeTable = new MungeTable(); static { @@ -56,35 +49,56 @@ public class ConfigurationBuilder defaultAdjacencyGraphs.add(new AdjacencyGraph("Standard Keypad", AdjacencyGraphUtil.standardKeypad)); defaultAdjacencyGraphs.add(new AdjacencyGraph("Mac Keypad", AdjacencyGraphUtil.macKeypad)); - defaultLeetTable.put('4', new Character[]{'a'}); - defaultLeetTable.put('@', new Character[]{'a'}); - defaultLeetTable.put('8', new Character[]{'b'}); - defaultLeetTable.put('(', new Character[]{'c'}); - defaultLeetTable.put('{', new Character[]{'c'}); - defaultLeetTable.put('[', new Character[]{'c'}); - defaultLeetTable.put('<', new Character[]{'c'}); - defaultLeetTable.put('3', new Character[]{'e'}); - defaultLeetTable.put('9', new Character[]{'g'}); - defaultLeetTable.put('6', new Character[]{'g'}); - defaultLeetTable.put('&', new Character[]{'g'}); - defaultLeetTable.put('#', new Character[]{'h'}); - defaultLeetTable.put('!', new Character[]{'i', 'l'}); - defaultLeetTable.put('1', new Character[]{'i', 'l'}); - defaultLeetTable.put('|', new Character[]{'i', 'l'}); - defaultLeetTable.put('0', new Character[]{'o'}); - defaultLeetTable.put('$', new Character[]{'s'}); - defaultLeetTable.put('5', new Character[]{'s'}); - defaultLeetTable.put('+', new Character[]{'t'}); - defaultLeetTable.put('7', new Character[]{'t', 'l'}); - defaultLeetTable.put('%', new Character[]{'x'}); - defaultLeetTable.put('2', new Character[]{'z'}); + defaultMungeTable + // simple single character substitutions (mostly leet speak) + .addSub("4", "a") + .addSub("@", "a") + .addSub("8", "b") + .addSub("(", "c") + .addSub("{", "c") + .addSub("[", "c") + .addSub("<", "c", "k", "v") + .addSub(">", "v") + .addSub("3", "e") + .addSub("9", "g", "q") + .addSub("6", "d", "g") + .addSub("&", "g") + .addSub("#", "f", "h") + .addSub("!", "i", "l") + .addSub("1", "i", "l") + .addSub("|", "i", "l") + .addSub("0", "o") + .addSub("$", "s") + .addSub("5", "s") + .addSub("+", "t") + .addSub("7", "t", "l") + .addSub("%", "x") + .addSub("2", "z") + // extra "munged" variations from here: https://en.wikipedia.org/wiki/Munged_password + .addSub("?", "y") // (y = why?) + .addSub("uu", "w") + .addSub("vv", "w") + .addSub("nn", "m") + .addSub("2u", "uu", "w") + .addSub("2v", "vv", "w") + .addSub("2n", "nn", "m") + .addSub("2b", "bb") + .addSub("2d", "dd") + .addSub("2g", "gg") + .addSub("2l", "ll") + .addSub("2p", "pp") + .addSub("2t", "tt") + .addSub("\\/\\/", "w") + .addSub("/\\/\\", "m") + .addSub("|)", "d") + .sort(); } private List passwordMatchers; private Map guessTypes; private List dictionaries; private List adjacencyGraphs; - private Map leetTable; + private MungeTable leetTable; private Pattern yearPattern; private Double minimumEntropy; private Locale locale; @@ -179,9 +193,9 @@ public static List getDefaultAdjacencyGraphs() /** * @return The default table of common english leet substitutions */ - public static Map getDefaultLeetTable() + public static MungeTable getDefaultMungeTable() { - return defaultLeetTable; + return defaultMungeTable; } /** @@ -282,7 +296,7 @@ public ConfigurationBuilder setAdjacencyGraphs(List adjacencyGra * @param leetTable Map for leetTable * @return Builder */ - public ConfigurationBuilder setLeetTable(Map leetTable) + public ConfigurationBuilder setLeetTable(MungeTable leetTable) { this.leetTable = leetTable; return this; @@ -419,7 +433,7 @@ public Configuration createConfiguration() } if (leetTable == null) { - leetTable = getDefaultLeetTable(); + leetTable = getDefaultMungeTable(); } if (yearPattern == null) { diff --git a/src/test/java/me/gosimple/nbvcxz/matching/DictionaryMatcherTest.java b/src/test/java/me/gosimple/nbvcxz/matching/DictionaryMatcherTest.java index cefb23e..5adc184 100644 --- a/src/test/java/me/gosimple/nbvcxz/matching/DictionaryMatcherTest.java +++ b/src/test/java/me/gosimple/nbvcxz/matching/DictionaryMatcherTest.java @@ -9,7 +9,10 @@ import org.junit.Test; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; /** * @author Adam Brusselback @@ -190,6 +193,42 @@ public void testDictionaryMatchLD() } + /** + * Test of passwords that contain munged substitutions of arbitrary lengths + * (E.g. 'w' could be written as 'uu') + */ + @Test + public void testArbitraryLengthSubstitutions() + { + System.out.println("Test of arbitrary length munge substitutions, of class DictionaryMatcher"); + + PasswordMatcher matcher = new DictionaryMatcher(); + + // create a table of expected dictionary matches + Map mappings = new HashMap<>(); + mappings.put("P@55uu0rd", "password"); // uu = w (from issue #45) + mappings.put("/\\/\\3G4", "mega"); // /\/\ = m + mappings.put("|)R!2b|3", "dribble"); // |) = D, 2b = bb + mappings.put("/\\/\\02!2l4", "mozilla"); // /\/\02!2l4 (2l = l) + mappings.put("so2n3", "some"); // 2n could mean nn or m, make sure 'm' is used + mappings.put("pe2n", "penn"); // same as above, but expecting 2n = nn + + for (String munged : mappings.keySet()) { + // make a list of all the dictionary matches that were made, + // using streams to convert a list of matches to a list of each match's dictionary value + List dictValues = matcher.match(configuration, munged) + .stream() + .map(DictionaryMatch.class::cast) // this is kind of dirty + .map(DictionaryMatch::getDictionaryValue) + .collect(Collectors.toList()); + + // make sure the "unmunged" version is in the list of dictionary value matches somewhere + String unmunged = mappings.get(munged); + String message = String.format("'%s' did not get matched to '%s'", munged, unmunged); + Assert.assertTrue(message, dictValues.contains(unmunged)); + } + } + private int calcHash(List matches) { int calculatedHash = 0; From 786b9212708d81d3fb7e861d2d813e02de4c5398 Mon Sep 17 00:00:00 2001 From: ronan Date: Thu, 11 Jun 2020 12:45:52 +0100 Subject: [PATCH 2/5] Performance improvements for the unmunger --- .../nbvcxz/matching/DictionaryMatcher.java | 115 +++++++++++------- .../matching/DictionaryMatcherTest.java | 2 + 2 files changed, 74 insertions(+), 43 deletions(-) diff --git a/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java b/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java index b334103..6116d94 100644 --- a/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java +++ b/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java @@ -28,15 +28,25 @@ private static List getUnmungedVariations(final Configuration configurat for (String mungeKey : mungeTable.getKeys()) { // keys sorted largest to smallest for (int i = 0; i < chain.size(); i++) { - // remove this password part if it can be split up further - String unsplit = chain.removeIfUnsplit(i); - if (unsplit != null) { + // get the next password part (will be null if it doesn't contain the munge key) + String part = chain.getPartIfContainsKey(i, mungeKey); + if (part != null) { // split password segment by the munge key - String[] splitParts = mungeTable.getKeyPattern(mungeKey).split(unsplit); + String[] splitParts = mungeTable.getKeyPattern(mungeKey).split(part); + for (int j = 0; j < splitParts.length; j++) { + // index of where the replacements go in the chain + int index = i + j; String sp = splitParts[j]; - // add the subs back into the chain, or just add the segment back if the munge key wasn't present - chain.add(i + j, mungeTable.getSubsOrOriginal(sp), sp.equals(mungeKey)); + boolean converted = sp.equals(mungeKey); + String[] subs = mungeTable.getSubsOrOriginal(sp); + // add the replacements back into the chain + if (j == 0) { + chain.replace(index, subs, converted); + } + else { + chain.add(index, subs, converted); + } } } } @@ -47,7 +57,7 @@ private static List getUnmungedVariations(final Configuration configurat if (chain.size() > 1) { // recursively generate all password permutations using the discovered munges - replaceAtIndex(chain.getParts(), 0, new int[chain.size()], translations); + replaceAtIndex(chain.getParts(), 0, new StringBuilder(), translations); } return translations; @@ -56,35 +66,34 @@ private static List getUnmungedVariations(final Configuration configurat /** * Internal function to recursively build the list of possible unmunged password permutations. * - * @param replacements List of possible password replacement substrings + * @param replacements 2D array of possible password replacement substrings * @param index Next index to generate all permutations at - * @param usingIndexes Bijection of replacements, where usingIndexes[i] specifies the - * substring to use from replacements at the next iteration + * @param buffer Buffer used to incrementally build each password permutation * @param finalPasswords Full list of generated password permutations */ - private static void replaceAtIndex(final List replacements, int index, int[] usingIndexes, final List finalPasswords) + private static void replaceAtIndex(final String[][] replacements, int index, StringBuilder buffer, final List finalPasswords) { - if (index == replacements.size()) { - // reached the end; build the password using the current index permutation - StringBuilder passwordBuilder = new StringBuilder(); - for (int i = 0; i < replacements.size(); i++) { - passwordBuilder.append(replacements.get(i)[usingIndexes[i]]); - } - - finalPasswords.add(passwordBuilder.toString()); + if (index == replacements.length) { + // reached the end; add the contents of the buffer to the list of password permutations + finalPasswords.add(buffer.toString()); return; } // exhaust all of the substring permutations at the current index - for (int i = 0; i < replacements.get(index).length; i++) + for (int i = 0; i < replacements[index].length; i++) { if (finalPasswords.size() < 100) { - usingIndexes[index] = i; - replaceAtIndex(replacements, index + 1, usingIndexes, finalPasswords); + // add the next substring permutation to the buffer + String postfix = replacements[index][i]; + buffer.append(postfix); + // recursively build the rest of the string + replaceAtIndex(replacements, index + 1, buffer, finalPasswords); + // backtrack by ignoring the added postfix + buffer.setLength(buffer.length() - postfix.length()); } else { - // Give up if we've already made 100 replacements + // give up if we've already generated 100 password permutations return; } } @@ -427,16 +436,45 @@ class PasswordChain { * Creates a password chain using an initial String value. */ public PasswordChain(String originalPassword) { - this.parts = new LinkedList<>(); - this.converted = new LinkedList<>(); + this.parts = new ArrayList<>(); + this.converted = new ArrayList<>(); add(0, new String[] {originalPassword}, false); } /** - * Adds a list of candidate substrings to a specific index in the chain. + * Gets the password part at an index, if it contains the munge key + * and has not been substituted yet. + * @param index Index of the part to return + * @param key The munge key + * @return Single password part, containing the munge key, or null if the requirements were not met + */ + public String getPartIfContainsKey(int index, String key) { + String[] subParts = parts.get(index); + String firstPart = subParts[0]; + if (!converted.get(index) && firstPart.contains(key)) { + return firstPart; + } + else { + return null; + } + } + + /** + * Replaces a part of the chain with possible substitutes. + * @param index Index of the part of the chain to replace + * @param subs Possible password munging substitutes + * @param converted Should be true if subs is the result of unmunging a part of the original password, false otherwise + */ + public void replace(int index, String[] subs, boolean converted) { + parts.set(index, subs); + this.converted.set(index, converted); + } + + /** + * Adds a list of possible password unmunge substitutes to a specific index in the chain. * @param index Index to insert the candidates at - * @param subs Candidate substrings for that index + * @param subs Possible password munging substitutes * @param converted Should be true if subs is the result of unmunging a part of the original password, false otherwise */ public void add(int index, String[] subs, boolean converted) { @@ -445,25 +483,16 @@ public void add(int index, String[] subs, boolean converted) { } /** - * Removes a list of candidate substrings from the chain, only if it has yet to be split up. - * @param index Index of the candidate substrings - * @return The candidate substring (since the removed array was of length 1). Null if that index has been split already. + * @return 2D string array representation of this password chain */ - public String removeIfUnsplit(int index) { - if (parts.get(index).length == 1 && !converted.get(index)) { // unsplit AND not yet unmunged - // return and remove the candidate part - converted.remove(index); - return parts.remove(index)[0]; + public String[][] getParts() { + // convert List of String[] to String[][] + String[][] replacements = new String[parts.size()][]; + for (int i = 0; i < parts.size(); i++) { + replacements[i] = parts.get(i); } - return null; - } - - /** - * @return List of candidate substring parts - */ - public List getParts() { - return parts; + return replacements; } /** diff --git a/src/test/java/me/gosimple/nbvcxz/matching/DictionaryMatcherTest.java b/src/test/java/me/gosimple/nbvcxz/matching/DictionaryMatcherTest.java index 5adc184..c0ddbda 100644 --- a/src/test/java/me/gosimple/nbvcxz/matching/DictionaryMatcherTest.java +++ b/src/test/java/me/gosimple/nbvcxz/matching/DictionaryMatcherTest.java @@ -210,6 +210,8 @@ public void testArbitraryLengthSubstitutions() mappings.put("/\\/\\3G4", "mega"); // /\/\ = m mappings.put("|)R!2b|3", "dribble"); // |) = D, 2b = bb mappings.put("/\\/\\02!2l4", "mozilla"); // /\/\02!2l4 (2l = l) + mappings.put("802t13", "bottle"); // 2t = tt + mappings.put("nn!|)|)|3", "middle"); // nn = m mappings.put("so2n3", "some"); // 2n could mean nn or m, make sure 'm' is used mappings.put("pe2n", "penn"); // same as above, but expecting 2n = nn From 1dea35c4c1b2aedcfe8f0b88741ac3cb81b25ecd Mon Sep 17 00:00:00 2001 From: ronan Date: Thu, 11 Jun 2020 14:05:55 +0100 Subject: [PATCH 3/5] Moved MungeTable and PasswordChain to resources package --- .../nbvcxz/matching/DictionaryMatcher.java | 85 +----------------- .../{matching => resources}/MungeTable.java | 2 +- .../nbvcxz/resources/PasswordChain.java | 87 +++++++++++++++++++ 3 files changed, 90 insertions(+), 84 deletions(-) rename src/main/java/me/gosimple/nbvcxz/{matching => resources}/MungeTable.java (98%) create mode 100644 src/main/java/me/gosimple/nbvcxz/resources/PasswordChain.java diff --git a/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java b/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java index 6116d94..2fa9e8b 100644 --- a/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java +++ b/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java @@ -4,6 +4,8 @@ import me.gosimple.nbvcxz.matching.match.Match; import me.gosimple.nbvcxz.resources.Configuration; import me.gosimple.nbvcxz.resources.Dictionary; +import me.gosimple.nbvcxz.resources.MungeTable; +import me.gosimple.nbvcxz.resources.PasswordChain; import java.util.*; @@ -419,86 +421,3 @@ public List match(final Configuration configuration, final String passwor return matches; } } - -/** - * Represents password permutations, using a list of candidate substrings. For instance, the first permutation - * of the full password would be the 0th string from each part of the chain. This class is used to exhaust - * password munging combinations. - */ -class PasswordChain { - // substring candidates - private List parts; - // a bijection with the list above, where each index specifies if the candidate parts at an index have - // already been completely unmunged - private List converted; - - /** - * Creates a password chain using an initial String value. - */ - public PasswordChain(String originalPassword) { - this.parts = new ArrayList<>(); - this.converted = new ArrayList<>(); - - add(0, new String[] {originalPassword}, false); - } - - /** - * Gets the password part at an index, if it contains the munge key - * and has not been substituted yet. - * @param index Index of the part to return - * @param key The munge key - * @return Single password part, containing the munge key, or null if the requirements were not met - */ - public String getPartIfContainsKey(int index, String key) { - String[] subParts = parts.get(index); - String firstPart = subParts[0]; - if (!converted.get(index) && firstPart.contains(key)) { - return firstPart; - } - else { - return null; - } - } - - /** - * Replaces a part of the chain with possible substitutes. - * @param index Index of the part of the chain to replace - * @param subs Possible password munging substitutes - * @param converted Should be true if subs is the result of unmunging a part of the original password, false otherwise - */ - public void replace(int index, String[] subs, boolean converted) { - parts.set(index, subs); - this.converted.set(index, converted); - } - - /** - * Adds a list of possible password unmunge substitutes to a specific index in the chain. - * @param index Index to insert the candidates at - * @param subs Possible password munging substitutes - * @param converted Should be true if subs is the result of unmunging a part of the original password, false otherwise - */ - public void add(int index, String[] subs, boolean converted) { - parts.add(index, subs); - this.converted.add(index, converted); - } - - /** - * @return 2D string array representation of this password chain - */ - public String[][] getParts() { - // convert List of String[] to String[][] - String[][] replacements = new String[parts.size()][]; - for (int i = 0; i < parts.size(); i++) { - replacements[i] = parts.get(i); - } - - return replacements; - } - - /** - * @return Size of the chain - */ - public int size() { - return parts.size(); - } -} \ No newline at end of file diff --git a/src/main/java/me/gosimple/nbvcxz/matching/MungeTable.java b/src/main/java/me/gosimple/nbvcxz/resources/MungeTable.java similarity index 98% rename from src/main/java/me/gosimple/nbvcxz/matching/MungeTable.java rename to src/main/java/me/gosimple/nbvcxz/resources/MungeTable.java index f422de2..faf4ee3 100644 --- a/src/main/java/me/gosimple/nbvcxz/matching/MungeTable.java +++ b/src/main/java/me/gosimple/nbvcxz/resources/MungeTable.java @@ -1,4 +1,4 @@ -package me.gosimple.nbvcxz.matching; +package me.gosimple.nbvcxz.resources; import java.util.ArrayList; import java.util.HashMap; diff --git a/src/main/java/me/gosimple/nbvcxz/resources/PasswordChain.java b/src/main/java/me/gosimple/nbvcxz/resources/PasswordChain.java new file mode 100644 index 0000000..d9c8b7b --- /dev/null +++ b/src/main/java/me/gosimple/nbvcxz/resources/PasswordChain.java @@ -0,0 +1,87 @@ +package me.gosimple.nbvcxz.resources; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents password permutations, using a list of candidate substrings. For instance, the first permutation + * of the full password would be the 0th string from each part of the chain. This class is used to exhaust + * password munging combinations. + */ +public class PasswordChain { + // substring candidates + private List parts; + // a bijection with the list above, where each index specifies if the candidate parts at an index have + // already been completely unmunged + private List converted; + + /** + * Creates a password chain using an initial String value. + */ + public PasswordChain(String originalPassword) { + this.parts = new ArrayList<>(); + this.converted = new ArrayList<>(); + + add(0, new String[] {originalPassword}, false); + } + + /** + * Gets the password part at an index, if it contains the munge key + * and has not been substituted yet. + * @param index Index of the part to return + * @param key The munge key + * @return Single password part, containing the munge key, or null if the requirements were not met + */ + public String getPartIfContainsKey(int index, String key) { + String[] subParts = parts.get(index); + String firstPart = subParts[0]; + if (!converted.get(index) && firstPart.contains(key)) { + return firstPart; + } + else { + return null; + } + } + + /** + * Replaces a part of the chain with possible substitutes. + * @param index Index of the part of the chain to replace + * @param subs Possible password munging substitutes + * @param converted Should be true if subs is the result of unmunging a part of the original password, false otherwise + */ + public void replace(int index, String[] subs, boolean converted) { + parts.set(index, subs); + this.converted.set(index, converted); + } + + /** + * Adds a list of possible password unmunge substitutes to a specific index in the chain. + * @param index Index to insert the candidates at + * @param subs Possible password munging substitutes + * @param converted Should be true if subs is the result of unmunging a part of the original password, false otherwise + */ + public void add(int index, String[] subs, boolean converted) { + parts.add(index, subs); + this.converted.add(index, converted); + } + + /** + * @return 2D string array representation of this password chain + */ + public String[][] getParts() { + // convert List of String[] to String[][] + String[][] replacements = new String[parts.size()][]; + for (int i = 0; i < parts.size(); i++) { + replacements[i] = parts.get(i); + } + + return replacements; + } + + /** + * @return Size of the chain + */ + public int size() { + return parts.size(); + } +} \ No newline at end of file From ebf117eb1e8465c0720283394fbde3a1d0eec896 Mon Sep 17 00:00:00 2001 From: ronan Date: Fri, 12 Jun 2020 11:03:50 +0100 Subject: [PATCH 4/5] Added optimization from the old implementation --- .../me/gosimple/nbvcxz/matching/DictionaryMatcher.java | 5 +++++ .../java/me/gosimple/nbvcxz/resources/PasswordChain.java | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java b/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java index 2fa9e8b..26c964f 100644 --- a/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java +++ b/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java @@ -56,6 +56,11 @@ private static List getUnmungedVariations(final Configuration configurat final List translations = new ArrayList<>(); + // do not bother continuing if we're going to replace every single character + if (chain.allReplaced()) { + return translations; + } + if (chain.size() > 1) { // recursively generate all password permutations using the discovered munges diff --git a/src/main/java/me/gosimple/nbvcxz/resources/PasswordChain.java b/src/main/java/me/gosimple/nbvcxz/resources/PasswordChain.java index d9c8b7b..71e1f87 100644 --- a/src/main/java/me/gosimple/nbvcxz/resources/PasswordChain.java +++ b/src/main/java/me/gosimple/nbvcxz/resources/PasswordChain.java @@ -78,6 +78,13 @@ public String[][] getParts() { return replacements; } + /** + * @return True if all of the parts of the chain have been replaced, false otherwise. + */ + public boolean allReplaced() { + return converted.stream().allMatch(b -> b); + } + /** * @return Size of the chain */ From 5cde6688a60459a93eb5fd8a7d28daf77ba88a57 Mon Sep 17 00:00:00 2001 From: ronan Date: Fri, 12 Jun 2020 13:02:25 +0100 Subject: [PATCH 5/5] Redid optimization from previous commit --- .../nbvcxz/matching/DictionaryMatcher.java | 28 +++++++++++-------- .../gosimple/nbvcxz/resources/MungeTable.java | 15 +++++++--- .../nbvcxz/resources/PasswordChain.java | 14 +++++++++- .../matching/DictionaryMatcherTest.java | 8 +++--- 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java b/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java index 26c964f..b0d1db4 100644 --- a/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java +++ b/src/main/java/me/gosimple/nbvcxz/matching/DictionaryMatcher.java @@ -25,6 +25,7 @@ public final class DictionaryMatcher implements PasswordMatcher */ private static List getUnmungedVariations(final Configuration configuration, final String password) { + final List translations = new ArrayList<>(); MungeTable mungeTable = configuration.getMungeTable(); PasswordChain chain = new PasswordChain(password); @@ -39,28 +40,31 @@ private static List getUnmungedVariations(final Configuration configurat for (int j = 0; j < splitParts.length; j++) { // index of where the replacements go in the chain int index = i + j; - String sp = splitParts[j]; - boolean converted = sp.equals(mungeKey); - String[] subs = mungeTable.getSubsOrOriginal(sp); + String splitPart = splitParts[j]; + // check if this substring can be replaced with substitutes, and get the subs if it can + boolean replaceable = mungeTable.isReplaceable(splitPart); + String[] subs = (replaceable ? mungeTable.getSubs(splitPart) : new String[] {splitPart}); // add the replacements back into the chain if (j == 0) { - chain.replace(index, subs, converted); + chain.replace(index, subs, replaceable); } else { - chain.add(index, subs, converted); + chain.add(index, subs, replaceable); + } + + if (replaceable) { + chain.recordCharsConverted(splitPart.length()); } } + + // do not bother continuing if we're going to replace every single character + if (chain.allReplaced()) { + return translations; + } } } } - final List translations = new ArrayList<>(); - - // do not bother continuing if we're going to replace every single character - if (chain.allReplaced()) { - return translations; - } - if (chain.size() > 1) { // recursively generate all password permutations using the discovered munges diff --git a/src/main/java/me/gosimple/nbvcxz/resources/MungeTable.java b/src/main/java/me/gosimple/nbvcxz/resources/MungeTable.java index faf4ee3..de2d474 100644 --- a/src/main/java/me/gosimple/nbvcxz/resources/MungeTable.java +++ b/src/main/java/me/gosimple/nbvcxz/resources/MungeTable.java @@ -63,11 +63,18 @@ public List getKeys() { } /** - * If the given key is in the table of password munges, returns the list - * of possible unmunges. Otherwise, just returns the given key. + * @return List of possible substitutes for a given munged substring */ - public String[] getSubsOrOriginal(String key) { - return table.getOrDefault(key, new String[] {key}); + public String[] getSubs(String key) { + return table.get(key); + } + + /** + * @param key Munged substring key + * @return True if the munged key can be replaced with substitutes (is in the table), false otherwise + */ + public boolean isReplaceable(String key) { + return table.containsKey(key); } /** diff --git a/src/main/java/me/gosimple/nbvcxz/resources/PasswordChain.java b/src/main/java/me/gosimple/nbvcxz/resources/PasswordChain.java index 71e1f87..c403cf9 100644 --- a/src/main/java/me/gosimple/nbvcxz/resources/PasswordChain.java +++ b/src/main/java/me/gosimple/nbvcxz/resources/PasswordChain.java @@ -14,6 +14,8 @@ public class PasswordChain { // a bijection with the list above, where each index specifies if the candidate parts at an index have // already been completely unmunged private List converted; + // number of password characters that have not yet been replaced with substitutes + private int unconvertedRemaining; /** * Creates a password chain using an initial String value. @@ -23,6 +25,7 @@ public PasswordChain(String originalPassword) { this.converted = new ArrayList<>(); add(0, new String[] {originalPassword}, false); + unconvertedRemaining = originalPassword.length(); } /** @@ -65,6 +68,15 @@ public void add(int index, String[] subs, boolean converted) { this.converted.add(index, converted); } + /** + * Record that a certain number of characters have been replaced when part of the chain + * has been replaced with substitutes. + * @param num Number of characters that were replaced with substitutes + */ + public void recordCharsConverted(int num) { + unconvertedRemaining -= num; + } + /** * @return 2D string array representation of this password chain */ @@ -82,7 +94,7 @@ public String[][] getParts() { * @return True if all of the parts of the chain have been replaced, false otherwise. */ public boolean allReplaced() { - return converted.stream().allMatch(b -> b); + return unconvertedRemaining == 0; } /** diff --git a/src/test/java/me/gosimple/nbvcxz/matching/DictionaryMatcherTest.java b/src/test/java/me/gosimple/nbvcxz/matching/DictionaryMatcherTest.java index c0ddbda..0a21e63 100644 --- a/src/test/java/me/gosimple/nbvcxz/matching/DictionaryMatcherTest.java +++ b/src/test/java/me/gosimple/nbvcxz/matching/DictionaryMatcherTest.java @@ -207,11 +207,11 @@ public void testArbitraryLengthSubstitutions() // create a table of expected dictionary matches Map mappings = new HashMap<>(); mappings.put("P@55uu0rd", "password"); // uu = w (from issue #45) - mappings.put("/\\/\\3G4", "mega"); // /\/\ = m + mappings.put("/\\/\\3GA", "mega"); // /\/\ = m mappings.put("|)R!2b|3", "dribble"); // |) = D, 2b = bb - mappings.put("/\\/\\02!2l4", "mozilla"); // /\/\02!2l4 (2l = l) - mappings.put("802t13", "bottle"); // 2t = tt - mappings.put("nn!|)|)|3", "middle"); // nn = m + mappings.put("/\\/\\02!2la", "mozilla"); // /\/\02!2l4 (2l = l) + mappings.put("B02t13", "bottle"); // 2t = tt + mappings.put("nn!|)|)l3", "middle"); // nn = m mappings.put("so2n3", "some"); // 2n could mean nn or m, make sure 'm' is used mappings.put("pe2n", "penn"); // same as above, but expecting 2n = nn