记录自己如何在没有协议文档的情况下,根据 openGauss JDBC Driver 源码,让 ShardingSphere openGauss Proxy 支持 openGauss 的 SCRAM SHA-256 前端认证机制。
文章目录
前言
目标:让 ShardingSphere openGauss Proxy 支持以 SCRAM SHA-256 机制验证 openGauss 客户端的身份。
最近在做一个和 ShardingSphere Proxy openGauss 前端认证方式相关的 issue ShardingSphere openGauss Proxy supports sha256 authentication method #13995。
PostgreSQL 有一种前端认证方式是 MD5
Password。客户端根据服务端提供的 salt 对密码进行 MD5 加密并发送,完成认证过程。
openGauss 觉得 MD5 不安全,前端认证推荐并默认使用 SHA-256
。
虽然说是 SHA-256
认证,听起来就是与 PostgreSQL 的 MD5 认证大同小异,只是摘要的长度不一样,后来做起来才发现完全不是这样。openGauss 用的是 SCRAM,其中的哈希算法可以使用 SHA-256
或者国密算法。
在做这个 issue 之前,我不了解 SCRAM,其他安全相关知识也了解不深,实现过程也是费了一些心思。
环境准备
openGauss 数据库与客户端
本文所使用的 openGauss 数据库及 gsql
(前身就是 psql
)均使用 Docker Image enmotech/opengauss:2.1.0
openGauss JDBC Driver 使用 org.opengauss:opengauss-jdbc:2.0.1-compatibility
<dependency>
<groupId>org.opengauss</groupId>
<artifactId>opengauss-jdbc</artifactId>
<version>2.0.1-compatibility</version>
</dependency>
openGauss 服务端配置
enmotech/opengauss:2.1.0
默认使用了 MD5 认证方式,需要修改各项配置为 sha256
并新建用户。
postgresql.conf
需要配置:
password_encryption_type = 2
pg_hba.conf
需要配置:
host all all 0.0.0.0/0 sha256
配置完成后需要重启或执行 gs_ctl reload
生效。
最后创建一个新用户:
CREATE USER wuweijie PASSWORD 'Wuweijie@123';
没有文档?扒源码去扒一下客户端源码看看是怎么做的。
确定 SHA-256 认证方式的枚举值
openGauss 不像 PostgreSQL 那样有比较完善的协议文档,包括本次要做的 SCRAM SHA-256 前端认证,openGauss 官网也只有配置相关的说明,如何实现没有指导文档。
没有文档怎么办?就像之前实现 openGauss 批量插入协议实现一样,看一下客户端是怎么做的。
private static final int AUTH_REQ_MD5 = 5;
// 省略部分代码...
private static final int AUTH_REQ_SHA256 = 10;
PostgreSQL MD5 认证方式的枚举值为 5,openGauss 所增加的 SHA-256 认证方式枚举值为 10。
确定 Auth Request 数据包的内容
openGauss JDBC Driver 认证相关关键逻辑节选及本文注释说明:
case AUTH_REQ_SHA256: {
// 省略部分代码...
byte[] digest;
int passwordStoredMethod = pgStream.receiveInteger4();
// 省略部分代码...
if (passwordStoredMethod == PLAIN_PASSWORD || passwordStoredMethod == SHA256_PASSWORD) {
String random64code = pgStream.receiveString(64);
String token = pgStream.receiveString(8);
byte[] result = null;
// 由于 openGauss 最新的客户端都使用 3.51 的协议版本,以下分支只需关心最后的 else
if (this.protocolVerion < PROTOCOL_VERSION_350) {
// 省略部分代码...
} else if (this.protocolVerion == PROTOCOL_VERSION_350) {
// 省略部分代码...
} else {
// 本次实现只看本分支
int server_iteration = pgStream.receiveInteger4();
result = MD5Digest.RFC5802Algorithm(password, random64code, token, server_iteration);
}
if (result == null) // 省略部分代码...
pgStream.sendChar('p');
pgStream.sendInteger4(4 + result.length + 1);
pgStream.send(result);
pgStream.sendChar(0);
pgStream.flush();
break;
} else if (passwordStoredMethod == MD5_PASSWORD) { // 省略部分代码...
} else { // 省略部分代码...
}
// 省略部分代码...
}
代码中的方法
RFC5802Algorithm
也就是SCRAM
。
大致明确 Auth Request 数据包的结构
综上,如果要把自己伪装成 openGauss 数据库服务端进行 SCRAM SHA-256 认证,需要发送的数据包长这样:
Byte1('R')
表明这个消息是个认证请求。
Int32(88)
消息内容的长度(以字节为单位),包括长度自身,不包括消息类型。
Int32(10)
表明使用的认证方式为 SHA-256。
Int32(2)
表明用户的密码存储方式使用的是 SHA-256。
Byte64
random64code 长度为 64 的字符串,含义暂时不清楚,根据命名猜测类似 salt,参与 SCRAM 计算。
Byte8
token 长度为 8 的字符串,参与 SCRAM 计算。
Int32
server_iteration 一个整数,参与 SCRAM 计算。
从客户端接收到的数据包长这样:
Byte1('p')
表明这个消息是个认证响应。
Int32
消息内容的长度(以字节为单位),包括长度自身,不包括消息类型。
String
一个以 '\0' 结尾的字符串。
Wireshark 抓包观察
从源码层面了解了数据包的格式后,通过 Wireshark 抓包偷窥 openGauss 客户端与数据库的交互过程。
当数据库收到客户端发送的 Startup 消息后,给客户端响应了一个类型为 R
的消息,要求客户端按照协议完成身份认证。以下为服务端发送给客户端的数据:
因为 Wireshark 只支持解析 PostgreSQL 协议,openGauss 特有的消息在 Wireshake 里体现为 Malformed Packet。
下图中数据包的结构用不同颜色的方框标注,可以看出数据的结构跟前面明确的数据包结构一致。(看不清可以点开大图)
再看以下客户端返回的信息,根据之前明确的数据包结构,客户端的认证消息里面只有一个字符串,所以客户端发送的是一个长度为 64
的字符串。(69 - 长度字段本身 4 - '\0'
结尾字符)
实现过程
创建对应的消息定义
Proxy 发送给客户端的认证请求节选:
OpenGaussAuthenticationSha256Packet.java
public final class OpenGaussAuthenticationSha256Packet implements PostgreSQLIdentifierPacket {
private static final int AUTH_REQ_SHA256 = 10;
private static final int PASSWORD_STORED_METHOD_SHA256 = 2;
private final byte[] random64Code;
private final byte[] token;
private final int serverIteration;
@Override
public void write(final PostgreSQLPacketPayload payload) {
payload.writeInt4(AUTH_REQ_SHA256);
payload.writeInt4(PASSWORD_STORED_METHOD_SHA256);
payload.writeBytes(random64Code);
payload.writeBytes(token);
payload.writeInt4(serverIteration);
}
@Override
public PostgreSQLIdentifierTag getIdentifier() {
return PostgreSQLMessagePacketType.AUTHENTICATION_REQUEST;
}
}
客户端发送给 Proxy 的认证响应直接复用了 PostgreSQLPasswordMessagePacket.java,代码节选:
public final class PostgreSQLPasswordMessagePacket implements PostgreSQLIdentifierPacket {
private final String digest;
public PostgreSQLPasswordMessagePacket(final PostgreSQLPacketPayload payload) {
payload.readInt4();
digest = payload.readStringNul();
}
@Override
public void write(final PostgreSQLPacketPayload payload) {}
@Override
public PostgreSQLIdentifierTag getIdentifier() {
return PostgreSQLMessagePacketType.PASSWORD_MESSAGE;
}
}
实现校验逻辑
首次实现校验逻辑的时候,我还不了解 SCRAM 的逻辑,于是我就按照 MD5
认证方式的实现方式去做。
实现思路:把客户端对密码的处理方式在 Proxy 重现一遍,最后把客户端发送的认证数据和 Proxy 内计算产生的结果对比。
Proxy 在认证请求里需要给客户端发送两个字符串 random64code
和 token
,那就随机生成;整数 server_iteration
看到代码里在协议 3.50
里写死 2048
,那此处就取 2048
:
public OpenGaussAuthenticationEngine() {
random64Code = RandomStringUtils.randomAlphanumeric(64);
token = RandomStringUtils.randomAlphanumeric(8);
serverIteration = 2048;
}
初步验证
写了一段简单的程序验证:(5433
是 openGauss,55433
是 ShardingSphere Proxy openGauss)
//try (Connection connection = DriverManager.getConnection("jdbc:opengauss://127.0.0.1:5433/postgres", "u3", "Wuweijie@123")) {
try (Connection connection = DriverManager.getConnection("jdbc:opengauss://127.0.0.1:55433/freedom", "wuweijie", "Wuweijie@123")) {
try (PreparedStatement preparedStatement = connection.prepareStatement("show all variables")) {
try (ResultSet resultSet = preparedStatement.executeQuery()) {
while (resultSet.next()) {
System.out.println(resultSet.getString(1) + " -> " + resultSet.getString(2));
}
}
}
}
部分输出:
sql_show -> true
sql_simple -> false
能够正常连接 ShardingSphere Proxy openGauss 并通过认证,于是就形成了这个 PR:
Add SHA256 authentication for openGauss Proxy #14002
无意间发现问题
第二天想着当时只用了 openGauss JDBC Driver 验证,要不试试 gsql
:
wuweijie@wuweijie-ubuntu /home/wuweijie
% docker run --rm -i -t --network host enmotech/opengauss:2.1.0 gsql -h 127.0.0.1 -Uwuweijie -W'Wuweijie@123' -p 55433 freedom
gsql: FATAL: password authentication failed for user "wuweijie"
结果发现认证失败!以同样的方式直连 openGauss 却没有问题,可能是我的实现方式有问题。但是 openGauss 在协议方面没有文档,我也不知道应该怎么做。问了一下 openGauss 的成员,拿了一个参考文档:openGauss支持国密SM3和SM4算法
除了哈希算法不一样,SCRAM 的机制是一样的。
这时候我才进一步了解 SCRAM 这种机制。
调整 Proxy 校验逻辑
后面我根据以下参考资料调整了 Proxy 的校验逻辑。
以上图片来源于:https://opengauss.org/zh/blogs/blogs.html?post/douxin/sm3_for_opengauss/
之前的变量名对应关系如下:
random64code
->salt
token
->nonce
调整后的逻辑大致如下:
private static boolean isPasswordRight(final ShardingSphereUser user, final Object[] args) {
String h3HexString = (String) args[0];
String salt = (String) args[1];
String nonce = (String) args[2];
int serverIteration = (int) args[3];
byte[] serverStoredKey = calculatedStoredKey(user.getPassword(), salt, serverIteration);
byte[] h3 = hexStringToBytes(h3HexString);
byte[] h2 = calculateH2(user.getPassword(), salt, nonce, serverIteration);
byte[] clientCalculatedStoredKey = sha256(xor(h3, h2));
return Arrays.equals(clientCalculatedStoredKey, serverStoredKey);
}
再次验证
实现完了,使用之前的 Java 代码验证 openGauss JDBC Driver 能够通过认证。
再用 gsql
验证,居然还是报错:
wuweijie@wuweijie-ubuntu /home/wuweijie
% docker run --rm -i -t --network host enmotech/opengauss:2.1.0 gsql -h 127.0.0.1 -Uwuweijie -W'Wuweijie@123' -p 55433 freedom
gsql: FATAL: password authentication failed for user "wuweijie"
又试了一下 2.0.0 的版本,同样报错:
wuweijie@wuweijie-ubuntu /home/wuweijie
% docker run --rm -i -t --network host enmotech/opengauss:2.0.0 gsql -h 127.0.0.1 -Uwuweijie -W'Wuweijie@123' -p 55433 freedom
gsql: FATAL: password authentication failed for user "wuweijie"
再次 Review 并修改
看逻辑没看出什么问题,再次观察 Wireshake 窃听到的 openGauss 数据库发送给客户端的数据包:
发现我之前遗漏了细节:
在数据包的 random64code
和 token
参数部分,数据值的字符只有 [a-f0-9]
,而我用的随机字符串生成方法是 [A-Za-z0-9]
。
random64Code = RandomStringUtils.randomAlphanumeric(64);
token = RandomStringUtils.randomAlphanumeric(8);
调整随机字符串生成方法并重命名变量:
saltHexString = generateRandomHexString(64);
nonceHexString = generateRandomHexString(8);
private String generateRandomHexString(final int length) {
ThreadLocalRandom random = ThreadLocalRandom.current();
StringBuilder result = new StringBuilder(length);
for (int i = 0; i < result.capacity(); i++) {
result.append(Integer.toString(random.nextInt(0x10), 0x10));
}
return result.toString();
再次验证。
最终验证
gsql
输出结果:
[~] docker run --rm -i -t --network host enmotech/opengauss:2.1.0 gsql -h 127.0.0.1 -Uwuweijie -W'Huawei@123' -p 55433 freedom
gsql ((openGauss 2.1.0 build 590b0f8e) compiled at 2021-09-30 14:29:04 commit 0 last mr )
Non-SSL connection (SSL connection is recommended when requiring high-security)
Type "help" for help.
freedom=> show all variables;
variable_name | variable_value
---------------------------------------+----------------
sql_show | true
sql_simple | false
kernel_executor_size | 0
省略部分输出
transaction_type | LOCAL
(20 rows)
用之前的代码验证 openGauss JDBC Driver,部分输出:
sql_show -> true
sql_simple -> false
两种客户端都能完成认证,于是再提一个 PR。
Refactor openGauss frontend SCRAM SHA-256 authentication #14073
完成!
回顾
如果一开始能够正确生成随即字符串,第一个 PR 的做法应该也是能够达到身份认证的目的的,但这不符合 SCRAM 的做法。
后续再做类似的事情的时候,一定要先了解清楚相关知识,比如本次的 SCRAM。不明确字段含义的时候,一定要多观察特征、细节。
就这样吧~