sql.password=
highFinancialValue=/path/to/alexa/list
+#knownPasswordHashes = /usr/share/pwned-passwords/pwned-passwords.bin
time.testValidMonths=12
time.reverificationDays=90
Package: wpia-gigi
Architecture: all
Depends: java7-runtime-headless, wpia-gigi-setuid, libpostgresql-jdbc-java, libdnsjava-java, ${shlibs:Depends}, ${misc:Depends}
+Recommends: pwned-passwords-bin
Conflicts: wpia-gigi-testing
Description: WPIA Web-DB software.
This program is used to manage accounts and certificates.
A path to a plain text file of Internet domain names, one per line,
which Gigi should refuse to issue certificates to.
.TP
+.B knownPasswordHashes
+A path to a file of SHA-1 hashes of known passwords.
+The file should contain the hashes in binary format, without any separators, and should be sorted.
+Gigi will refuse user passwords with hashes that are found in this file.
+If this option is specified, Gigi will refuse startup if the file cannot be opened,
+otherwise it will attempt to use the file
+.I /usr/share/pwned-passwords/pwned-passwords.bin
+(provided by the \fBpwned-passwords-bin\fR package)
+but continue startup if the file cannot be opened.
+.TP
.B time.testValidMonths
The maximum time, in months, for which a passed agent quiz is considered recent.
Defaults to \fI12\fR.
/kb/names
/kb/lostPassword
/kb/goodPassword
+/kb/knownPasswordHash
/kb/verificationHandbook
/kb/truststores
/ttp/user
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;
import java.math.BigInteger;
+import java.nio.channels.FileChannel;
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
import java.security.KeyStore;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;
import java.util.Calendar;
import java.util.Collections;
import club.wpia.gigi.pages.wot.Points;
import club.wpia.gigi.pages.wot.RequestTTPPage;
import club.wpia.gigi.pages.wot.VerifyPage;
+import club.wpia.gigi.passwords.DelegatingPasswordChecker;
+import club.wpia.gigi.passwords.PasswordChecker;
+import club.wpia.gigi.passwords.PasswordHashChecker;
+import club.wpia.gigi.passwords.PasswordStrengthChecker;
import club.wpia.gigi.ping.PingerDaemon;
import club.wpia.gigi.util.AuthorizationContext;
import club.wpia.gigi.util.DomainAssessment;
private static Gigi instance;
+ private static PasswordChecker passwordChecker;
+
private static final Template baseTemplate = new Template(Gigi.class.getResource("Gigi.templ"));
private PingerDaemon pinger;
this.truststore = truststore;
pinger = new PingerDaemon(truststore);
pinger.start();
+ Gigi.passwordChecker = getPasswordChecker(conf);
+ }
+ }
+
+ private PasswordChecker getPasswordChecker(Properties conf) {
+ final String knownPasswordHashesPath;
+ final boolean knownPasswordHashesRequired;
+ String knownPasswordHashesConfig = conf.getProperty("knownPasswordHashes");
+ if (knownPasswordHashesConfig != null) {
+ knownPasswordHashesPath = knownPasswordHashesConfig;
+ knownPasswordHashesRequired = true;
+ } else {
+ knownPasswordHashesPath = "/usr/share/pwned-passwords/pwned-passwords.bin";
+ knownPasswordHashesRequired = false;
+ }
+
+ final MessageDigest sha1;
+ try {
+ sha1 = MessageDigest.getInstance("SHA-1");
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+
+ try {
+ final FileChannel knownPasswordHashesFile = FileChannel.open(
+ FileSystems.getDefault().getPath(knownPasswordHashesPath));
+ return new DelegatingPasswordChecker(new PasswordChecker[] {
+ new PasswordStrengthChecker(),
+ new PasswordHashChecker(knownPasswordHashesFile, sha1)
+ });
+ } catch (IOException e) {
+ if (knownPasswordHashesRequired) {
+ throw new RuntimeException("Error while opening password hash database, refusing startup", e);
+ } else {
+ System.err.println("Error while opening password hash database, passwords will be checked only by strength");
+ e.printStackTrace();
+ return new PasswordStrengthChecker();
+ }
}
}
instance.pinger.interrupt();
}
+ public static PasswordChecker getPasswordChecker() {
+ if (passwordChecker == null) {
+ throw new IllegalStateException("Not yet initialized!");
+ }
+ return passwordChecker;
+ }
+
+ public static void setPasswordChecker(PasswordChecker passwordChecker) {
+ Gigi.passwordChecker = passwordChecker;
+ }
+
}
import java.util.List;
import java.util.Locale;
import java.util.Set;
+import java.util.TreeSet;
+import club.wpia.gigi.Gigi;
import club.wpia.gigi.GigiApiException;
import club.wpia.gigi.database.GigiPreparedStatement;
import club.wpia.gigi.database.GigiResultSet;
import club.wpia.gigi.localisation.Language;
import club.wpia.gigi.output.DateSelector;
import club.wpia.gigi.pages.PasswordResetPage;
+import club.wpia.gigi.passwords.PasswordStrengthChecker;
import club.wpia.gigi.util.CalendarUtil;
import club.wpia.gigi.util.DayDate;
import club.wpia.gigi.util.Notary;
import club.wpia.gigi.util.PasswordHash;
-import club.wpia.gigi.util.PasswordStrengthChecker;
import club.wpia.gigi.util.TimeConditions;
/**
}
private void setPassword(String newPass) throws GigiApiException {
- PasswordStrengthChecker.assertStrongPassword(newPass, getNames(), getEmail());
+ Name[] names = getNames();
+ TreeSet<String> nameParts = new TreeSet<>();
+ for (int i = 0; i < names.length; i++) {
+ for (NamePart string : names[i].getParts()) {
+ nameParts.add(string.getValue());
+ }
+ }
+ GigiApiException gaPassword = Gigi.getPasswordChecker().checkPassword(newPass, nameParts.toArray(new String[nameParts.size()]), getEmail());
+ if (gaPassword != null) {
+ throw gaPassword;
+ }
try (GigiPreparedStatement ps = new GigiPreparedStatement("UPDATE users SET `password`=? WHERE id=?")) {
ps.setString(1, PasswordHash.hash(newPass));
ps.setInt(2, getId());
import javax.servlet.http.HttpServletRequest;
+import club.wpia.gigi.Gigi;
import club.wpia.gigi.GigiApiException;
import club.wpia.gigi.database.GigiPreparedStatement;
import club.wpia.gigi.database.GigiResultSet;
import club.wpia.gigi.output.template.Template;
import club.wpia.gigi.output.template.TranslateCommand;
import club.wpia.gigi.pages.Page;
+import club.wpia.gigi.passwords.PasswordStrengthChecker;
import club.wpia.gigi.util.CalendarUtil;
import club.wpia.gigi.util.HTMLEncoder;
import club.wpia.gigi.util.Notary;
-import club.wpia.gigi.util.PasswordStrengthChecker;
import club.wpia.gigi.util.RateLimit.RateLimitException;
public class Signup extends Form {
} else if ( !pw1.equals(pw2)) {
ga.mergeInto(new GigiApiException("Passwords don't match"));
}
- int pwpoints = PasswordStrengthChecker.checkpw(pw1, ni.getNamePartsPlain(), email);
- if (pwpoints < 3) {
- ga.mergeInto(new GigiApiException(new SprintfCommand("The Password you submitted failed to contain enough differing characters and/or contained words from your name and/or email address. For the current requirements and to learn more, visit our {0}FAQ{1}.", Arrays.asList("!(/kb/goodPassword", "!'</a>'"))));
- }
if ( !ga.isEmpty()) {
throw ga;
}
+ GigiApiException gaPassword = Gigi.getPasswordChecker().checkPassword(pw1, ni.getNamePartsPlain(), email);
+ if (gaPassword != null) {
+ throw gaPassword;
+ }
GigiApiException ga2 = new GigiApiException();
try (GigiPreparedStatement q1 = new GigiPreparedStatement("SELECT * FROM `emails` WHERE `email`=? AND `deleted` IS NULL"); GigiPreparedStatement q2 = new GigiPreparedStatement("SELECT * FROM `certOwners` INNER JOIN `users` ON `users`.`id`=`certOwners`.`id` WHERE `email`=? AND `deleted` IS NULL")) {
q1.setString(1, email);
--- /dev/null
+package club.wpia.gigi.passwords;
+
+import club.wpia.gigi.GigiApiException;
+
+/**
+ * A {@link PasswordChecker} that delegates checks to several other PasswordCheckers
+ * and merges their error messages.
+ */
+public class DelegatingPasswordChecker implements PasswordChecker {
+
+ private final PasswordChecker[] checkers;
+
+ public DelegatingPasswordChecker(PasswordChecker[] checkers) {
+ this.checkers = checkers;
+ }
+
+ @Override
+ public GigiApiException checkPassword(String password, String[] nameParts, String email) {
+ GigiApiException exception = new GigiApiException();
+ for (PasswordChecker checker : checkers) {
+ GigiApiException currentException = checker.checkPassword(password, nameParts, email);
+ if (currentException != null) {
+ exception.mergeInto(currentException);
+ }
+ }
+ if (exception.isEmpty()) {
+ return null;
+ } else {
+ return exception;
+ }
+ }
+}
--- /dev/null
+package club.wpia.gigi.passwords;
+
+import club.wpia.gigi.GigiApiException;
+import club.wpia.gigi.dbObjects.Name;
+
+/**
+ * A strategy to check whether a password is acceptable for use or not.
+ */
+public interface PasswordChecker {
+
+ /**
+ * Checks if a password is acceptable for use.
+ * Most implementations judge a password’s strength in some way
+ * and reject weak passwords.
+ *
+ * @param password The password to check.
+ * @param nameParts The name parts of the user that wants to use this password.
+ * @param email The email address of the user that wants to use this password.
+ * @return {@code null} if the password is acceptable,
+ * otherwise a {@link GigiApiException} with an appropriate error message.
+ */
+ public GigiApiException checkPassword(String password, String[] nameParts, String email);
+}
--- /dev/null
+package club.wpia.gigi.passwords;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.Charset;
+import java.security.MessageDigest;
+import java.util.Arrays;
+
+import club.wpia.gigi.GigiApiException;
+import club.wpia.gigi.output.template.SprintfCommand;
+
+/**
+ * A {@link PasswordChecker} which searches for a password
+ * in a given file containing hashes of known breached passwords.
+ * If the password is found, it is rejected.
+ */
+public class PasswordHashChecker implements PasswordChecker {
+
+ private final FileChannel database;
+
+ private final MessageDigest digest;
+
+ private final int digestLength;
+
+ private final ByteBuffer hashBuffer;
+
+ /**
+ * @param database The database of password hashes.
+ * It should contain the hashes in binary format, with no separating characters,
+ * sorted in ascending order.
+ * @param digest The digest function that was used to compile the database.
+ */
+ public PasswordHashChecker(FileChannel database, MessageDigest digest) {
+ this.database = database;
+ this.digest = digest;
+ this.digestLength = digest.getDigestLength();
+ this.hashBuffer = ByteBuffer.allocate(digestLength);
+ }
+
+ @Override
+ public GigiApiException checkPassword(String password, String[] nameParts, String email) {
+ byte[] passwordHash = passwordHash(password);
+ boolean knownPasswordHash;
+ try {
+ knownPasswordHash = knownPasswordHash(passwordHash);
+ } catch (IOException e) {
+ System.err.printf(
+ "Error while reading password hash database, ACCEPTING POTENTIALLY KNOWN PASSWORD for user with email %s.",
+ email
+ );
+ e.printStackTrace();
+ knownPasswordHash = false;
+ }
+ if (knownPasswordHash) {
+ return new GigiApiException(new SprintfCommand(
+ "The password you submitted was found in a public database of passwords exposed in data breaches. If you use this password anywhere else, you should change it. See our {0}FAQ{1} for more information.",
+ Arrays.asList("!(/kb/knownPasswordHash", "!'</a>'")
+ ));
+ } else {
+ return null;
+ }
+ }
+
+ private byte[] passwordHash(String password) {
+ byte[] passwordBytes = password.getBytes(Charset.forName("UTF-8"));
+ byte[] passwordHash;
+ synchronized (digest) {
+ digest.reset();
+ digest.update(passwordBytes);
+ passwordHash = digest.digest();
+ }
+ return passwordHash;
+ }
+
+ private boolean knownPasswordHash(byte[] passwordHash) throws IOException {
+ long targetEstimate = estimateHashOffset(passwordHash);
+ long bestGuess = targetEstimate;
+
+ hashBuffer.clear();
+ database.read(hashBuffer, bestGuess);
+ // first, guess the location of the hash within the file
+ for (int i = 0; i < 4; i++) {
+ long bestGuessEstimate = estimateHashOffset(hashBuffer.array());
+ if (bestGuessEstimate == targetEstimate) {
+ break;
+ }
+ bestGuess = bestGuess + targetEstimate - bestGuessEstimate;
+ hashBuffer.clear();
+ database.read(hashBuffer, bestGuess);
+ }
+ int searchDirection = compareHashes(passwordHash, hashBuffer.array());
+ if (searchDirection == 0) {
+ return true;
+ }
+ // then, do a linear search from that location until we’ve either found or moved past the hash
+ int newSearchDirection = searchDirection;
+ while (searchDirection == newSearchDirection) {
+ bestGuess += digestLength * searchDirection;
+ hashBuffer.clear();
+ database.read(hashBuffer, bestGuess);
+ newSearchDirection = compareHashes(passwordHash, hashBuffer.array());
+ }
+ return newSearchDirection == 0;
+ }
+
+ private int compareHashes(byte[] left, byte[] right) {
+ for (int i = 0; i < digestLength; i++) {
+ int leftByte = Byte.toUnsignedInt(left[i]);
+ int rightByte = Byte.toUnsignedInt(right[i]);
+ if (leftByte < rightByte) {
+ return -1;
+ }
+ if (leftByte > rightByte) {
+ return 1;
+ }
+ }
+ return 0;
+ }
+
+ private long estimateHashOffset(byte[] hash) throws IOException {
+ long pos = (database.size()
+ * ((hash[0] & 0xFF) << 8 | (hash[1] & 0xFF)))
+ / (1L << 16);
+ pos += (database.size()
+ * ((hash[2] & 0xFF) << 8 | (hash[3] & 0xFF)))
+ / (1L << 32);
+ return (pos / digestLength) * digestLength;
+ }
+}
-package club.wpia.gigi.util;
+package club.wpia.gigi.passwords;
import java.util.Arrays;
import java.util.TreeSet;
import club.wpia.gigi.dbObjects.NamePart;
import club.wpia.gigi.output.template.SprintfCommand;
-public class PasswordStrengthChecker {
+public class PasswordStrengthChecker implements PasswordChecker {
private static Pattern digits = Pattern.compile("\\d");
private static Pattern special = Pattern.compile("(?!\\s)\\W");
- private PasswordStrengthChecker() {}
+ public PasswordStrengthChecker() {}
- private static int checkpwlight(String pw) {
+ /**
+ * @param pw The password.
+ * @return Estimate of the password’s strength (positive).
+ */
+ private int ratePasswordStrength(String pw) {
int points = 0;
if (pw.length() > 15) {
points++;
return points;
}
- public static int checkpw(String pw, String[] nameParts, String email) {
- if (pw == null) {
- return 0;
- }
- int light = checkpwlight(pw);
+ /**
+ * @param pw The password.
+ * @param nameParts The name parts of the user.
+ * @param email The email address of the user.
+ * @return Estimate of the password’s weakness (negative).
+ */
+ private int ratePasswordWeakness(String pw, String[] nameParts, String email) {
+ int points = 0;
if (contained(pw, email)) {
- light -= 2;
+ points -= 2;
}
for (int i = 0; i < nameParts.length; i++) {
if (contained(pw, nameParts[i])) {
- light -= 2;
+ points -= 2;
}
}
- // TODO dictionary check
- return light;
+ return points;
}
- public static void assertStrongPassword(String pw, Name[] names, String email) throws GigiApiException {
- TreeSet<String> parts = new TreeSet<>();
- for (int i = 0; i < names.length; i++) {
- for (NamePart string : names[i].getParts()) {
- parts.add(string.getValue());
- }
- }
- if (checkpw(pw, parts.toArray(new String[parts.size()]), email) < 3) {
- throw (new GigiApiException(new SprintfCommand("The Password you submitted failed to contain enough differing characters and/or contained words from your name and/or email address. For the current requirements and to learn more, visit our {0}FAQ{1}.", Arrays.asList("!(/kb/goodPassword", "!'</a>'"))));
+ public int ratePassword(String pw, String[] nameParts, String email) {
+ return ratePasswordStrength(pw) + ratePasswordWeakness(pw, nameParts, email);
+ }
+
+ @Override
+ public GigiApiException checkPassword(String password, String[] nameParts, String email) {
+ int points = ratePassword(password, nameParts, email);
+ if (points < 3) {
+ return new GigiApiException(new SprintfCommand(
+ "The Password you submitted failed to contain enough differing characters and/or contained words from your name and/or email address. For the current requirements and to learn more, visit our {0}FAQ{1}.",
+ Arrays.asList("!(/kb/goodPassword", "!'</a>'")
+ ));
+ } else {
+ return null;
}
}
--- /dev/null
+package club.wpia.gigi.passwords;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.junit.Assert.*;
+
+import org.junit.Test;
+
+import club.wpia.gigi.GigiApiException;
+import club.wpia.gigi.passwords.DelegatingPasswordChecker;
+import club.wpia.gigi.passwords.PasswordChecker;
+
+public class TestDelegatingPasswordChecker {
+
+ @Test
+ public void testNoCheckers() {
+ DelegatingPasswordChecker checker = new DelegatingPasswordChecker(new PasswordChecker[0]);
+
+ assertNull(checker.checkPassword("", new String[0], ""));
+ }
+
+ @Test
+ public void testOneChecker() {
+ DelegatingPasswordChecker checker = new DelegatingPasswordChecker(new PasswordChecker[] {
+ new PasswordChecker() {
+ @Override
+ public GigiApiException checkPassword(String password, String[] nameParts, String email) {
+ return password.isEmpty() ?
+ new GigiApiException("empty password") :
+ null;
+ }
+ }
+ });
+
+ assertNull(checker.checkPassword("a strong password", new String[0], ""));
+
+ GigiApiException exception = checker.checkPassword("", new String[0], "");
+ assertNotNull(exception);
+ assertEquals("empty password", exception.getMessage());
+ }
+
+ @Test
+ public void testTwoCheckers() {
+ DelegatingPasswordChecker checker = new DelegatingPasswordChecker(new PasswordChecker[] {
+ new PasswordChecker() {
+ @Override
+ public GigiApiException checkPassword(String password, String[] nameParts, String email) {
+ return password.equals(email) ?
+ new GigiApiException("password = email") :
+ null;
+ }
+ },
+ new PasswordChecker() {
+ @Override
+ public GigiApiException checkPassword(String password, String[] nameParts, String email) {
+ return password.equals("12345") ?
+ new GigiApiException("12345 is a bad password") :
+ null;
+ }
+ }
+ });
+
+ assertNull(checker.checkPassword("a strong password", new String[0], "email"));
+
+ GigiApiException exception1 = checker.checkPassword("email", new String[0], "email");
+ assertNotNull(exception1);
+ assertEquals("password = email", exception1.getMessage());
+
+ GigiApiException exception2 = checker.checkPassword("12345", new String[0], "email");
+ assertNotNull(exception2);
+ assertEquals("12345 is a bad password", exception2.getMessage());
+
+ GigiApiException exception3 = checker.checkPassword("12345", new String[0], "12345");
+ assertNotNull(exception3);
+ assertThat(exception3.getMessage(), containsString("password = email"));
+ assertThat(exception3.getMessage(), containsString("12345 is a bad password"));
+ }
+}
-package club.wpia.gigi.util;
+package club.wpia.gigi.passwords;
import static org.junit.Assert.*;
import org.junit.Test;
+import club.wpia.gigi.passwords.PasswordStrengthChecker;
import club.wpia.gigi.testUtils.ClientBusinessTest;
-import club.wpia.gigi.util.PasswordStrengthChecker;
public class TestPasswordStrengthChecker extends ClientBusinessTest {
public TestPasswordStrengthChecker() {}
private int check(String pw) {
- return PasswordStrengthChecker.checkpw(pw, new String[] {
+ return new PasswordStrengthChecker().ratePassword(pw, new String[] {
"fname", "lname", "mname", "suffix"
}, e);
}
import org.junit.AfterClass;
import org.junit.BeforeClass;
+import club.wpia.gigi.Gigi;
import club.wpia.gigi.GigiApiException;
import club.wpia.gigi.database.DatabaseConnection;
import club.wpia.gigi.database.DatabaseConnection.Link;
import club.wpia.gigi.dbObjects.Domain;
import club.wpia.gigi.dbObjects.DomainPingType;
import club.wpia.gigi.dbObjects.User;
+import club.wpia.gigi.passwords.PasswordStrengthChecker;
import club.wpia.gigi.testUtils.TestEmailReceiver.TestMail;
import club.wpia.gigi.util.DatabaseManager;
import club.wpia.gigi.util.DomainAssessment;
TimeConditions.init(props);
DomainAssessment.init(props);
PasswordHash.init(props);
+ Gigi.setPasswordChecker(new PasswordStrengthChecker());
if ( !DatabaseConnection.isInited()) {
DatabaseConnection.init(testProps);