没文档也要扒源码让 ShardingSphere 支持 openGauss SCRAM 前端认证

记录自己如何在没有协议文档的情况下,根据 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 批量插入协议实现一样,看一下客户端是怎么做的。

https://gitee.com/opengauss/openGauss-connector-jdbc/blob/master/pgjdbc/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java#L63

  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 数据包的内容

https://gitee.com/opengauss/openGauss-connector-jdbc/blob/master/pgjdbc/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java#L668

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。

下图中数据包的结构用不同颜色的方框标注,可以看出数据的结构跟前面明确的数据包结构一致。(看不清可以点开大图)
Authentication Request

再看以下客户端返回的信息,根据之前明确的数据包结构,客户端的认证消息里面只有一个字符串,所以客户端发送的是一个长度为 64 的字符串。(69 - 长度字段本身 4 - '\0' 结尾字符)
Password Message

实现过程

创建对应的消息定义

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 在认证请求里需要给客户端发送两个字符串 random64codetoken,那就随机生成;整数 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 的校验逻辑。
SCRAM
以上图片来源于: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 数据库发送给客户端的数据包:
Authentication Request
发现我之前遗漏了细节:

在数据包的 random64codetoken 参数部分,数据值的字符只有 [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。不明确字段含义的时候,一定要多观察特征、细节。

就这样吧~

参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

wuweijie@apache.org

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值