A => .gitattributes +9 -0
@@ 1,9 @@
+#
+# https://help.github.com/articles/dealing-with-line-endings/
+#
+# Linux start script should use lf
+/gradlew text eol=lf
+
+# These are Windows script files and should use crlf
+*.bat text eol=crlf
+
A => .gitignore +170 -0
@@ 1,170 @@
+# Created by https://www.toptal.com/developers/gitignore/api/java,gradle,intellij
+# Edit at https://www.toptal.com/developers/gitignore?templates=java,gradle,intellij
+
+### Intellij ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# AWS User-specific
+.idea/**/aws.xml
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# SonarLint plugin
+.idea/sonarlint/
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+### Intellij Patch ###
+# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
+
+# *.iml
+# modules.xml
+# .idea/misc.xml
+# *.ipr
+
+# Sonarlint plugin
+# https://plugins.jetbrains.com/plugin/7973-sonarlint
+.idea/**/sonarlint/
+
+# SonarQube Plugin
+# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
+.idea/**/sonarIssues.xml
+
+# Markdown Navigator plugin
+# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
+.idea/**/markdown-navigator.xml
+.idea/**/markdown-navigator-enh.xml
+.idea/**/markdown-navigator/
+
+# Cache file creation bug
+# See https://youtrack.jetbrains.com/issue/JBR-2257
+.idea/$CACHE_FILE$
+
+# CodeStream plugin
+# https://plugins.jetbrains.com/plugin/12206-codestream
+.idea/codestream.xml
+
+# Azure Toolkit for IntelliJ plugin
+# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
+.idea/**/azureSettings.xml
+
+### Java ###
+# Compiled class file
+*.class
+
+# Log file
+*.log
+
+# BlueJ files
+*.ctxt
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.nar
+*.ear
+*.zip
+*.tar.gz
+*.rar
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+replay_pid*
+
+### Gradle ###
+.gradle
+**/build/
+!src/**/build/
+
+# Ignore Gradle GUI config
+gradle-app.setting
+
+# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
+!gradle-wrapper.jar
+
+# Avoid ignore Gradle wrappper properties
+!gradle-wrapper.properties
+
+# Cache of project
+.gradletasknamecache
+
+# Eclipse Gradle plugin generated files
+# Eclipse Core
+.project
+# JDT-specific (Eclipse Java Development Tools)
+.classpath
+
+### Gradle Patch ###
+# Java heap dump
+*.hprof
+
+# End of https://www.toptal.com/developers/gitignore/api/java,gradle,intellij
A => .idea/.gitignore +3 -0
@@ 1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
A => .idea/compiler.xml +6 -0
@@ 1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="CompilerConfiguration">
+ <bytecodeTargetLevel target="17" />
+ </component>
+</project><
\ No newline at end of file
A => .idea/jarRepositories.xml +20 -0
@@ 1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="RemoteRepositoriesConfiguration">
+ <remote-repository>
+ <option name="id" value="central" />
+ <option name="name" value="Maven Central repository" />
+ <option name="url" value="https://repo1.maven.org/maven2" />
+ </remote-repository>
+ <remote-repository>
+ <option name="id" value="jboss.community" />
+ <option name="name" value="JBoss Community repository" />
+ <option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
+ </remote-repository>
+ <remote-repository>
+ <option name="id" value="MavenRepo" />
+ <option name="name" value="MavenRepo" />
+ <option name="url" value="https://repo.maven.apache.org/maven2/" />
+ </remote-repository>
+ </component>
+</project><
\ No newline at end of file
A => .idea/markdown.xml +6 -0
@@ 1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="MarkdownSettings">
+ <option name="showProblemsInCodeBlocks" value="false" />
+ </component>
+</project><
\ No newline at end of file
A => .idea/misc.xml +5 -0
@@ 1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="ExternalStorageConfigurationManager" enabled="true" />
+ <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK" />
+</project><
\ No newline at end of file
A => .idea/vcs.xml +6 -0
@@ 1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="VcsDirectoryMappings">
+ <mapping directory="$PROJECT_DIR$" vcs="Git" />
+ </component>
+</project><
\ No newline at end of file
A => LICENSE.md +21 -0
@@ 1,21 @@
+MIT License
+
+Copyright (c) 2022 Jorge Castro
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
A => README.md +9 -0
@@ 1,9 @@
+## Password Critic
+
+Password Critic is a small Java library providing functionality to assess password strength and present the results in a user-friendly format.
+
+It is a useful tool for testing current or potential passwords and evaluating their viability.
+
+### Usage
+
+See the [examples](examples) for help with using the library.
A => examples/DemoCLI.java +101 -0
@@ 1,101 @@
+import xyz.jorgecastro.passwordcritic.PasswordCritic;
+
+import java.io.Console;
+
+public class DemoCLI {
+ public static void main(String[] args) {
+ Console console = System.console();
+
+ System.out.println("Hello! This is a demo of the Password Critic.");
+ System.out.println("Please type in your password to assess its strength:");
+
+ /*
+ Instantiate the Password Critic; the char[] passed to the constructor is automatically zeroed out for
+ security
+ */
+ PasswordCritic passwordCritic = new PasswordCritic(console.readPassword());
+
+ /* Get an estimate on how long it'd take to crack this password in different scenarios */
+ // System.out.println(passwordCritic.getCrackTimesEstimate());
+ /*
+ Example output:
+
+ Online attack on a rate-limited service (~100 guesses/hour): centuries
+ Online attack on a service with absent or bypassed rate limiting (~10 guesses/second): centuries
+ Offline attack using a slow hash function and many cores (~10K guesses/second): 3 months
+ Offline attack using a fast hash function and many cores (~10B guesses/second): 8 seconds
+ Get feedback on the password
+ */
+
+ /* Get feedback on the password */
+ // System.out.println(passwordCritic.getFeedback());
+ /*
+ Example output:
+
+ Warnings: This is a top-100 common password.
+
+ Suggestions: Add another word or two. Uncommon words are better.
+ */
+
+ /*
+ Get the above and more information, i.e. the estimated number of guesses it'd take to crack this password,
+ the password's score in a 0-4 scale and the time it took to compute it all
+ */
+ // System.out.println(passwordCritic.getStrengthSummary());
+ /*
+ Example output:
+ Estimated number of guesses it would take to crack this password: 25.0000
+
+ Here's how long it would take to crack your password in different scenarios:
+
+ Online attack on a rate-limited service (~100 guesses/hour): 15 minutes
+ Online attack on a service with absent or bypassed rate limiting (~10 guesses/second): 3 seconds
+ Offline attack using a slow hash function and many cores (~10K guesses/second): less than a second
+ Offline attack using a fast hash function and many cores (~10B guesses/second): less than a second
+
+ Score on a 0-4 scale; 0 (Weak), 1 (Fair), 2 (Good), 3 (Strong), 4 (Very strong): 0
+
+ Warnings: Sequences like abc or 6543 are easy to guess.
+
+ Suggestions: Add another word or two. Uncommon words are better. Avoid sequences.
+
+ Time it took to calculate the above (in seconds): 0.0333254
+ */
+
+ /* Returns the number of times the given password has appeared in data breach reports */
+ // System.out.println(passwordCritic.search()); // ==> 747
+
+ /* Provide a detailed assessment containing all the information appearing above */
+ System.out.println(passwordCritic.critique());
+ /*
+ Example output:
+
+ Estimated number of guesses it would take to crack this password: 1220.00
+
+ Here's how long it would take to crack your password in different scenarios:
+
+ Online attack on a rate-limited service (~100 guesses/hour): 12 hours
+ Online attack on a service with absent or bypassed rate limiting (~10 guesses/second): 2 minutes
+ Offline attack using a slow hash function and many cores (~10K guesses/second): less than a second
+ Offline attack using a fast hash function and many cores (~10B guesses/second): less than a second
+
+ Score on a 0-4 scale; 0 (Weak), 1 (Fair), 2 (Good), 3 (Strong), 4 (Very strong): 1
+
+ Warnings: A word by itself is easy to guess.
+
+ Suggestions: Add another word or two. Uncommon words are better.
+
+ Time it took to calculate the above (in seconds): 0.0354738
+
+ Your password has appeared a total of 2046 times in data breach reports.
+
+ If the above number is greater than zero please consider discarding this password.
+ Visit https://haveibeenpwned.com/Passwords for more information.
+ */
+
+ /* Wipe password-related information from memory */
+ passwordCritic.wipe();
+
+ System.exit(0);
+ }
+}
A => examples/DemoGUI.java +37 -0
@@ 1,37 @@
+import xyz.jorgecastro.passwordcritic.PasswordCritic;
+
+import javax.swing.*;
+
+public class DemoGUI extends JFrame {
+ PasswordCritic passwordCritic;
+
+ public DemoGUI() {
+ JPanel panel = new JPanel();
+ panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+
+ JTextArea textArea = new JTextArea();
+ textArea.setEditable(false);
+
+ JPasswordField passwordField = new JPasswordField(10);
+ passwordField.addActionListener(
+ e -> {
+ passwordCritic = new PasswordCritic(passwordField.getPassword());
+ textArea.append(passwordCritic.critique() + "\n");
+ passwordCritic.wipe();
+ passwordField.setEnabled(false);
+ }
+ );
+
+ panel.add(textArea);
+ panel.add(new JLabel("Type in your password below and hit ENTER to assess its strength"));
+ panel.add(passwordField);
+
+ add(panel);
+ setTitle("Password Critic");
+ setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+ setSize(850, 450);
+ setVisible(true);
+ }
+
+ public static void main(String[] args) { new DemoGUI(); }
+}
A => gradle/wrapper/gradle-wrapper.jar +0 -0
A => gradle/wrapper/gradle-wrapper.properties +5 -0
@@ 1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
A => gradlew +240 -0
@@ 1,240 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# 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
+#
+# https://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.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
A => gradlew.bat +91 -0
@@ 1,91 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
A => lib/build.gradle +88 -0
@@ 1,88 @@
+plugins {
+ id 'java-library'
+ id 'java-library-distribution'
+ id 'maven-publish'
+ id 'signing'
+}
+
+group 'xyz.jorgecastro'
+version '1.0.0'
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2'
+
+ api 'com.nulab-inc:zxcvbn:1.7.0'
+
+ implementation 'com.squareup.retrofit2:retrofit:2.9.0'
+ implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'
+ implementation "com.google.guava:guava:31.1-jre"
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
+
+tasks.named('jar') {
+ manifest {
+ attributes('Implementation-Title': project.name, 'Implementation-Version': project.version)
+ }
+}
+
+java {
+ withSourcesJar()
+ withJavadocJar()
+}
+
+distributions {
+ main {
+ distributionBaseName = 'password-critic'
+ }
+}
+
+publishing {
+ publications {
+ mavenJava(MavenPublication) {
+ artifactId = 'password-critic'
+ from components.java
+ pom {
+ name = 'Password Critic'
+ description = 'A small Java library providing functionality to assess password strength and present the results in a user-friendly format'
+ url = 'https://sr.ht/~jorgecastro/password-critic/'
+ licenses {
+ license {
+ name = 'MIT License'
+ url = 'https://git.sr.ht/~jorgecastro/password-critic/blob/master/LICENSE.md'
+ }
+ }
+ developers {
+ developer {
+ id = 'jorgecastro'
+ name = 'Jorge Castro'
+ email = 'me@jorgecastro.xyz'
+ }
+ }
+ scm {
+ connection = 'scm:git:https://git.sr.ht/~jorgecastro/password-critic'
+ developerConnection = 'scm:git:ssh://git@git.sr.ht/~jorgecastro/password-critic'
+ url = 'https://git.sr.ht/~jorgecastro/password-critic'
+ }
+ }
+ }
+ }
+ repositories {
+ maven {
+ name = 'ossrh'
+ url = 'https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/'
+ credentials(PasswordCredentials)
+ }
+ }
+}
+
+signing {
+ useGpgCmd()
+ sign publishing.publications.mavenJava
+}
A => lib/src/main/java/xyz/jorgecastro/passwordcritic/PasswordCritic.java +374 -0
@@ 1,374 @@
+package xyz.jorgecastro.passwordcritic;
+
+import com.google.common.base.Charsets;
+import com.google.common.hash.HashCode;
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+import com.nulabinc.zxcvbn.AttackTimes.CrackTimesDisplay;
+import com.nulabinc.zxcvbn.Feedback;
+import com.nulabinc.zxcvbn.Strength;
+import com.nulabinc.zxcvbn.WipeableString;
+import com.nulabinc.zxcvbn.Zxcvbn;
+import retrofit2.Call;
+import retrofit2.Retrofit;
+import retrofit2.converter.scalars.ScalarsConverterFactory;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.stream.Stream;
+
+/**
+ * The PasswordCritic class provides various methods to assess password strength and present the results in a
+ * user-friendly format.
+ *
+ * <p>It extends {@link Zxcvbn}.
+ *
+ * @see WipeableString
+ * @see Strength
+ * @see HashFunction
+ * @see Retrofit
+ */
+public class PasswordCritic extends Zxcvbn {
+ private WipeableString password;
+ private Strength passwordStrength;
+ private final HashFunction sha1;
+ private final PwnedPasswordsService pwnedPasswordsService;
+
+ /**
+ * Construct an instance of {@link PasswordCritic} ready to operate on the given password. This constructor
+ * automatically zeroes out the passed {@code char[]} to increase security.
+ *
+ * @param password the {@code char[]} representing the password to be tested.
+ */
+ public PasswordCritic(char[] password) {
+ // Create a WipeableString to hold our password and assign it to field
+ this.password = new WipeableString(password);
+ //Zero out the char[] originally containing the password for security
+ Arrays.fill(password, '0');
+ // Get Strength object with information on password strength and assign it to field
+ passwordStrength = super.measure(this.password);
+
+ // Instantiate function to generate SHA-1 hashes and assign it to field
+ sha1 = Hashing.sha1();
+
+ // Create Retrofit object and generate an implementation of our interface to interact with the Pwned Passwords
+ // API
+ Retrofit retrofit = new Retrofit.Builder()
+ .baseUrl("https://api.pwnedpasswords.com/")
+ .addConverterFactory(ScalarsConverterFactory.create())
+ .build();
+
+ pwnedPasswordsService = retrofit.create(PwnedPasswordsService.class);
+ }
+
+ /**
+ * Measures the strength of a password and returns a {@link Strength} object representing the results. Only use this
+ * method if you wish to manage passwords and work with {@link Strength} objects directly, otherwise just pass
+ * passwords using {@link #PasswordCritic(char[])} and {@link #setPassword(char[])}.
+ *
+ * @param password the {@code char[]} representing the password to be tested.
+ * @return a {@link Strength} object representing the password's strength.
+ */
+ public static Strength measure(char[] password) { return new Zxcvbn().measure(new WipeableString(password)); }
+
+ /**
+ * Get a reference to the {@link WipeableString} containing the password associated with this instance. Please note
+ * that this {@link WipeableString} will be wiped if a new password is set using {@link #setPassword(char[])} or if
+ * the {@link #wipe()} method is called on this object.
+ *
+ * @return a reference to the {@link WipeableString} containing the password.
+ */
+ public WipeableString getPassword() { return password; }
+
+ /**
+ * Set a new password for this instance to operate on. This method automatically zeroes out the passed
+ * {@code char[]} and calls {@link #wipe()} to remove previous password-related information for security.
+ *
+ * @param password the {@code char[]} representing the password to be tested.
+ */
+ public void setPassword(char[] password) {
+ //Wipe the information for the previous password and associated Strength from memory
+ this.wipe();
+ // Create a WipeableString to hold our password and assign it to field
+ this.password = new WipeableString(password);
+ //Zero out the char[] originally containing the password for security
+ Arrays.fill(password, '0');
+ // Get Strength object with information on password strength
+ passwordStrength = measure(this.password);
+ }
+
+ /**
+ * Return the estimated number of guesses that would be needed to crack a password with this {@link Strength}.
+ *
+ * @param strength the {@link Strength} of the password in question.
+ * @return a {@code double} representing the guess estimate.
+ */
+ public static double getGuesses(Strength strength) { return strength.getGuesses(); }
+
+ /**
+ * Calls {@link #getGuesses(Strength)} and passes the {@link Strength} object derived from this instance's
+ * associated password.
+ *
+ * @return a {@code double} representing the guess estimate.
+ */
+ public double getGuesses() { return getGuesses(passwordStrength); }
+
+ /**
+ * Return an integer from 0 to 4 representing the strength score of a password with this {@link Strength}. The
+ * scores are given as follows:
+ * <ul>
+ * <li>0 (Weak): {@link #getGuesses(Strength)} {@literal < 10^3 + 5}</li>
+ * <li>1 (Fair): {@link #getGuesses(Strength)} {@literal < 10^6 + 5}</li>
+ * <li>2 (Good): {@link #getGuesses(Strength)} {@literal < 10^8 + 5}</li>
+ * <li>3 (Strong): {@link #getGuesses(Strength)} {@literal < 10^10 + 5}</li>
+ * <li>4 (Very strong): {@link #getGuesses(Strength)} {@literal >= 10^10 + 5}</li>
+ * </ul>
+ *
+ * @param strength the {@link Strength} of the password in question.
+ * @return an {@code int} representing the strength score.
+ */
+ public static int getScore(Strength strength) {return strength.getScore(); }
+
+ /**
+ * Calls {@link #getScore(Strength)} and passes the {@link Strength} object derived from this instance's associated
+ * password.
+ *
+ * @return an {@code int} representing the strength score.
+ */
+ public int getScore() {return getScore(passwordStrength); }
+
+ /**
+ * Return a double representing the time (in seconds) that it took to calculate a password's {@link Strength}.
+ *
+ * @param strength the {@link Strength} of the password in question.
+ * @return a {@code double} representing the {@link Strength}'s calculation time.
+ */
+ public static double getCalcTime(Strength strength) { return (double) strength.getCalcTime() / 1000000000; }
+
+ /**
+ * Calls {@link #getCalcTime(Strength)} and passes the {@link Strength} object derived from this instance's
+ * associated password.
+ *
+ * @return a {@code double} representing the {@link Strength}'s calculation time.
+ */
+ public double getCalcTime() { return getCalcTime(passwordStrength); }
+
+ /**
+ * Return a nicely formatted {@link String} with estimates on how long it'd take to crack a password with this
+ * {@link Strength}.
+ *
+ * @param strength the {@link Strength} of the password in question.
+ * @return a pretty {@link String} with crack time estimates.
+ */
+ public static String getCrackTimesEstimate(Strength strength) {
+ // Get crack times estimate
+ CrackTimesDisplay crackTimes = strength.getCrackTimesDisplay();
+
+ // Simulates online attack on a service that rate limits password auth attempts
+ String onlineThrottling = crackTimes.getOnlineThrottling100perHour();
+
+ // Simulates online attack on a service that doesn't rate limit or where rate limiting has been bypassed
+ String onlineNoThrottling = crackTimes.getOnlineNoThrottling10perSecond();
+
+ // Simulates an offline attack. assumes multiple attackers, proper user-unique salting, and a slow hash function
+ // with moderate work factor, such as bcrypt, scrypt, PBKDF2.
+ String offlineSlowHashing = crackTimes.getOfflineSlowHashing1e4perSecond();
+
+ // Simulates an offline attack with user-unique salting but a fast hash function like SHA-1, SHA-256 or MD5.
+ // A wide range of reasonable numbers anywhere from one billion - one trillion guesses per second,
+ // depending on number of cores and machines.
+ String offlineFastHashing = crackTimes.getOfflineFastHashing1e10PerSecond();
+
+ return String.format(
+ """
+ Online attack on a rate-limited service (~100 guesses/hour): %s
+ Online attack on a service with absent or bypassed rate limiting (~10 guesses/second): %s
+ Offline attack using a slow hash function and many cores (~10K guesses/second): %s
+ Offline attack using a fast hash function and many cores (~10B guesses/second): %s""",
+ onlineThrottling,
+ onlineNoThrottling,
+ offlineSlowHashing,
+ offlineFastHashing
+ );
+ }
+
+ /**
+ * Calls {@link #getCrackTimesEstimate(Strength)} and passes the {@link Strength} object derived from this
+ * instance's associated password.
+ *
+ * @return a pretty {@link String} with crack time estimates.
+ */
+ public String getCrackTimesEstimate() { return getCrackTimesEstimate(passwordStrength); }
+
+ /**
+ * Parse the content of the given {@link Feedback} instance into a nicely formatted {@link String} showing relevant
+ * warnings and suggestions.
+ *
+ * @param feedback the {@link Feedback} associated to the password in question.
+ * @return a pretty {@link String} with feedback, i.e. warnings and suggestions, or an empty one if there's none.
+ */
+ public static String parseFeedback(Feedback feedback) {
+ StringBuilder parsedFeedback = new StringBuilder();
+
+ // Get warnings from feedback (if any)
+ if (!feedback.getWarning().isBlank()) {
+ parsedFeedback.append("Warnings: ").append(feedback.getWarning()).append("\n\n");
+ }
+
+ // Get suggestions from feedback (if any)
+ if (!feedback.getSuggestions().isEmpty()) {
+ String suggestions = String.join(" ", feedback.getSuggestions());
+ parsedFeedback.append("Suggestions: ").append(suggestions);
+ }
+
+ // If feedback could be found, return it
+ if (!parsedFeedback.isEmpty()) {
+ return parsedFeedback.toString();
+ }
+
+ return "No specific feedback has been computed for your password.\nIt seems like it might be a good one!";
+ }
+
+ /**
+ * Calls {@link #parseFeedback(Feedback)} and passes the {@link Feedback} object generated from the {@link Strength}
+ * object derived from this instance's associated password.
+ *
+ * @return a pretty {@link String} with feedback, i.e. warnings and suggestions, or an empty one if there's none.
+ */
+ public String getFeedback() { return parseFeedback(passwordStrength.getFeedback()); }
+
+ /**
+ * Analyze a {@link Strength} object and provide a summary with relevant metrics.
+ *
+ * @param strength the {@link Strength} object to analyze.
+ * @return a pretty {@link String} with information on a password's associated {@link Strength}.
+ */
+ public static String getStrengthSummary(Strength strength) {
+ // Estimated number of guesses needed to crack password
+ double guesses = getGuesses(strength);
+
+ // Get crack times estimate
+ String crackTimesEstimate = getCrackTimesEstimate(strength);
+
+ // Integer from 0-4: 0 (Weak), 1 (Fair), 2 (Good), 3 (Strong), 4 (Very strong)
+ int score = getScore(strength);
+
+ // Get feedback on our password
+ String feedback = parseFeedback(strength.getFeedback());
+
+ // Get the time it took to compute this information (in seconds)
+ double calcTime = getCalcTime(strength);
+
+ return String.format(
+ """
+ Estimated number of guesses it would take to crack this password: %g
+
+ Here's how long it would take to crack your password in different scenarios:
+
+ %s
+
+ Score on a 0-4 scale; 0 (Weak), 1 (Fair), 2 (Good), 3 (Strong), 4 (Very strong): %d
+
+ %s
+
+ Time it took to calculate the above (in seconds): %g""",
+ guesses,
+ crackTimesEstimate,
+ score,
+ feedback,
+ calcTime
+ );
+ }
+
+ /**
+ * Calls {@link #getStrengthSummary(Strength)} and passes the {@link Strength} object derived from this
+ * instance's associated password.
+ *
+ * @return a pretty {@link String} with information on the password's associated Strength.
+ */
+ public String getStrengthSummary() { return getStrengthSummary(passwordStrength); }
+
+ /**
+ * Consult the Pwned Passwords API to find if this instance's associated password has been exposed in a reported
+ * data breach.
+ *
+ * @return the number of occurrences of this password in data breach reports.
+ */
+ public int search() {
+ // Refer to https://haveibeenpwned.com/API/v3#PwnedPasswords for API details
+
+ // Hash our password and get the hash prefix (first 5 characters) and suffix (rest of the hash)
+ HashCode hashedPassword = sha1.hashString(password, Charsets.UTF_8);
+ String hashPrefix = hashedPassword.toString().substring(0, 5).toUpperCase();
+ String hashSuffix = hashedPassword.toString().substring(5).toUpperCase();
+
+ // Call object will make a request to our endpoint when executed
+ Call<String> request = pwnedPasswordsService.searchPassword(hashPrefix);
+
+ // Declare variable to hold our response's body as a String
+ String responseBody;
+ try {
+ // Execute request and get the body of the response
+ responseBody = request.execute().body();
+ } catch (IOException e) {
+ e.printStackTrace();
+ return 0;
+ }
+
+ // If no potential matches are found then password has not been exposed in a data breach
+ if (responseBody == null || responseBody.isBlank()) {
+ return 0;
+ }
+
+ // Break up the response body line-by-line
+ Stream<String> lines = responseBody.lines();
+
+ // HashMap passwordRange will contain the hash suffixes of the potential matches as keys and their occurrence
+ // count as the corresponding value
+ HashMap<String, Integer> passwordRange = new HashMap<>();
+ for (Iterator<String> it = lines.iterator(); it.hasNext(); ) {
+ String line = it.next();
+ passwordRange.put(
+ line.substring(0, 35),
+ Integer.valueOf(line.substring(36))
+ );
+ }
+
+ // If our password suffix is a valid key return the corresponding count, otherwise 0 as that means our password
+ // hash was not found in the dataset
+ return passwordRange.getOrDefault(hashSuffix, 0);
+ }
+
+ /**
+ * Provide an assessment of this instance's associated password using all the available information. Presents the
+ * strength summary provided by {@link #getStrengthSummary()} and the number of occurrences of this password in data
+ * breach reports provided by {@link #search()}.
+ *
+ * @return a pretty {@link String} with a fully-detailed assessment of this password.
+ */
+ public String critique() {
+ return String.format(
+ """
+ %s
+
+ Your password has appeared a total of %d times in data breach reports.
+
+ If the above number is greater than zero please consider discarding this password.
+ Visit https://haveibeenpwned.com/Passwords for more information.""",
+ getStrengthSummary(),
+ search()
+ );
+ }
+
+ /**
+ * Wipe sensitive password-related information from memory by calling {@link Strength#wipe()} on the
+ * {@link Strength} object generated from the password associated to this instance.
+ */
+ public void wipe() {
+ // This will wipe the WipeableString with our password and other information that may be stored in the Strength
+ // object
+ passwordStrength.wipe();
+ }
+}
A => lib/src/main/java/xyz/jorgecastro/passwordcritic/PwnedPasswordsService.java +20 -0
@@ 1,20 @@
+package xyz.jorgecastro.passwordcritic;
+
+import retrofit2.Call;
+import retrofit2.http.GET;
+import retrofit2.http.Path;
+
+/** Interface to be adapted by {@link retrofit2.Retrofit} to interact with the Pwned Passwords API. */
+public interface PwnedPasswordsService {
+ // Refer to https://haveibeenpwned.com/API/v3#PwnedPasswords for API details
+
+ /**
+ * Generate a {@link Call} object that will query the Pwned Passwords API to search for a password by range (using
+ * its partial hash) when executed.
+ *
+ * @param hashPrefix the first 5 characters of a SHA-1 password hash.
+ * @return a {@link Call} object to search for password by range.
+ */
+ @GET("range/{hashPrefix}")
+ Call<String> searchPassword(@Path("hashPrefix") String hashPrefix);
+}
A => lib/src/main/java/xyz/jorgecastro/passwordcritic/package-info.java +4 -0
@@ 1,4 @@
+/**
+ * Provides a set of classes to assess password strength and present the results in a user-friendly format.
+ */
+package xyz.jorgecastro.passwordcritic;
A => lib/src/test/java/xyz/jorgecastro/passwordcritic/PasswordCriticTest.java +95 -0
@@ 1,95 @@
+package xyz.jorgecastro.passwordcritic;
+
+import com.nulabinc.zxcvbn.WipeableString;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class PasswordCriticTest {
+ @Test
+ void getPassword() {
+ char[] password = {'p', 'a', 's', 's', 'w', 'o', 'r', 'd'};
+ PasswordCritic passwordCritic = new PasswordCritic(password);
+ assertEquals(passwordCritic.getPassword(), new WipeableString("password"));
+ }
+
+ @Test
+ void setPassword() {
+ char[] password = {'p', 'a', 's', 's', 'w', 'o', 'r', 'd'};
+ PasswordCritic passwordCritic = new PasswordCritic(password);
+
+ char[] otherPassword = {'o', 't', 'h', 'e', 'r', 'p', 'a', 's', 's', 'w', 'o', 'r', 'd'};
+ passwordCritic.setPassword(otherPassword);
+
+ assertEquals(passwordCritic.getPassword(), new WipeableString("otherpassword"));
+ char[] charArray = new char[otherPassword.length];
+ Arrays.fill(charArray, '0');
+ assertArrayEquals(otherPassword, charArray);
+ }
+
+ @Test
+ void getGuesses() {
+ char[] password = {'p', 'a', 's', 's', 'w', 'o', 'r', 'd'};
+ PasswordCritic passwordCritic = new PasswordCritic(password);
+ assertTrue(passwordCritic.getGuesses() > 0.0);
+ }
+
+ @Test
+ void getScore() {
+ char[] password = {'p', 'a', 's', 's', 'w', 'o', 'r', 'd'};
+ PasswordCritic passwordCritic = new PasswordCritic(password);
+ int score = passwordCritic.getScore();
+ assertTrue(score <= 4 && score >= 0);
+ }
+
+ @Test
+ void getCalcTime() {
+ char[] password = {'p', 'a', 's', 's', 'w', 'o', 'r', 'd'};
+ PasswordCritic passwordCritic = new PasswordCritic(password);
+ assertTrue(passwordCritic.getCalcTime() > 0);
+ }
+
+ @Test
+ void getCrackTimesEstimate() {
+ char[] password = {'p', 'a', 's', 's', 'w', 'o', 'r', 'd'};
+ PasswordCritic passwordCritic = new PasswordCritic(password);
+ assertEquals(
+ passwordCritic.getCrackTimesEstimate(),
+ """
+ Online attack on a rate-limited service (~100 guesses/hour): 2 minutes
+ Online attack on a service with absent or bypassed rate limiting (~10 guesses/second): less than a second
+ Offline attack using a slow hash function and many cores (~10K guesses/second): less than a second
+ Offline attack using a fast hash function and many cores (~10B guesses/second): less than a second"""
+ );
+ }
+
+ @Test
+ void getFeedback() {
+ char[] password = {'p', 'a', 's', 's', 'w', 'o', 'r', 'd'};
+ PasswordCritic passwordCritic = new PasswordCritic(password);
+ assertEquals(
+ passwordCritic.getFeedback(),
+ """
+ Warnings: This is a top-10 common password.
+
+ Suggestions: Add another word or two. Uncommon words are better."""
+ );
+ }
+
+ @Test
+ void search() {
+ char[] password = {'p', 'a', 's', 's', 'w', 'o', 'r', 'd'};
+ PasswordCritic passwordCritic = new PasswordCritic(password);
+ assertTrue(passwordCritic.search() >= 0);
+ }
+
+ @Test
+ void wipe() {
+ char[] password = {'p', 'a', 's', 's', 'w', 'o', 'r', 'd'};
+ PasswordCritic passwordCritic = new PasswordCritic(password);
+ passwordCritic.wipe();
+ assertTrue(String.valueOf(passwordCritic.getPassword()).isEmpty());
+ }
+}
A => settings.gradle +2 -0
@@ 1,2 @@
+rootProject.name = 'password-critic'
+include 'lib'