Skip to content

Commit

Permalink
Support for SCRAM-SHA-256 SASL authentication (#89)
Browse files Browse the repository at this point in the history
* Add protocol awareness and encode/decode for authentication message types 10 (SASL mechanisms), 11 (SASL continue), and 12 (SASL final). Add more specific errors for types 2(Kerberos), 7(GSSAPI), 8(GSSAPI), 9(SSPI), and 6(obsolete SCM).

* Add generic SASL authentication management class with pluggable (via generics) SASL mechanism implementations.

* A mostly complete, if very, VERY messy, implementation of SCRAM-SHA-256 and SCRAM-SHA-256-PLUS per RFC 7677 et al. Things that are still missing: Channel binding support (Postgres DOES use this), authorization names (Postgres does not use these), proper username and password normalization, RFC-compliant validation of nonces, and determining whether the Hi() function can be replaced with PBKDF2

* Extend PostgresConnection to use SCRAM-SHA-256 negotiation when offered.

* Heavily update test matrix. Leave several of the Swift version/OS combos disabled to cut down on the excessive number of checks generated by the test matrices (72 instead of 234).
  • Loading branch information
gwynne committed Jan 11, 2021
1 parent 2808c4f commit 072b685
Show file tree
Hide file tree
Showing 7 changed files with 1,101 additions and 90 deletions.
135 changes: 69 additions & 66 deletions .github/workflows/test.yml
@@ -1,35 +1,40 @@
name: test
on:
- pull_request
defaults:
run:
shell: bash
on: [ 'pull_request' ]
env:
LOG_LEVEL: notice

jobs:

# Test that packages depending on us still work
dependents:
strategy:
fail-fast: false
matrix:
swiftver:
- 5.2
- 5.3
dbimage:
- postgres:13
- postgres:12
- postgres:11
dependent:
- postgres-kit
- fluent-postgres-driver
container: swift:${{ matrix.swiftver }}-focal
runs-on: ubuntu-latest
services:
psql-a:
image: ${{ matrix.dbimage }}
env:
env:
POSTGRES_USER: vapor_username
POSTGRES_DB: vapor_database
POSTGRES_PASSWORD: vapor_password
psql-b:
image: ${{ matrix.dbimage }}
env:
env:
POSTGRES_USER: vapor_username
POSTGRES_DB: vapor_database
POSTGRES_PASSWORD: vapor_password
container: swift:5.2-bionic
strategy:
fail-fast: false
matrix:
dbimage:
- postgres:12
- postgres:11
dependent:
- postgres-kit
- fluent-postgres-driver
steps:
- name: Check out package
uses: actions/checkout@v2
Expand All @@ -50,83 +55,81 @@ jobs:
POSTGRES_HOSTNAME: psql-a
POSTGRES_HOSTNAME_A: psql-a
POSTGRES_HOSTNAME_B: psql-b
LOG_LEVEL: notice

# Run package tests on Linux Swift runners against supported PSQL versions
linux:
strategy:
fail-fast: false
matrix:
dbimage:
- postgres:13
- postgres:12
- postgres:11
runner:
# 5.2 Stable
- swift:5.2-xenial
- swift:5.2-bionic
# 5.2 Unstable
- swiftlang/swift:nightly-5.2-xenial
- swiftlang/swift:nightly-5.2-bionic
# 5.3 Unstable
- swiftlang/swift:nightly-5.3-xenial
- swiftlang/swift:nightly-5.3-bionic
# Master Unsable
- swiftlang/swift:nightly-master-xenial
- swiftlang/swift:nightly-master-bionic
- swiftlang/swift:nightly-master-focal
- swiftlang/swift:nightly-master-centos8
- swiftlang/swift:nightly-master-amazonlinux2
container: ${{ matrix.runner }}
dbauth:
- trust
- md5
- scram-sha-256
swiftver:
#- swift:5.2
- swift:5.3
- swiftlang/swift:nightly-5.3
#- swiftlang/swift:nightly-master
swiftos:
#- xenial
- bionic
- focal
#- centos7
#- centos8
- amazonlinux2
container: ${{ format('{0}-{1}', matrix.swiftver, matrix.swiftos) }}
runs-on: ubuntu-latest
services:
psql:
image: ${{ matrix.dbimage }}
env:
env:
POSTGRES_USER: vapor_username
POSTGRES_DB: vapor_database
POSTGRES_PASSWORD: vapor_password
POSTGRES_HOST_AUTH_METHOD: ${{ matrix.authtype }}
steps:
#- name: SPM is incompatible with CentOS 7
# if: ${{ matrix.swiftos == 'centos7' }}
# run: |
# yum install -y make libcurl-devel
# git clone https://github.com/git/git -bv2.28.0 --depth 1 && cd git
# make prefix=/usr -j all install NO_OPENSSL=1 NO_EXPAT=1 NO_TCLTK=1 NO_GETTEXT=1 NO_PERL=1
- name: Check out code
uses: actions/checkout@v2
- name: Run tests with Thread Sanitizer
run: swift test --enable-test-discovery --sanitize=thread
env:
POSTGRES_HOSTNAME: psql
LOG_LEVEL: notice
macOS:
env: { POSTGRES_HOSTNAME: 'psql' }

# Run package tests on macOS against supported PSQL versions
macos:
strategy:
fail-fast: false
matrix:
include:
- formula: postgresql@11
datadir: postgresql@11
- formula: postgresql@12
datadir: postgres
dbauth:
- trust
- md5
- scram-sha-256
formula:
- postgresql@11
- postgresql@12
runs-on: macos-latest
steps:
- name: Select latest available Xcode
uses: maxim-lobanov/setup-xcode@1.0
with:
xcode-version: latest
- name: Replace Postgres install and start server
run: |
brew uninstall --force postgresql php && rm -rf /usr/local/{etc,var}/{postgres,pg}*
brew install ${{ matrix.formula }} && brew link --force ${{ matrix.formula }}
initdb --locale=C -E UTF-8 $(brew --prefix)/var/${{ matrix.datadir }}
brew services start ${{ matrix.formula }}
- name: Wait for server to be ready
run: until pg_isready; do sleep 1; done
timeout-minutes: 2
- name: Setup users and databases for Postgres
with: { 'xcode-version': latest }
- name: Install Postgres, setup DB and auth, and wait for server start
run: |
createuser --createdb --login vapor_username
for db in vapor_database_{a,b}; do
createdb -Ovapor_username $db && psql $db <<<"ALTER SCHEMA public OWNER TO vapor_username;"
done
export PATH="/usr/local/opt/${{ matrix.formula }}/bin:$PATH" PGDATA=/tmp/vapor-postgres-test
brew install ${{ matrix.formula }}
initdb --locale=C -A ${{ matrix.dbauth }} -U vapor_username --pwfile=<(echo vapor_password)
pg_ctl start --wait
timeout-minutes: 5
- name: Checkout code
uses: actions/checkout@v2
- name: Run tests with Thread Sanitizer
run: swift test --enable-test-discovery --sanitize=thread
env:
POSTGRES_DATABASE: vapor_database_a
POSTGRES_DATABASE_A: vapor_database_a
POSTGRES_DATABASE_B: vapor_database_b
LOG_LEVEL: notice
env: { POSTGRES_DATABASE: 'postgres' }
Expand Up @@ -23,6 +23,9 @@ extension PostgresConnection {
private final class PostgresAuthenticationRequest: PostgresRequest {
enum State {
case ready
case saslInitialSent(SASLAuthenticationManager<SASLMechanism.SCRAM.SHA256>)
case saslChallengeResponse(SASLAuthenticationManager<SASLMechanism.SCRAM.SHA256>)
case saslWaitOkay
case done
}

Expand Down Expand Up @@ -60,12 +63,63 @@ private final class PostgresAuthenticationRequest: PostgresRequest {
return try [PostgresMessage.Password(string: hash).message()]
case .plaintext:
return try [PostgresMessage.Password(string: self.password ?? "").message()]
case .saslMechanisms(let saslMechanisms):
if saslMechanisms.contains("SCRAM-SHA-256") && self.password != nil {
let saslManager = SASLAuthenticationManager(asClientSpeaking:
SASLMechanism.SCRAM.SHA256(username: self.username, password: { self.password! }))
var message: PostgresMessage?

if (try saslManager.handle(message: nil, sender: { bytes in
message = try PostgresMessage.SASLInitialResponse(mechanism: "SCRAM-SHA-256", initialData: bytes).message()
})) {
self.state = .saslWaitOkay
} else {
self.state = .saslInitialSent(saslManager)
}
return [message].compactMap { $0 }
} else {
throw PostgresError.protocol("Unable to authenticate with any available SASL mechanism: \(saslMechanisms)")
}
case .saslContinue, .saslFinal:
throw PostgresError.protocol("Unexpected SASL response to start message: \(message)")
case .ok:
self.state = .done
return []
}
default: throw PostgresError.protocol("Unexpected response to start message: \(message)")
}
case .saslInitialSent(let manager),
.saslChallengeResponse(let manager):
switch message.identifier {
case .authentication:
let auth = try PostgresMessage.Authentication(message: message)
switch auth {
case .saslContinue(let data), .saslFinal(let data):
var message: PostgresMessage?
if try manager.handle(message: data, sender: { bytes in
message = try PostgresMessage.SASLResponse(responseData: bytes).message()
}) {
self.state = .saslWaitOkay
} else {
self.state = .saslChallengeResponse(manager)
}
return [message].compactMap { $0 }
default: throw PostgresError.protocol("Unexpected response during SASL negotiation: \(message)")
}
default: throw PostgresError.protocol("Unexpected response during SASL negotiation: \(message)")
}
case .saslWaitOkay:
switch message.identifier {
case .authentication:
let auth = try PostgresMessage.Authentication(message: message)
switch auth {
case .ok:
self.state = .done
return []
default: throw PostgresError.protocol("Unexpected response while waiting for post-SASL ok: \(message)")
}
default: throw PostgresError.protocol("Unexpected response while waiting for post-SASL ok: \(message)")
}
case .done:
switch message.identifier {
case .parameterStatus:
Expand Down
59 changes: 58 additions & 1 deletion Sources/PostgresNIO/Message/PostgresMessage+Authentication.swift
Expand Up @@ -20,8 +20,39 @@ extension PostgresMessage {
throw PostgresError.protocol("Could not parse MD5 salt from authentication message")
}
return .md5(salt)
case 10:
var mechanisms: [String] = []
while buffer.readableBytes > 0 {
guard let nextString = buffer.readNullTerminatedString() else {
throw PostgresError.protocol("Could not parse SASL mechanisms from authentication message")
}
if nextString.isEmpty {
break
}
mechanisms.append(nextString)
}
guard buffer.readableBytes == 0 else {
throw PostgresError.protocol("Trailing data at end of SASL mechanisms authentication message")
}
return .saslMechanisms(mechanisms)
case 11:
guard let challengeData = buffer.readBytes(length: buffer.readableBytes) else {
throw PostgresError.protocol("Could not parse SASL challenge from authentication message")
}
return .saslContinue(challengeData)
case 12:
guard let finalData = buffer.readBytes(length: buffer.readableBytes) else {
throw PostgresError.protocol("Could not parse SASL final data from authentication message")
}
return .saslFinal(finalData)

case 2, 7...9:
throw PostgresError.protocol("Support for KRBv5, GSSAPI, and SSPI authentication are not implemented")
case 6:
throw PostgresError.protocol("Support for SCM credential authentication is obsolete")

default:
throw PostgresError.protocol("Unkonwn authentication request type: \(type)")
throw PostgresError.protocol("Unknown authentication request type: \(type)")
}
}

Expand All @@ -34,6 +65,17 @@ extension PostgresMessage {
case .md5(let salt):
buffer.writeInteger(5, as: Int32.self)
buffer.writeBytes(salt)
case .saslMechanisms(let mechanisms):
buffer.writeInteger(10, as: Int32.self)
mechanisms.forEach {
buffer.write(nullTerminated: $0)
}
case .saslContinue(let challenge):
buffer.writeInteger(11, as: Int32.self)
buffer.writeBytes(challenge)
case .saslFinal(let data):
buffer.writeInteger(12, as: Int32.self)
buffer.writeBytes(data)
}
}

Expand All @@ -49,12 +91,27 @@ extension PostgresMessage {
/// Specifies that an MD5-encrypted password is required.
case md5([UInt8])

/// AuthenticationSASL
/// Specifies the start of SASL mechanism negotiation.
case saslMechanisms([String])

/// AuthenticationSASLContinue
/// Specifies SASL mechanism-specific challenge data.
case saslContinue([UInt8])

/// AuthenticationSASLFinal
/// Specifies mechanism-specific post-authentication client data.
case saslFinal([UInt8])

/// See `CustomStringConvertible`.
public var description: String {
switch self {
case .ok: return "Ok"
case .plaintext: return "CleartextPassword"
case .md5(let salt): return "MD5Password(salt: 0x\(salt.hexdigest()))"
case .saslMechanisms(let mech): return "SASLMechanisms(\(mech))"
case .saslContinue(let data): return "SASLChallenge(\(data))"
case .saslFinal(let data): return "SASLFinal(\(data))"
}
}
}
Expand Down

0 comments on commit 072b685

Please sign in to comment.