Skip to content

Commit 072b685

Browse files
authored
Support for SCRAM-SHA-256 SASL authentication (vapor#89)
* 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).
1 parent 2808c4f commit 072b685

File tree

7 files changed

+1101
-90
lines changed

7 files changed

+1101
-90
lines changed

.github/workflows/test.yml

Lines changed: 69 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,40 @@
11
name: test
2-
on:
3-
- pull_request
4-
defaults:
5-
run:
6-
shell: bash
2+
on: [ 'pull_request' ]
3+
env:
4+
LOG_LEVEL: notice
5+
76
jobs:
7+
8+
# Test that packages depending on us still work
89
dependents:
10+
strategy:
11+
fail-fast: false
12+
matrix:
13+
swiftver:
14+
- 5.2
15+
- 5.3
16+
dbimage:
17+
- postgres:13
18+
- postgres:12
19+
- postgres:11
20+
dependent:
21+
- postgres-kit
22+
- fluent-postgres-driver
23+
container: swift:${{ matrix.swiftver }}-focal
924
runs-on: ubuntu-latest
1025
services:
1126
psql-a:
1227
image: ${{ matrix.dbimage }}
13-
env:
28+
env:
1429
POSTGRES_USER: vapor_username
1530
POSTGRES_DB: vapor_database
1631
POSTGRES_PASSWORD: vapor_password
1732
psql-b:
1833
image: ${{ matrix.dbimage }}
19-
env:
34+
env:
2035
POSTGRES_USER: vapor_username
2136
POSTGRES_DB: vapor_database
2237
POSTGRES_PASSWORD: vapor_password
23-
container: swift:5.2-bionic
24-
strategy:
25-
fail-fast: false
26-
matrix:
27-
dbimage:
28-
- postgres:12
29-
- postgres:11
30-
dependent:
31-
- postgres-kit
32-
- fluent-postgres-driver
3338
steps:
3439
- name: Check out package
3540
uses: actions/checkout@v2
@@ -50,83 +55,81 @@ jobs:
5055
POSTGRES_HOSTNAME: psql-a
5156
POSTGRES_HOSTNAME_A: psql-a
5257
POSTGRES_HOSTNAME_B: psql-b
53-
LOG_LEVEL: notice
58+
59+
# Run package tests on Linux Swift runners against supported PSQL versions
5460
linux:
5561
strategy:
5662
fail-fast: false
5763
matrix:
5864
dbimage:
65+
- postgres:13
5966
- postgres:12
6067
- postgres:11
61-
runner:
62-
# 5.2 Stable
63-
- swift:5.2-xenial
64-
- swift:5.2-bionic
65-
# 5.2 Unstable
66-
- swiftlang/swift:nightly-5.2-xenial
67-
- swiftlang/swift:nightly-5.2-bionic
68-
# 5.3 Unstable
69-
- swiftlang/swift:nightly-5.3-xenial
70-
- swiftlang/swift:nightly-5.3-bionic
71-
# Master Unsable
72-
- swiftlang/swift:nightly-master-xenial
73-
- swiftlang/swift:nightly-master-bionic
74-
- swiftlang/swift:nightly-master-focal
75-
- swiftlang/swift:nightly-master-centos8
76-
- swiftlang/swift:nightly-master-amazonlinux2
77-
container: ${{ matrix.runner }}
68+
dbauth:
69+
- trust
70+
- md5
71+
- scram-sha-256
72+
swiftver:
73+
#- swift:5.2
74+
- swift:5.3
75+
- swiftlang/swift:nightly-5.3
76+
#- swiftlang/swift:nightly-master
77+
swiftos:
78+
#- xenial
79+
- bionic
80+
- focal
81+
#- centos7
82+
#- centos8
83+
- amazonlinux2
84+
container: ${{ format('{0}-{1}', matrix.swiftver, matrix.swiftos) }}
7885
runs-on: ubuntu-latest
7986
services:
8087
psql:
8188
image: ${{ matrix.dbimage }}
82-
env:
89+
env:
8390
POSTGRES_USER: vapor_username
8491
POSTGRES_DB: vapor_database
8592
POSTGRES_PASSWORD: vapor_password
93+
POSTGRES_HOST_AUTH_METHOD: ${{ matrix.authtype }}
8694
steps:
95+
#- name: SPM is incompatible with CentOS 7
96+
# if: ${{ matrix.swiftos == 'centos7' }}
97+
# run: |
98+
# yum install -y make libcurl-devel
99+
# git clone https://github.com/git/git -bv2.28.0 --depth 1 && cd git
100+
# make prefix=/usr -j all install NO_OPENSSL=1 NO_EXPAT=1 NO_TCLTK=1 NO_GETTEXT=1 NO_PERL=1
87101
- name: Check out code
88102
uses: actions/checkout@v2
89103
- name: Run tests with Thread Sanitizer
90104
run: swift test --enable-test-discovery --sanitize=thread
91-
env:
92-
POSTGRES_HOSTNAME: psql
93-
LOG_LEVEL: notice
94-
macOS:
105+
env: { POSTGRES_HOSTNAME: 'psql' }
106+
107+
# Run package tests on macOS against supported PSQL versions
108+
macos:
95109
strategy:
96110
fail-fast: false
97111
matrix:
98-
include:
99-
- formula: postgresql@11
100-
datadir: postgresql@11
101-
- formula: postgresql@12
102-
datadir: postgres
112+
dbauth:
113+
- trust
114+
- md5
115+
- scram-sha-256
116+
formula:
117+
- postgresql@11
118+
- postgresql@12
103119
runs-on: macos-latest
104120
steps:
105121
- name: Select latest available Xcode
106122
uses: maxim-lobanov/setup-xcode@1.0
107-
with:
108-
xcode-version: latest
109-
- name: Replace Postgres install and start server
110-
run: |
111-
brew uninstall --force postgresql php && rm -rf /usr/local/{etc,var}/{postgres,pg}*
112-
brew install ${{ matrix.formula }} && brew link --force ${{ matrix.formula }}
113-
initdb --locale=C -E UTF-8 $(brew --prefix)/var/${{ matrix.datadir }}
114-
brew services start ${{ matrix.formula }}
115-
- name: Wait for server to be ready
116-
run: until pg_isready; do sleep 1; done
117-
timeout-minutes: 2
118-
- name: Setup users and databases for Postgres
123+
with: { 'xcode-version': latest }
124+
- name: Install Postgres, setup DB and auth, and wait for server start
119125
run: |
120-
createuser --createdb --login vapor_username
121-
for db in vapor_database_{a,b}; do
122-
createdb -Ovapor_username $db && psql $db <<<"ALTER SCHEMA public OWNER TO vapor_username;"
123-
done
126+
export PATH="/usr/local/opt/${{ matrix.formula }}/bin:$PATH" PGDATA=/tmp/vapor-postgres-test
127+
brew install ${{ matrix.formula }}
128+
initdb --locale=C -A ${{ matrix.dbauth }} -U vapor_username --pwfile=<(echo vapor_password)
129+
pg_ctl start --wait
130+
timeout-minutes: 5
124131
- name: Checkout code
125132
uses: actions/checkout@v2
126133
- name: Run tests with Thread Sanitizer
127134
run: swift test --enable-test-discovery --sanitize=thread
128-
env:
129-
POSTGRES_DATABASE: vapor_database_a
130-
POSTGRES_DATABASE_A: vapor_database_a
131-
POSTGRES_DATABASE_B: vapor_database_b
132-
LOG_LEVEL: notice
135+
env: { POSTGRES_DATABASE: 'postgres' }

Sources/PostgresNIO/Connection/PostgresConnection+Authenticate.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ extension PostgresConnection {
2323
private final class PostgresAuthenticationRequest: PostgresRequest {
2424
enum State {
2525
case ready
26+
case saslInitialSent(SASLAuthenticationManager<SASLMechanism.SCRAM.SHA256>)
27+
case saslChallengeResponse(SASLAuthenticationManager<SASLMechanism.SCRAM.SHA256>)
28+
case saslWaitOkay
2629
case done
2730
}
2831

@@ -60,12 +63,63 @@ private final class PostgresAuthenticationRequest: PostgresRequest {
6063
return try [PostgresMessage.Password(string: hash).message()]
6164
case .plaintext:
6265
return try [PostgresMessage.Password(string: self.password ?? "").message()]
66+
case .saslMechanisms(let saslMechanisms):
67+
if saslMechanisms.contains("SCRAM-SHA-256") && self.password != nil {
68+
let saslManager = SASLAuthenticationManager(asClientSpeaking:
69+
SASLMechanism.SCRAM.SHA256(username: self.username, password: { self.password! }))
70+
var message: PostgresMessage?
71+
72+
if (try saslManager.handle(message: nil, sender: { bytes in
73+
message = try PostgresMessage.SASLInitialResponse(mechanism: "SCRAM-SHA-256", initialData: bytes).message()
74+
})) {
75+
self.state = .saslWaitOkay
76+
} else {
77+
self.state = .saslInitialSent(saslManager)
78+
}
79+
return [message].compactMap { $0 }
80+
} else {
81+
throw PostgresError.protocol("Unable to authenticate with any available SASL mechanism: \(saslMechanisms)")
82+
}
83+
case .saslContinue, .saslFinal:
84+
throw PostgresError.protocol("Unexpected SASL response to start message: \(message)")
6385
case .ok:
6486
self.state = .done
6587
return []
6688
}
6789
default: throw PostgresError.protocol("Unexpected response to start message: \(message)")
6890
}
91+
case .saslInitialSent(let manager),
92+
.saslChallengeResponse(let manager):
93+
switch message.identifier {
94+
case .authentication:
95+
let auth = try PostgresMessage.Authentication(message: message)
96+
switch auth {
97+
case .saslContinue(let data), .saslFinal(let data):
98+
var message: PostgresMessage?
99+
if try manager.handle(message: data, sender: { bytes in
100+
message = try PostgresMessage.SASLResponse(responseData: bytes).message()
101+
}) {
102+
self.state = .saslWaitOkay
103+
} else {
104+
self.state = .saslChallengeResponse(manager)
105+
}
106+
return [message].compactMap { $0 }
107+
default: throw PostgresError.protocol("Unexpected response during SASL negotiation: \(message)")
108+
}
109+
default: throw PostgresError.protocol("Unexpected response during SASL negotiation: \(message)")
110+
}
111+
case .saslWaitOkay:
112+
switch message.identifier {
113+
case .authentication:
114+
let auth = try PostgresMessage.Authentication(message: message)
115+
switch auth {
116+
case .ok:
117+
self.state = .done
118+
return []
119+
default: throw PostgresError.protocol("Unexpected response while waiting for post-SASL ok: \(message)")
120+
}
121+
default: throw PostgresError.protocol("Unexpected response while waiting for post-SASL ok: \(message)")
122+
}
69123
case .done:
70124
switch message.identifier {
71125
case .parameterStatus:

Sources/PostgresNIO/Message/PostgresMessage+Authentication.swift

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,39 @@ extension PostgresMessage {
2020
throw PostgresError.protocol("Could not parse MD5 salt from authentication message")
2121
}
2222
return .md5(salt)
23+
case 10:
24+
var mechanisms: [String] = []
25+
while buffer.readableBytes > 0 {
26+
guard let nextString = buffer.readNullTerminatedString() else {
27+
throw PostgresError.protocol("Could not parse SASL mechanisms from authentication message")
28+
}
29+
if nextString.isEmpty {
30+
break
31+
}
32+
mechanisms.append(nextString)
33+
}
34+
guard buffer.readableBytes == 0 else {
35+
throw PostgresError.protocol("Trailing data at end of SASL mechanisms authentication message")
36+
}
37+
return .saslMechanisms(mechanisms)
38+
case 11:
39+
guard let challengeData = buffer.readBytes(length: buffer.readableBytes) else {
40+
throw PostgresError.protocol("Could not parse SASL challenge from authentication message")
41+
}
42+
return .saslContinue(challengeData)
43+
case 12:
44+
guard let finalData = buffer.readBytes(length: buffer.readableBytes) else {
45+
throw PostgresError.protocol("Could not parse SASL final data from authentication message")
46+
}
47+
return .saslFinal(finalData)
48+
49+
case 2, 7...9:
50+
throw PostgresError.protocol("Support for KRBv5, GSSAPI, and SSPI authentication are not implemented")
51+
case 6:
52+
throw PostgresError.protocol("Support for SCM credential authentication is obsolete")
53+
2354
default:
24-
throw PostgresError.protocol("Unkonwn authentication request type: \(type)")
55+
throw PostgresError.protocol("Unknown authentication request type: \(type)")
2556
}
2657
}
2758

@@ -34,6 +65,17 @@ extension PostgresMessage {
3465
case .md5(let salt):
3566
buffer.writeInteger(5, as: Int32.self)
3667
buffer.writeBytes(salt)
68+
case .saslMechanisms(let mechanisms):
69+
buffer.writeInteger(10, as: Int32.self)
70+
mechanisms.forEach {
71+
buffer.write(nullTerminated: $0)
72+
}
73+
case .saslContinue(let challenge):
74+
buffer.writeInteger(11, as: Int32.self)
75+
buffer.writeBytes(challenge)
76+
case .saslFinal(let data):
77+
buffer.writeInteger(12, as: Int32.self)
78+
buffer.writeBytes(data)
3779
}
3880
}
3981

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

94+
/// AuthenticationSASL
95+
/// Specifies the start of SASL mechanism negotiation.
96+
case saslMechanisms([String])
97+
98+
/// AuthenticationSASLContinue
99+
/// Specifies SASL mechanism-specific challenge data.
100+
case saslContinue([UInt8])
101+
102+
/// AuthenticationSASLFinal
103+
/// Specifies mechanism-specific post-authentication client data.
104+
case saslFinal([UInt8])
105+
52106
/// See `CustomStringConvertible`.
53107
public var description: String {
54108
switch self {
55109
case .ok: return "Ok"
56110
case .plaintext: return "CleartextPassword"
57111
case .md5(let salt): return "MD5Password(salt: 0x\(salt.hexdigest()))"
112+
case .saslMechanisms(let mech): return "SASLMechanisms(\(mech))"
113+
case .saslContinue(let data): return "SASLChallenge(\(data))"
114+
case .saslFinal(let data): return "SASLFinal(\(data))"
58115
}
59116
}
60117
}

0 commit comments

Comments
 (0)