Skip to content

Commit b7551a6

Browse files
Copilotdamacus
andcommitted
Add automatic SCRAM-SHA-256 password escaping and comprehensive documentation
Co-authored-by: damacus <[email protected]>
1 parent a7a620f commit b7551a6

File tree

7 files changed

+377
-2
lines changed

7 files changed

+377
-2
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ We follow the currently supported versions listed on <https://www.postgresql.org
4848
- [postgresql_role](documentation/postgresql_role.md)
4949
- [postgresql_service](documentation/postgresql_service.md)
5050

51+
## Additional Documentation
52+
53+
- [SCRAM-SHA-256 Authentication](documentation/scram-sha-256.md)
54+
5155
## Contributors
5256

5357
This project exists thanks to all the people who [contribute.](https://opencollective.com/sous-chefs/contributors.svg?width=890&button=false)

documentation/postgresql_role.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,57 @@ postgresql_role 'user1' do
6565
valid_until '2018-12-31'
6666
end
6767
```
68+
69+
Create a user with a pre-hashed SCRAM-SHA-256 password:
70+
71+
```ruby
72+
postgresql_role 'secure_user' do
73+
encrypted_password 'SCRAM-SHA-256$4096:27klCUc487uwvJVGKI5YNA==$6K2Y+S3YBlpfRNrLROoO2ulWmnrQoRlGI1GqpNRq0T0=:y4esBVjK/hMtxDB5aWN4ynS1SnQcT1TFTqV0J/snls4='
74+
login true
75+
createdb true
76+
end
77+
```
78+
79+
## SCRAM-SHA-256 Authentication
80+
81+
SCRAM-SHA-256 is a password authentication method that provides better security than MD5. When using SCRAM-SHA-256 authentication:
82+
83+
1. **Pre-hashed passwords**: If you have a pre-computed SCRAM-SHA-256 password hash, use the `encrypted_password` property.
84+
2. **Password format**: SCRAM-SHA-256 passwords have the format: `SCRAM-SHA-256$<iter>:<salt>$<StoredKey>:<ServerKey>`
85+
3. **Automatic escaping**: The cookbook automatically handles escaping of special characters (`$`) in SCRAM-SHA-256 passwords.
86+
87+
### Password Generation
88+
89+
To generate a SCRAM-SHA-256 password hash, you can use:
90+
91+
```bash
92+
# Using PostgreSQL's built-in function
93+
psql -c "SELECT gen_random_uuid();" # for salt generation
94+
# Then use a SCRAM-SHA-256 library to generate the hash
95+
```
96+
97+
Or use a Ruby library like `scram-sha-256`:
98+
99+
```ruby
100+
require 'scram-sha-256'
101+
password_hash = ScramSha256.hash_password('your_password', 4096)
102+
```
103+
104+
### Configuration Example
105+
106+
```ruby
107+
# Configure access method
108+
postgresql_access 'scram access' do
109+
type 'host'
110+
database 'all'
111+
user 'myuser'
112+
address '127.0.0.1/32'
113+
auth_method 'scram-sha-256'
114+
end
115+
116+
# Create user with SCRAM password
117+
postgresql_role 'myuser' do
118+
encrypted_password 'SCRAM-SHA-256$4096:abc123...$def456...:ghi789...'
119+
login true
120+
end
121+
```

documentation/scram-sha-256.md

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# SCRAM-SHA-256 Authentication
2+
3+
SCRAM-SHA-256 (Salted Challenge Response Authentication Mechanism) is a password authentication method in PostgreSQL that provides better security than traditional MD5 authentication.
4+
5+
## Overview
6+
7+
SCRAM-SHA-256 authentication offers several advantages:
8+
- **Stronger security**: Uses SHA-256 instead of MD5
9+
- **Salt protection**: Prevents rainbow table attacks
10+
- **Iteration count**: Makes brute force attacks more difficult
11+
- **Mutual authentication**: Both client and server verify each other
12+
13+
## Password Format
14+
15+
SCRAM-SHA-256 passwords have this specific format:
16+
```
17+
SCRAM-SHA-256$<iteration_count>:<salt>$<StoredKey>:<ServerKey>
18+
```
19+
20+
Example:
21+
```
22+
SCRAM-SHA-256$4096:27klCUc487uwvJVGKI5YNA==$6K2Y+S3YBlpfRNrLROoO2ulWmnrQoRlGI1GqpNRq0T0=:y4esBVjK/hMtxDB5aWN4ynS1SnQcT1TFTqV0J/snls4=
23+
```
24+
25+
## Usage with Chef
26+
27+
### Creating Users with SCRAM-SHA-256 Passwords
28+
29+
When you have a pre-computed SCRAM-SHA-256 password hash:
30+
31+
```ruby
32+
postgresql_role 'secure_user' do
33+
encrypted_password 'SCRAM-SHA-256$4096:27klCUc487uwvJVGKI5YNA==$6K2Y+S3YBlpfRNrLROoO2ulWmnrQoRlGI1GqpNRq0T0=:y4esBVjK/hMtxDB5aWN4ynS1SnQcT1TFTqV0J/snls4='
34+
login true
35+
action :create
36+
end
37+
```
38+
39+
### Automatic Character Escaping
40+
41+
The cookbook automatically handles escaping of special characters (`$`) in SCRAM-SHA-256 passwords. You don't need to manually escape these characters - the cookbook will handle this transparently.
42+
43+
**Before (manual escaping required):**
44+
```ruby
45+
postgresql_role 'user1' do
46+
# Manual escaping was required
47+
password 'SCRAM-SHA-256$4096:salt$key:server'.gsub('$', '\$')
48+
action [:create, :update]
49+
end
50+
```
51+
52+
**Now (automatic escaping):**
53+
```ruby
54+
postgresql_role 'user1' do
55+
# No manual escaping needed
56+
encrypted_password 'SCRAM-SHA-256$4096:salt$key:server'
57+
action [:create, :update]
58+
end
59+
```
60+
61+
## Configuring Authentication
62+
63+
To use SCRAM-SHA-256 authentication, configure the access method:
64+
65+
```ruby
66+
postgresql_access 'scram authentication' do
67+
type 'host'
68+
database 'all'
69+
user 'myuser'
70+
address '127.0.0.1/32'
71+
auth_method 'scram-sha-256'
72+
end
73+
```
74+
75+
## Password Generation
76+
77+
### Using PostgreSQL
78+
79+
Generate a SCRAM-SHA-256 password directly in PostgreSQL:
80+
81+
```sql
82+
-- Set password for existing user (PostgreSQL will hash it)
83+
ALTER ROLE myuser PASSWORD 'plaintext_password';
84+
85+
-- Check the generated hash
86+
SELECT rolpassword FROM pg_authid WHERE rolname = 'myuser';
87+
```
88+
89+
### Using Ruby
90+
91+
Generate a SCRAM-SHA-256 hash using the `scram-sha-256` gem:
92+
93+
```ruby
94+
require 'scram-sha-256'
95+
96+
# Generate hash with default iteration count (4096)
97+
password_hash = ScramSha256.hash_password('my_plain_password')
98+
99+
# Generate hash with custom iteration count
100+
password_hash = ScramSha256.hash_password('my_plain_password', 8192)
101+
```
102+
103+
### Using Python
104+
105+
Generate a SCRAM-SHA-256 hash using Python:
106+
107+
```python
108+
import hashlib
109+
import hmac
110+
import base64
111+
import secrets
112+
113+
def generate_scram_sha256(password, salt=None, iterations=4096):
114+
if salt is None:
115+
salt = secrets.token_bytes(16)
116+
117+
# Implementation details would go here
118+
# This is a simplified example
119+
pass
120+
```
121+
122+
## Common Use Cases
123+
124+
### Control Panel Integration
125+
126+
When integrating with control panels that pre-hash passwords:
127+
128+
```ruby
129+
# Control panel provides pre-hashed password
130+
hashed_password = control_panel.get_user_password_hash(username)
131+
132+
postgresql_role username do
133+
encrypted_password hashed_password
134+
login true
135+
createdb user_permissions.include?('createdb')
136+
action [:create, :update]
137+
end
138+
```
139+
140+
### Migration from MD5
141+
142+
When migrating from MD5 to SCRAM-SHA-256:
143+
144+
```ruby
145+
# First, configure SCRAM-SHA-256 authentication
146+
postgresql_access 'upgrade to scram' do
147+
type 'host'
148+
database 'all'
149+
user 'all'
150+
address '127.0.0.1/32'
151+
auth_method 'scram-sha-256'
152+
end
153+
154+
# Users will need to reset their passwords
155+
# The new passwords will automatically use SCRAM-SHA-256
156+
```
157+
158+
## Troubleshooting
159+
160+
### Common Issues
161+
162+
1. **Password mangling**: If you see passwords with missing `$` characters, ensure you're using this cookbook version that includes automatic escaping.
163+
164+
2. **Authentication failures**: Verify that:
165+
- The `pg_hba.conf` is configured for `scram-sha-256`
166+
- The password hash format is correct
167+
- The user has login privileges
168+
169+
3. **Iteration count**: Higher iteration counts (e.g., 8192 or 16384) provide better security but require more CPU time.
170+
171+
### Debugging
172+
173+
Check the PostgreSQL logs for authentication details:
174+
175+
```bash
176+
tail -f /var/log/postgresql/postgresql-*.log
177+
```
178+
179+
Verify user configuration:
180+
181+
```sql
182+
SELECT rolname, rolcanlogin, rolpassword
183+
FROM pg_authid
184+
WHERE rolname = 'your_username';
185+
```
186+
187+
## Security Recommendations
188+
189+
1. **Use high iteration counts**: 4096 is the minimum; consider 8192 or higher for sensitive applications.
190+
2. **Enforce SCRAM-SHA-256**: Disable MD5 authentication entirely when possible.
191+
3. **Regular password rotation**: Implement password rotation policies.
192+
4. **Monitor authentication**: Log and monitor authentication attempts.
193+
194+
## References
195+
196+
- [PostgreSQL SCRAM-SHA-256 Documentation](https://www.postgresql.org/docs/current/auth-password.html)
197+
- [RFC 7677: SCRAM-SHA-256 and SCRAM-SHA-256-PLUS](https://tools.ietf.org/html/rfc7677)
198+
- [PostgreSQL Security Best Practices](https://www.postgresql.org/docs/current/auth-methods.html)

libraries/sql/role.rb

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ def pg_role_encrypted_password(name)
6262
authid&.to_a&.pop&.fetch('rolpassword')
6363
end
6464

65+
def escape_password_for_sql(password)
66+
return password if password.nil? || password.empty?
67+
68+
# SCRAM-SHA-256 passwords contain $ characters that can be interpreted
69+
# by shell or string processing. Escape them to prevent mangling.
70+
if password.start_with?('SCRAM-SHA-256')
71+
password.gsub('$', '\$')
72+
else
73+
password
74+
end
75+
end
76+
6577
def role_sql(new_resource)
6678
sql = []
6779

@@ -80,7 +92,8 @@ def role_sql(new_resource)
8092
sql.push("CONNECTION LIMIT #{new_resource.connection_limit}")
8193

8294
if new_resource.encrypted_password
83-
sql.push("ENCRYPTED PASSWORD '#{new_resource.encrypted_password}'")
95+
escaped_password = escape_password_for_sql(new_resource.encrypted_password)
96+
sql.push("ENCRYPTED PASSWORD '#{escaped_password}'")
8497
elsif new_resource.unencrypted_password
8598
sql.push("PASSWORD '#{new_resource.unencrypted_password}'")
8699
else
@@ -121,7 +134,8 @@ def update_role_password(new_resource)
121134
sql.push("ALTER ROLE \"#{new_resource.rolename}\"")
122135

123136
if new_resource.encrypted_password
124-
sql.push("ENCRYPTED PASSWORD '#{new_resource.encrypted_password}'")
137+
escaped_password = escape_password_for_sql(new_resource.encrypted_password)
138+
sql.push("ENCRYPTED PASSWORD '#{escaped_password}'")
125139
elsif new_resource.unencrypted_password
126140
sql.push("PASSWORD '#{new_resource.unencrypted_password}'")
127141
else

spec/libraries/role_spec.rb

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
require 'spec_helper'
2+
require_relative '../../libraries/sql/role'
3+
4+
# Mock the dependencies for testing
5+
module PostgreSQL
6+
module Cookbook
7+
module Utils
8+
def nil_or_empty?(value)
9+
value.nil? || value.empty?
10+
end
11+
end
12+
13+
module SqlHelpers
14+
module Connection
15+
end
16+
end
17+
end
18+
end
19+
20+
class Utils
21+
end
22+
23+
describe 'PostgreSQL::Cookbook::SqlHelpers::Role' do
24+
let(:test_class) do
25+
Class.new do
26+
include PostgreSQL::Cookbook::SqlHelpers::Role
27+
include PostgreSQL::Cookbook::Utils
28+
end
29+
end
30+
31+
let(:instance) { test_class.new }
32+
33+
describe '#escape_password_for_sql' do
34+
context 'with SCRAM-SHA-256 passwords' do
35+
let(:scram_password) { 'SCRAM-SHA-256$4096:27klCUc487uwvJVGKI5YNA==$6K2Y+S3YBlpfRNrLROoO2ulWmnrQoRlGI1GqpNRq0T0=:y4esBVjK/hMtxDB5aWN4ynS1SnQcT1TFTqV0J/snls4=' }
36+
37+
it 'escapes dollar signs in SCRAM-SHA-256 passwords' do
38+
result = instance.send(:escape_password_for_sql, scram_password)
39+
expect(result).to eq('SCRAM-SHA-256\$4096:27klCUc487uwvJVGKI5YNA==\$6K2Y+S3YBlpfRNrLROoO2ulWmnrQoRlGI1GqpNRq0T0=:y4esBVjK/hMtxDB5aWN4ynS1SnQcT1TFTqV0J/snls4=')
40+
end
41+
42+
it 'handles SCRAM-SHA-256 passwords with multiple dollar signs' do
43+
password = 'SCRAM-SHA-256$1024:salt$key1$key2'
44+
result = instance.send(:escape_password_for_sql, password)
45+
expect(result).to eq('SCRAM-SHA-256\$1024:salt\$key1\$key2')
46+
end
47+
end
48+
49+
context 'with non-SCRAM passwords' do
50+
it 'does not modify MD5 passwords' do
51+
md5_password = 'md5c5e1324c052bd9e8471c44a3d2bda0c8'
52+
result = instance.send(:escape_password_for_sql, md5_password)
53+
expect(result).to eq(md5_password)
54+
end
55+
56+
it 'does not modify plain text passwords' do
57+
plain_password = 'my$plain$password'
58+
result = instance.send(:escape_password_for_sql, plain_password)
59+
expect(result).to eq(plain_password)
60+
end
61+
62+
it 'does not modify other hash types' do
63+
other_hash = 'sha256$somehash$value'
64+
result = instance.send(:escape_password_for_sql, other_hash)
65+
expect(result).to eq(other_hash)
66+
end
67+
end
68+
69+
context 'with edge cases' do
70+
it 'handles nil passwords' do
71+
result = instance.send(:escape_password_for_sql, nil)
72+
expect(result).to be_nil
73+
end
74+
75+
it 'handles empty passwords' do
76+
result = instance.send(:escape_password_for_sql, '')
77+
expect(result).to eq('')
78+
end
79+
80+
it 'handles passwords that start with SCRAM-SHA-256 but have no dollar signs' do
81+
password = 'SCRAM-SHA-256-invalid'
82+
result = instance.send(:escape_password_for_sql, password)
83+
expect(result).to eq(password)
84+
end
85+
end
86+
end
87+
end

0 commit comments

Comments
 (0)