Skip to content

Commit a8157ee

Browse files
authored
feat: use scram hash in users.toml (#908)
Allow to specify SCRAM-SHA-256 hashes in `users.toml` instead of plaintext passwords. Users can authenticate to pgdog without us having to store the password anywhere. Server auth has to be passwordless, however, e.g. RDS IAM or trust.
1 parent 4393ab2 commit a8157ee

18 files changed

Lines changed: 309 additions & 86 deletions

File tree

.schema/users.schema.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,13 @@
141141
"null"
142142
]
143143
},
144+
"password_hash": {
145+
"description": "Passwords hash. Can be used to validate user logins without storing passwords in users.toml.\nServer authentication must use RDS IAM or some other passwordless authentication, e.g. trust.",
146+
"type": [
147+
"string",
148+
"null"
149+
]
150+
},
144151
"passwords": {
145152
"description": "Multiple passwords for this user, all of which will be attempted during auth to server and client.",
146153
"type": "array",

Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

integration/rust/tests/integration/auth.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,22 @@ async fn test_passthrough_password_change() {
301301

302302
admin.execute("RELOAD").await.unwrap();
303303
}
304+
305+
#[tokio::test]
306+
async fn test_scram_hashed_passthrough() {
307+
let mut conn =
308+
sqlx::PgConnection::connect("postgres://pgdog_hashed:pgdog@127.0.0.1:6432/pgdog")
309+
.await
310+
.unwrap();
311+
conn.execute("SELECT 1").await.unwrap();
312+
313+
let conn =
314+
sqlx::PgConnection::connect("postgres://pgdog_hashed:badpw@127.0.0.1:6432/pgdog").await;
315+
assert!(conn.is_err());
316+
assert!(
317+
conn.err()
318+
.unwrap()
319+
.to_string()
320+
.contains("password for user")
321+
);
322+
}

integration/users.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ name = "pgdog"
33
database = "pgdog"
44
password = "pgdog"
55

6+
[[users]]
7+
name = "pgdog_hashed"
8+
database = "pgdog"
9+
password_hash = "SCRAM-SHA-256$4096:b6lksqhYfkXiN2hDMl3zfA==$9Rh0FdfjsbECkf289/WO2yHGiUBnNOOb1uNHVtOCCDE=:V7jC7GHvIr4guRsI66S3u4aXNhHWj1Pz71ymRM7bLFU="
10+
server_password = "pgdog" # Pointless obviously, but we can test scram hash is working as expected. pgdog -> server auth is expected to be passwordless, e.g. rds iam, trust, azure working directory, etc.
11+
server_user = "pgdog"
12+
min_pool_size = 0
13+
614
[[users]]
715
name = "pgdog_session"
816
database = "pgdog"

pgdog-config/src/core.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,14 @@ impl ConfigAndUsers {
6262
}
6363

6464
let mut users: Users = if let Ok(users) = read_to_string(users_path) {
65-
let mut users: Users = match toml::from_str(&users) {
65+
let users: Users = match toml::from_str(&users) {
6666
Ok(config) => config,
6767
Err(err) => {
6868
let error = Error::config(&users, err);
6969
error!("failed to load {}: {}", users_path.display(), error);
7070
return Err(error);
7171
}
7272
};
73-
users.check(&config);
7473
info!("loaded \"{}\"", users_path.display());
7574
users
7675
} else {

pgdog-config/src/users.rs

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use serde::{Deserialize, Serialize};
22
use std::env;
3+
use std::fmt::Display;
34
use std::path::PathBuf;
45
use tracing::warn;
56

@@ -47,11 +48,11 @@ impl Users {
4748
/// Run configuration checks.
4849
pub fn check(&mut self, config: &Config) {
4950
for user in &mut self.users {
50-
if user.password().is_empty() {
51+
if user.passwords().is_empty() {
5152
if !config.general.passthrough_auth() {
5253
warn!(
53-
"user \"{}\" doesn't have a password and passthrough auth is disabled",
54-
user.name
54+
r#"user "{}" (database "{}") doesn't have a password and passthrough auth is disabled"#,
55+
user.name, user.database,
5556
);
5657
}
5758

@@ -64,24 +65,36 @@ impl Users {
6465

6566
for database in databases {
6667
if min_pool_size > 0 {
67-
warn!("user \"{}\" (database \"{}\") doesn't have a password configured, \
68-
so we can't connect to the server to maintain min_pool_size of {}; setting it to 0", user.name, database, min_pool_size);
68+
warn!(
69+
r#"user "{}" (database "{}") does not have a password configured, PgDog cannot connect to the server to maintain "min_pool_size" of {}, setting it to 0"#,
70+
user.name, database, min_pool_size
71+
);
6972
user.min_pool_size = Some(0);
7073
}
7174
}
7275
}
7376
}
7477

78+
if user.server_password.is_none()
79+
&& user.server_auth == ServerAuth::Password
80+
&& user.password_hash.is_some()
81+
{
82+
warn!(
83+
r#"user "{}" (database "{}") is using hash authentication but does not specify a "server_password""#,
84+
user.name, user.database
85+
);
86+
}
87+
7588
if !user.database.is_empty() && !user.databases.is_empty() {
7689
warn!(
77-
r#"user "{}" is configured for both "database" and "databases", defaulting to "database""#,
78-
user.name
90+
r#"user "{}" is configured for both "{}" and "{:?}", defaulting to "{}""#,
91+
user.name, user.database, user.databases, user.database,
7992
);
8093
}
8194

8295
if user.all_databases && (!user.databases.is_empty() || !user.database.is_empty()) {
8396
warn!(
84-
r#"user "{}" is configured for "all_databases" and specific databases, defaulting to "all_databases""#,
97+
r#"user "{}" is configured for all databases and a specific database, defaulting to all databases""#,
8598
user.name
8699
);
87100
}
@@ -143,6 +156,31 @@ impl ServerAuth {
143156
}
144157
}
145158

159+
/// The kind of password configured on the user.
160+
#[derive(Debug, Clone, PartialEq, Eq)]
161+
pub enum PasswordKind {
162+
Plain(String),
163+
Hashed(String),
164+
}
165+
166+
impl PasswordKind {
167+
pub fn as_str(&self) -> &str {
168+
match self {
169+
Self::Plain(plain) => plain.as_str(),
170+
Self::Hashed(hash) => hash.as_str(),
171+
}
172+
}
173+
}
174+
175+
impl Display for PasswordKind {
176+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177+
match self {
178+
Self::Plain(plain) => write!(f, "{}", plain),
179+
Self::Hashed(hashed) => write!(f, "{}", hashed),
180+
}
181+
}
182+
}
183+
146184
/// User allowed to connect to pgDog.
147185
/// A user entry in `users.toml`, controlling which users are allowed to connect to PgDog.
148186
///
@@ -174,6 +212,9 @@ pub struct User {
174212
/// Multiple passwords for this user, all of which will be attempted during auth to server and client.
175213
#[serde(default)]
176214
pub passwords: Vec<String>,
215+
/// Passwords hash. Can be used to validate user logins without storing passwords in users.toml.
216+
/// Server authentication must use RDS IAM or some other passwordless authentication, e.g. trust.
217+
pub password_hash: Option<String>,
177218
/// Overrides [`default_pool_size`](https://docs.pgdog.dev/configuration/pgdog.toml/general/) for this user. No more than this many server connections will be open at any given time to serve requests for this connection pool.
178219
///
179220
/// https://docs.pgdog.dev/configuration/users.toml/users/#pool_size
@@ -252,10 +293,19 @@ impl User {
252293
}
253294
}
254295

255-
pub fn passwords(&self) -> Vec<String> {
256-
let mut passwords = self.passwords.clone();
296+
pub fn passwords(&self) -> Vec<PasswordKind> {
297+
let mut passwords: Vec<_> = self
298+
.passwords
299+
.clone()
300+
.into_iter()
301+
.map(|p| PasswordKind::Plain(p))
302+
.collect();
257303
if !self.password().is_empty() {
258-
passwords.push(self.password().to_string());
304+
passwords.push(PasswordKind::Plain(self.password().to_string()));
305+
}
306+
307+
if let Some(hash) = self.password_hash.clone() {
308+
passwords.push(PasswordKind::Hashed(hash));
259309
}
260310

261311
passwords

pgdog/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ toml = "0.8"
3939
pgdog-plugin.workspace = true
4040
tokio-util = { version = "0.7", features = ["rt"] }
4141
fnv = "1"
42-
scram = { git = "https://github.com/pgdogdev/scram.git", rev = "90c511f703d2856dddd029ea062239c3132f902d" }
42+
scram = { git = "https://github.com/pgdogdev/scram.git", rev = "2c259426941a61f30e4252b9186e5f93e42fc924" }
43+
ring = "0.16"
4344
base64 = "0.22"
4445
md5 = "0.7"
4546
futures = "0.3"

pgdog/src/auth/md5.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//!
55
use bytes::Bytes;
66
use md5::Context;
7+
78
use rand::Rng;
89

910
use super::Error;

pgdog/src/auth/scram/mod.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,25 @@ pub mod state;
77
pub use client::Client;
88
pub use error::Error;
99
pub use server::Server;
10+
11+
/// Generate a `SCRAM-SHA-256$iterations:salt$StoredKey:ServerKey` hash string
12+
/// from a plaintext password, suitable for storage in `users.toml` or `pg_shadow`.
13+
pub fn generate_hash(password: &str, iterations: std::num::NonZeroU32, salt: &[u8]) -> String {
14+
use base64::prelude::*;
15+
use ring::digest;
16+
use ring::hmac::{self, HMAC_SHA256};
17+
18+
let salted_password = scram::hash_password(password, iterations, salt);
19+
let key = hmac::Key::new(HMAC_SHA256, &salted_password);
20+
let client_key = hmac::sign(&key, b"Client Key");
21+
let server_key = hmac::sign(&key, b"Server Key");
22+
let stored_key = digest::digest(&digest::SHA256, client_key.as_ref());
23+
24+
format!(
25+
"SCRAM-SHA-256${}:{}${}:{}",
26+
iterations,
27+
BASE64_STANDARD.encode(salt),
28+
BASE64_STANDARD.encode(stored_key.as_ref()),
29+
BASE64_STANDARD.encode(server_key.as_ref()),
30+
)
31+
}

0 commit comments

Comments
 (0)