Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add PasswordUtil for encrypting passwords client side #3082

Merged
merged 2 commits into from
Jan 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/matrix.js
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,8 @@ include.forEach(v => {
jvmArgs.push('-XX:+StressCCP');
}
}
// Force using /dev/urandom so we do not worry about draining entropy pool
testJvmArgs.push('-Djava.security.egd=file:/dev/./urandom')
v.extraJvmArgs = jvmArgs.join(' ');
v.testExtraJvmArgs = testJvmArgs.join(' ::: ');
delete v.hash;
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).

### Changed
### Added
* feat: Add PasswordUtil for encrypting passwords client side [PR #3082](https://github.com/pgjdbc/pgjdbc/pull/3082)
### Fixed

## [42.7.1] (2023-12-06 08:34:00 -0500)
Expand Down
41 changes: 41 additions & 0 deletions pgjdbc/src/main/java/org/postgresql/PGConnection.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@
import org.postgresql.jdbc.PreferQueryMode;
import org.postgresql.largeobject.LargeObjectManager;
import org.postgresql.replication.PGReplicationConnection;
import org.postgresql.util.GT;
import org.postgresql.util.PGobject;
import org.postgresql.util.PSQLException;
import org.postgresql.util.PSQLState;
import org.postgresql.util.PasswordUtil;

import org.checkerframework.checker.nullness.qual.Nullable;

import java.sql.Array;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Map;
Expand Down Expand Up @@ -244,6 +250,41 @@ public interface PGConnection {
*/
PGReplicationConnection getReplicationAPI();

/**
* Change a user's password to the specified new password.
*
* <p>
* If the specific encryption type is not specified, this method defaults to querying the database server for the server's default password_encryption.
* This method does not send the new password in plain text to the server.
* Instead, it encrypts the password locally and sends the encoded hash so that the plain text password is never sent on the wire.
* </p>
*
* <p>
* Acceptable values for encryptionType are null, "md5", or "scram-sha-256".
* Users should avoid "md5" unless they are explicitly targeting an older server that does not support the more secure SCRAM.
* </p>
*
* @param user The username of the database user
* @param newPassword The new password for the database user
* @param encryptionType The type of password encryption to use or null if the database server default should be used.
vlsi marked this conversation as resolved.
Show resolved Hide resolved
* @throws SQLException If the password could not be altered
*/
default void alterUserPassword(String user, char[] newPassword, @Nullable String encryptionType) throws SQLException {
try (Statement stmt = ((Connection) this).createStatement()) {
if (encryptionType == null) {
try (ResultSet rs = stmt.executeQuery("SHOW password_encryption")) {
if (!rs.next()) {
throw new PSQLException(GT.tr("Expected a row when reading password_encrypton but none was found"),
vlsi marked this conversation as resolved.
Show resolved Hide resolved
PSQLState.NO_DATA);
}
encryptionType = rs.getString(1);
}
}
String sql = PasswordUtil.genAlterUserPasswordSQL(user, newPassword, encryptionType);
stmt.execute(sql);
}
}

/**
* <p>Returns the current values of all parameters reported by the server.</p>
*
Expand Down
2 changes: 1 addition & 1 deletion pgjdbc/src/main/java/org/postgresql/util/MD5Digest.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public static byte[] encode(byte[] user, byte[] password, byte[] salt) {
/*
* Turn 16-byte stream into a human-readable 32-byte hex string
*/
private static void bytesToHex(byte[] bytes, byte[] hex, int offset) {
public static void bytesToHex(byte[] bytes, byte[] hex, int offset) {
int pos = offset;
for (int i = 0; i < 16; i++) {
//bit twiddling converts to int, so just do it once here for both operations
Expand Down
220 changes: 220 additions & 0 deletions pgjdbc/src/main/java/org/postgresql/util/PasswordUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/*
* Copyright (c) 2023, PostgreSQL Global Development Group
* See the LICENSE file in the project root for more information.
*/

package org.postgresql.util;

import org.postgresql.core.Utils;

import com.ongres.scram.common.ScramFunctions;
import com.ongres.scram.common.ScramMechanisms;
import com.ongres.scram.common.bouncycastle.base64.Base64;
import com.ongres.scram.common.stringprep.StringPreparations;
import org.checkerframework.checker.nullness.qual.Nullable;

import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.sql.SQLException;
import java.util.Arrays;
import java.util.Objects;

public class PasswordUtil {
private static final String DEFAULT_PASSWORD_ENCRYPTION = "scram-sha-256";
private static final int DEFAULT_ITERATIONS = 4096;
private static final int DEFAULT_SALT_LENGTH = 16;

private static class SecureRandomHolder {
static final SecureRandom INSTANCE = new SecureRandom();
}

private static SecureRandom getSecureRandom() {
return SecureRandomHolder.INSTANCE;
}

/**
* Generate the encoded text representation of the given password for
* SCRAM-SHA-256 authentication. The return value of this method is the literal
* text that may be used when creating or modifying a user with the given
* password without the surrounding single quotes.
*
* @param password The plain text of the user's password
* @param iterations The number of iterations of the hashing algorithm to
* perform
* @param salt The random salt value
* @return The text representation of the password encrypted for SCRAM-SHA-256
* authentication
*/
public static String encodeScramSha256(char[] password, int iterations, byte[] salt) {
Objects.requireNonNull(password, "password");
if (iterations <= 0) {
throw new IllegalArgumentException("iterations must be greater than zero");
}
if (salt.length == 0) {
throw new IllegalArgumentException("salt length must be greater than zero");
}
try {
String passwordText = String.valueOf(password);
byte[] saltedPassword = ScramFunctions.saltedPassword(ScramMechanisms.SCRAM_SHA_256,
StringPreparations.SASL_PREPARATION, passwordText, salt, iterations);
byte[] clientKey = ScramFunctions.clientKey(ScramMechanisms.SCRAM_SHA_256, saltedPassword);
byte[] storedKey = ScramFunctions.storedKey(ScramMechanisms.SCRAM_SHA_256, clientKey);
byte[] serverKey = ScramFunctions.serverKey(ScramMechanisms.SCRAM_SHA_256, saltedPassword);

return "SCRAM-SHA-256" //
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why adding empty comments?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why show up with feedback on lines that have not changed since the PR was first opened after the PR is merged?

Are you just looking for something to nitpick?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You did not fix javadocs (see #3082 (comment)), and I thought it would be easier and faster to just wait for you to merge the PR and then fix the style issues

+ "$" + iterations //
+ ":" + Base64.toBase64String(salt) //
+ "$" + Base64.toBase64String(storedKey) //
+ ":" + Base64.toBase64String(serverKey);
} finally {
Arrays.fill(password, ' ');
}
}

/**
* Encode the given password for SCRAM-SHA-256 authentication using the default
* iteration count and a random salt.
*
* @param password The plain text of the user's password
* @return The text representation of the password encrypted for SCRAM-SHA-256
* authentication
*/
public static String encodeScramSha256(char[] password) {
Objects.requireNonNull(password, "password");
try {
SecureRandom rng = getSecureRandom();
byte[] salt = rng.generateSeed(DEFAULT_SALT_LENGTH);
return encodeScramSha256(password, DEFAULT_ITERATIONS, salt);
} finally {
Arrays.fill(password, ' ');
}
}

/**
* Encode the the given password for use with md5 authentication. The PostgreSQL
* server uses the username as the per-user salt so that must also be provided.
* The return value of this method is the literal text that may be used when
* creating or modifying a user with the given password without the surrounding
* single quotes.
*
* @param user The username of the database user
* @param password The plain text of the user's password
* @return The text representation of the password encrypted for md5
* authentication.
*/
public static String encodeMd5(String user, char[] password) {
Objects.requireNonNull(password, "password");
ByteBuffer passwordBytes = null;
try {
passwordBytes = StandardCharsets.UTF_8.encode(CharBuffer.wrap(password));
byte[] userBytes = user.getBytes(StandardCharsets.UTF_8);
final MessageDigest md = MessageDigest.getInstance("MD5");

md.update(passwordBytes);
md.update(userBytes);
byte[] digest = md.digest(); // 16-byte MD5

final byte[] encodedPassword = new byte[35]; // 3 + 2 x 16
encodedPassword[0] = (byte) 'm';
encodedPassword[1] = (byte) 'd';
encodedPassword[2] = (byte) '5';
MD5Digest.bytesToHex(digest, encodedPassword, 3);

return new String(encodedPassword, StandardCharsets.UTF_8);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("Unable to encode password with MD5", e);
} finally {
Arrays.fill(password, ' ');
if (passwordBytes != null) {
if (passwordBytes.hasArray()) {
Arrays.fill(passwordBytes.array(), (byte) 0);
} else {
int limit = passwordBytes.limit();
for (int i = 0; i < limit; i++) {
passwordBytes.put(i, (byte) 0);
}
}
}
}
}

/**
* Encode the given password using the driver's default encryption method.
*
* @param user The username of the database user
* @param password The plain text of the user's password
* @return The encoded password
* @throws SQLException If an error occurs encoding the password
*/
public static String encodePassword(String user, char[] password) throws SQLException {
Comment on lines +146 to +153
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the method is not reliable from the users' perspective.
What does driver's default mean?
What if somebody uses the method, and then they upgrade the driver some time later. Is the driver allowed to change the default encoding method?

Apparently, for backward compatibility, we can't change the method. In that regard, encodePassword duplicates encodeScramSha256.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the method is not reliable from the users' perspective. What does driver's default mean?

It means the driver encodes the password with whatever the latest version of password encoding the driver wants, using the defaults built into the driver.

What if somebody uses the method, and then they upgrade the driver some time later. Is the driver allowed to change the default encoding method?

Yes. That's exactly the point. So that code targeting that method uses the latest, most recommended method of encoding passwords without being connected to a specific server.

Apparently, for backward compatibility, we can't change the method. In that regard, encodePassword duplicates encodeScramSha256.

Compatibility with what? We haven't haven't released anything yet. Are you suggesting changing the signature?

It doesn't duplicate the SCRAM-SHA-256 function, it it delegates to it because that's the current driver default.

If in the future if the SCRAM-SHA-256 default is replaced with SCRAM-SHA-512 or something else entirely, we'd change that delegation.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If in the future if the SCRAM-SHA-256 default is replaced with SCRAM-SHA-512 or something else entirely, we'd change that delegation

If we ever make such a change, then we effectively break backward compatibility. That means we can't easily make such a change.

So, please suggest what is the use case for having "driver's default" encodePassword method. Why add the method assuming there's not a single use case for it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we ever make such a change, then we effectively break backward compatibility. That means we can't easily make such a change.

We don't break anything because the definition of that method is encoding the password with whatever the driver considers to be the most secure and recommended approach. The user is delegating to this driver, as the de facto Java driver for PosgreSQL, to make a determination of how the user should be encoding passwords.

If a user wants to use a specific algo or parameters then there's other overloads to use instead.

So, please suggest what is the use case for having "driver's default" encodePassword method. Why add the method assuming there's not a single use case for it?

It's in the original PR description:

Splitting out the encoding allows the same functions to be used for CREATE USER ... (again without passing the credentials in plaintext).

A user can leverage that to generate their own SQL that involves encoding passwords.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we ever make such a change, then we effectively break backward compatibility. That means we can't easily make such a change.

We don't break anything because the definition of that method is encoding the password with whatever the driver considers to be the most secure and recommended approach. The user is delegating to this driver, as the de facto Java driver for PosgreSQL, to make a determination of how the user should be encoding passwords.

I think the issue is that if the server uses SCRAM-512 for the latest version and previous versions use SCRAM-256 using the latest driver would fail on older versions of the server.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you mean if the driver is bumped to SCRAM-512 and the server does not yet support it, then it would fail.

Yes, that's expected because the output of that method is not for a particular server. It's for generating literals for the encoded password using the latest recommended method per the driver. I'd see it being used by something that is generating it's own SQL, potentially for future execution out of band. The tie in to the driver is that the driver, as the de factor Java driver for PostgreSQL, is aware of the recommended password encoding.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you mean if the driver is bumped to SCRAM-512 and the server does not yet support it, then it would fail.

Yes, that's expected because the output of that method is not for a particular server. It's for generating literals for the encoded password using the latest recommended method per the driver. I'd see it being used by something that is generating it's own SQL, potentially for future execution out of band. The tie in to the driver is that the driver, as the de factor Java driver for PostgreSQL, is aware of the recommended password encoding.

No, what I mean is server version 17 comes out with SCRAM-512. The driver uses SCRAM-512 as the default and now the default only works for server version 17.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't follow ... isn't that the same situation I described in my previous comment?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose it is, but we can't break backward compatibility. If a user upgrades the driver their code should continue to work.
Generally if we do a major version upgrade we would mention that we have breaking changes but a breaking change to a default seems wrong somehow

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I don't see it as a break as the functional meaning of the method ("encode my password using the recommended...) has not changed. I see it like the following changing over server versions:

CREATE USER foo WITH PASSWORD 'abcd1234';
SELECT passwd FROM pg_shadow WHERE usename = 'foo'

It'd be md5 encoded in <=10 and some variant SCRAM after that. Though that's not as user facing so maybe not the best example.

The original goal was to have a method that users could rely on when making things like SQL script generating tooling that they know will always be the latest recommendation. That way when SCRAM-SHA-256 is replaced with SCRAM-SHA-512 or something else entirely, a user that bumps their driver to the latest pgjdbc would automatically get the newer recommendation.

If that doesn't make sense as valid use case or if you foresee misuse of it causing complication, then let's remove it.

return encodePassword(user, password, null);
}

/**
* Encode the given password for the specified encryption type.
* The word "encryption" is used here to match the verbiage in the PostgreSQL
* server, i.e. the "password_encryption" setting. In reality, a cryptographic
* digest / HMAC operation is being performed.
* The database user is only required for the md5 encryption type.
*
* @param user The username of the database user
* @param password The plain text of the user's password
* @param encryptionType The encryption type for which to encode the user's
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coulo you add the meaning of null to the docs?

* password. This should match the database's supported
* methods and value of the password_encryption setting.
* @return The encoded password
* @throws SQLException If an error occurs encoding the password
*/
public static String encodePassword(String user, char[] password, @Nullable String encryptionType)
throws SQLException {
Objects.requireNonNull(password, "password");
if (encryptionType == null) {
encryptionType = DEFAULT_PASSWORD_ENCRYPTION;
}
switch (encryptionType) {
case "on":
case "off":
case "md5":
return encodeMd5(user, password);
case "scram-sha-256":
return encodeScramSha256(password);
}
// If we get here then it's an unhandled encryption type so we must wipe the array ourselves
Arrays.fill(password, ' ');
throw new PSQLException("Unable to determine encryption type: " + encryptionType, PSQLState.SYSTEM_ERROR);
}

/**
* Generate the SQL statement to alter a user's password using the given
* encryption.
* If the encryption type is not specified, the driver's default will be used.
* All other encryption settings for the password will use the driver's
* defaults.
*
* @param user The username of the database user
* @param password The plain text of the user's password
* @param encryptionType The encryption type of the password or null to use the
* default
* @return An SQL statement that may be executed to change the user's password
* @throws SQLException If an error occurs encoding the password
*/
public static String genAlterUserPasswordSQL(String user, char[] password, @Nullable String encryptionType)
throws SQLException {
// This will also wipe the password array for us:
String encodedPassword = encodePassword(user, password, encryptionType);
StringBuilder sb = new StringBuilder();
sb.append("ALTER USER ");
Utils.escapeIdentifier(sb, user);
sb.append(" PASSWORD '");
// The choice of true / false for standard conforming strings does not matter
// here as the value being escaped is generated by us and known to be hex
// characters for all of the implemented password encryption methods.
Utils.escapeLiteral(sb, encodedPassword, true);
sb.append("'");
return sb.toString();
}
}
Loading
Loading