diff --git a/README.md b/README.md index cfbd8cf1d..7da2aa7e4 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ We follow the currently supported versions listed on :$:` +3. **Automatic escaping**: The cookbook automatically handles escaping of special characters (`$`) in SCRAM-SHA-256 passwords. + +### Password Generation + +To generate a SCRAM-SHA-256 password hash, you can use: + +```bash +# Using PostgreSQL's built-in function +psql -c "SELECT gen_random_uuid();" # for salt generation +# Then use a SCRAM-SHA-256 library to generate the hash +``` + +Or use a Ruby library like `scram-sha-256`: + +```ruby +require 'scram-sha-256' +password_hash = ScramSha256.hash_password('your_password', 4096) +``` + +### Configuration Example + +```ruby +# Configure access method +postgresql_access 'scram access' do + type 'host' + database 'all' + user 'myuser' + address '127.0.0.1/32' + auth_method 'scram-sha-256' +end + +# Create user with SCRAM password +postgresql_role 'myuser' do + encrypted_password 'SCRAM-SHA-256$4096:abc123...$def456...:ghi789...' + login true +end +``` diff --git a/documentation/scram-sha-256.md b/documentation/scram-sha-256.md new file mode 100644 index 000000000..92bc9b721 --- /dev/null +++ b/documentation/scram-sha-256.md @@ -0,0 +1,203 @@ +# SCRAM-SHA-256 Authentication + +SCRAM-SHA-256 (Salted Challenge Response Authentication Mechanism) is a password authentication method in PostgreSQL that provides better security than traditional MD5 authentication. + +## Overview + +SCRAM-SHA-256 authentication offers several advantages: + +- **Stronger security**: Uses SHA-256 instead of MD5 +- **Salt protection**: Prevents rainbow table attacks +- **Iteration count**: Makes brute force attacks more difficult +- **Mutual authentication**: Both client and server verify each other + +## Password Format + +SCRAM-SHA-256 passwords have this specific format: + +```text +SCRAM-SHA-256$:$: +``` + +Example: + +```text +SCRAM-SHA-256$4096:27klCUc487uwvJVGKI5YNA==$6K2Y+S3YBlpfRNrLROoO2ulWmnrQoRlGI1GqpNRq0T0=:y4esBVjK/hMtxDB5aWN4ynS1SnQcT1TFTqV0J/snls4= +``` + +## Usage with Chef + +### Creating Users with SCRAM-SHA-256 Passwords + +When you have a pre-computed SCRAM-SHA-256 password hash: + +```ruby +postgresql_role 'secure_user' do + encrypted_password 'SCRAM-SHA-256$4096:27klCUc487uwvJVGKI5YNA==$6K2Y+S3YBlpfRNrLROoO2ulWmnrQoRlGI1GqpNRq0T0=:y4esBVjK/hMtxDB5aWN4ynS1SnQcT1TFTqV0J/snls4=' + login true + action :create +end +``` + +### Automatic Character Escaping + +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. + +**Before (manual escaping required):** + +```ruby +postgresql_role 'user1' do + # Manual escaping was required + password 'SCRAM-SHA-256$4096:salt$key:server'.gsub('$', '\$') + action [:create, :update] +end +``` + +**Now (automatic escaping):** + +```ruby +postgresql_role 'user1' do + # No manual escaping needed + encrypted_password 'SCRAM-SHA-256$4096:salt$key:server' + action [:create, :update] +end +``` + +## Configuring Authentication + +To use SCRAM-SHA-256 authentication, configure the access method: + +```ruby +postgresql_access 'scram authentication' do + type 'host' + database 'all' + user 'myuser' + address '127.0.0.1/32' + auth_method 'scram-sha-256' +end +``` + +## Password Generation + +### Using PostgreSQL + +Generate a SCRAM-SHA-256 password directly in PostgreSQL: + +```sql +-- Set password for existing user (PostgreSQL will hash it) +ALTER ROLE myuser PASSWORD 'plaintext_password'; + +-- Check the generated hash +SELECT rolpassword FROM pg_authid WHERE rolname = 'myuser'; +``` + +### Using Ruby + +Generate a SCRAM-SHA-256 hash using the `scram-sha-256` gem: + +```ruby +require 'scram-sha-256' + +# Generate hash with default iteration count (4096) +password_hash = ScramSha256.hash_password('my_plain_password') + +# Generate hash with custom iteration count +password_hash = ScramSha256.hash_password('my_plain_password', 8192) +``` + +### Using Python + +Generate a SCRAM-SHA-256 hash using Python: + +```python +import hashlib +import hmac +import base64 +import secrets + +def generate_scram_sha256(password, salt=None, iterations=4096): + if salt is None: + salt = secrets.token_bytes(16) + + # Implementation details would go here + # This is a simplified example + pass +``` + +## Common Use Cases + +### Control Panel Integration + +When integrating with control panels that pre-hash passwords: + +```ruby +# Control panel provides pre-hashed password +hashed_password = control_panel.get_user_password_hash(username) + +postgresql_role username do + encrypted_password hashed_password + login true + createdb user_permissions.include?('createdb') + action [:create, :update] +end +``` + +### Migration from MD5 + +When migrating from MD5 to SCRAM-SHA-256: + +```ruby +# First, configure SCRAM-SHA-256 authentication +postgresql_access 'upgrade to scram' do + type 'host' + database 'all' + user 'all' + address '127.0.0.1/32' + auth_method 'scram-sha-256' +end + +# Users will need to reset their passwords +# The new passwords will automatically use SCRAM-SHA-256 +``` + +## Troubleshooting + +### Common Issues + +1. **Password mangling**: If you see passwords with missing `$` characters, ensure you're using this cookbook version that includes automatic escaping. + +2. **Authentication failures**: Verify that: + - The `pg_hba.conf` is configured for `scram-sha-256` + - The password hash format is correct + - The user has login privileges + +3. **Iteration count**: Higher iteration counts (e.g., 8192 or 16384) provide better security but require more CPU time. + +### Debugging + +Check the PostgreSQL logs for authentication details: + +```bash +tail -f /var/log/postgresql/postgresql-*.log +``` + +Verify user configuration: + +```sql +SELECT rolname, rolcanlogin, rolpassword +FROM pg_authid +WHERE rolname = 'your_username'; +``` + +## Security Recommendations + +1. **Use high iteration counts**: 4096 is the minimum; consider 8192 or higher for sensitive applications. +2. **Enforce SCRAM-SHA-256**: Disable MD5 authentication entirely when possible. +3. **Regular password rotation**: Implement password rotation policies. +4. **Monitor authentication**: Log and monitor authentication attempts. + +## References + +- [PostgreSQL SCRAM-SHA-256 Documentation](https://www.postgresql.org/docs/current/auth-password.html) +- [RFC 7677: SCRAM-SHA-256 and SCRAM-SHA-256-PLUS](https://tools.ietf.org/html/rfc7677) +- [PostgreSQL Security Best Practices](https://www.postgresql.org/docs/current/auth-methods.html) diff --git a/libraries/sql/role.rb b/libraries/sql/role.rb index 5f100118a..402094ed6 100644 --- a/libraries/sql/role.rb +++ b/libraries/sql/role.rb @@ -62,6 +62,18 @@ def pg_role_encrypted_password(name) authid&.to_a&.pop&.fetch('rolpassword') end + def escape_password_for_sql(password) + return password if nil_or_empty?(password) + + # SCRAM-SHA-256 passwords contain $ characters that can be interpreted + # by shell or string processing. Escape them to prevent mangling. + if password.start_with?('SCRAM-SHA-256') + password.gsub('\\', '\\\\').gsub("'", "''").gsub('$', '\\$') + else + password.gsub("'", "''") + end + end + def role_sql(new_resource) sql = [] @@ -80,7 +92,8 @@ def role_sql(new_resource) sql.push("CONNECTION LIMIT #{new_resource.connection_limit}") if new_resource.encrypted_password - sql.push("ENCRYPTED PASSWORD '#{new_resource.encrypted_password}'") + escaped_password = escape_password_for_sql(new_resource.encrypted_password) + sql.push("ENCRYPTED PASSWORD '#{escaped_password}'") elsif new_resource.unencrypted_password sql.push("PASSWORD '#{new_resource.unencrypted_password}'") else @@ -121,7 +134,8 @@ def update_role_password(new_resource) sql.push("ALTER ROLE \"#{new_resource.rolename}\"") if new_resource.encrypted_password - sql.push("ENCRYPTED PASSWORD '#{new_resource.encrypted_password}'") + escaped_password = escape_password_for_sql(new_resource.encrypted_password) + sql.push("ENCRYPTED PASSWORD '#{escaped_password}'") elsif new_resource.unencrypted_password sql.push("PASSWORD '#{new_resource.unencrypted_password}'") else diff --git a/spec/libraries/role_spec.rb b/spec/libraries/role_spec.rb new file mode 100644 index 000000000..fd4b73c66 --- /dev/null +++ b/spec/libraries/role_spec.rb @@ -0,0 +1,87 @@ +require 'spec_helper' +require_relative '../../libraries/sql/role' + +# Mock the dependencies for testing +module PostgreSQL + module Cookbook + module Utils + def nil_or_empty?(value) + value.nil? || value.empty? + end + end + + module SqlHelpers + module Connection + end + end + end +end + +class Utils +end + +describe 'PostgreSQL::Cookbook::SqlHelpers::Role' do + let(:test_class) do + Class.new do + include PostgreSQL::Cookbook::SqlHelpers::Role + include PostgreSQL::Cookbook::Utils + end + end + + let(:instance) { test_class.new } + + describe '#escape_password_for_sql' do + context 'with SCRAM-SHA-256 passwords' do + let(:scram_password) { 'SCRAM-SHA-256$4096:27klCUc487uwvJVGKI5YNA==$6K2Y+S3YBlpfRNrLROoO2ulWmnrQoRlGI1GqpNRq0T0=:y4esBVjK/hMtxDB5aWN4ynS1SnQcT1TFTqV0J/snls4=' } + + it 'escapes dollar signs in SCRAM-SHA-256 passwords' do + result = instance.send(:escape_password_for_sql, scram_password) + expect(result).to eq('SCRAM-SHA-256\$4096:27klCUc487uwvJVGKI5YNA==\$6K2Y+S3YBlpfRNrLROoO2ulWmnrQoRlGI1GqpNRq0T0=:y4esBVjK/hMtxDB5aWN4ynS1SnQcT1TFTqV0J/snls4=') + end + + it 'handles SCRAM-SHA-256 passwords with multiple dollar signs' do + password = 'SCRAM-SHA-256$1024:salt$key1$key2' + result = instance.send(:escape_password_for_sql, password) + expect(result).to eq('SCRAM-SHA-256\$1024:salt\$key1\$key2') + end + end + + context 'with non-SCRAM passwords' do + it 'does not modify MD5 passwords' do + md5_password = 'md5c5e1324c052bd9e8471c44a3d2bda0c8' + result = instance.send(:escape_password_for_sql, md5_password) + expect(result).to eq(md5_password) + end + + it 'does not modify plain text passwords' do + plain_password = 'my$plain$password' + result = instance.send(:escape_password_for_sql, plain_password) + expect(result).to eq(plain_password) + end + + it 'does not modify other hash types' do + other_hash = 'sha256$somehash$value' + result = instance.send(:escape_password_for_sql, other_hash) + expect(result).to eq(other_hash) + end + end + + context 'with edge cases' do + it 'handles nil passwords' do + result = instance.send(:escape_password_for_sql, nil) + expect(result).to be_nil + end + + it 'handles empty passwords' do + result = instance.send(:escape_password_for_sql, '') + expect(result).to eq('') + end + + it 'handles passwords that start with SCRAM-SHA-256 but have no dollar signs' do + password = 'SCRAM-SHA-256-invalid' + result = instance.send(:escape_password_for_sql, password) + expect(result).to eq(password) + end + end + end +end \ No newline at end of file diff --git a/test/cookbooks/test/recipes/access.rb b/test/cookbooks/test/recipes/access.rb index c88ceba07..f52b6be96 100644 --- a/test/cookbooks/test/recipes/access.rb +++ b/test/cookbooks/test/recipes/access.rb @@ -137,3 +137,10 @@ address '127.0.0.1/32' auth_method 'scram-sha-256' end + +# Test SCRAM-SHA-256 password handling with $ characters +postgresql_user 'scram_test_user' do + encrypted_password 'SCRAM-SHA-256$4096:27klCUc487uwvJVGKI5YNA==$6K2Y+S3YBlpfRNrLROoO2ulWmnrQoRlGI1GqpNRq0T0=:y4esBVjK/hMtxDB5aWN4ynS1SnQcT1TFTqV0J/snls4=' + login true + action [:create] +end diff --git a/test/integration/access/controls/base_access.rb b/test/integration/access/controls/base_access.rb index 224ee27f6..9354f63cd 100644 --- a/test/integration/access/controls/base_access.rb +++ b/test/integration/access/controls/base_access.rb @@ -140,3 +140,14 @@ its('auth_params') { should cmp 'ldapbasedn="dc=example, dc=net" ldapsearchattribute=uid ldapserver=ldap.example.net' } end end + +control 'scram_test_user role should exist' do + impact 1.0 + desc 'The scram_test_user database role should exist to test SCRAM-SHA-256 password handling' + + postgres_access = postgres_session('postgres', '12345', '127.0.0.1') + + describe postgres_access.query('SELECT rolname FROM pg_roles;') do + its('output') { should cmp /scram_test_user/ } + end +end