Skip to content

Commit f16dc5e

Browse files
tianzhouclaude
andauthored
Support sslcert/sslkey client certificate authentication for PostgreSQL (#441)
* Support sslcert/sslkey client certificate auth for PostgreSQL PostgreSQL servers that enforce certificate authentication in pg_hba.conf (`hostssl ... cert`, `clientcert=verify-*`) reject DBHub with "connection requires a valid client certificate" because the DSN parser only read sslmode and sslrootcert. Add the standard libpq `sslcert` / `sslkey` parameters, accepted both as DSN query params and as TOML source fields. Connector (src/connectors/postgres/index.ts): - Read sslcert/sslkey and pass the PEM contents to node-postgres as TLS `cert`/`key`, on top of whatever the sslmode branch already built (require keeps rejectUnauthorized=false; verify-* keep CA handling). - Fail fast on an inconsistent DSN: one of the pair without the other, or a client cert with sslmode=disable / unset (node-postgres would silently drop it on a plaintext connection). Encrypted PEM keys are rejected with a clear message instead of Node's opaque decoder error. - Share the ~/ expansion + FailedToReadCertificate wrapping across all three SSL files. TOML loader (src/config/toml-loader.ts): - New sslcert/sslkey fields: PostgreSQL only, both-or-neither, sslmode in require/verify-ca/verify-full, file must exist and be readable. - ~/ expansion, backfill from DSN, DSN/field conflict check, and DSN building all mirror sslrootcert via shared helpers. Tests cover the parser and loader rules, plus a new Testcontainers suite that boots Postgres with `hostssl all all all cert` and verifies a cert connection succeeds (client_dn = CN=user) and a cert-less one is refused. Docs: docs/config/toml.mdx, docs/config/command-line.mdx, dbhub.toml.example, CLAUDE.md. Closes #439 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012UzToxWXzC7oQ9xMnhM192 * Align sslcert/sslkey docs with the require/verify-ca/verify-full restriction Address review: CLAUDE.md and the postgresSslFileParams comment said client certificates apply in "any TLS mode", but disable and an unset sslmode are rejected. Name the three accepted modes explicitly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012UzToxWXzC7oQ9xMnhM192 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6ecb16d commit f16dc5e

11 files changed

Lines changed: 753 additions & 52 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ outside TOML and follow the same order:
176176
- SQL Server (NTLM): `sqlserver://user:password@localhost:1433/dbname?authentication=ntlm&domain=MYDOMAIN`
177177
- Oracle: `oracle://user:password@localhost:1521/FREEPDB1` (path is the service name; `?sid=ORCL` for a SID; `sslmode=require`/`verify-full` switch to TCPS)
178178
- SQLite: `sqlite:///path/to/database.db` or `sqlite:///:memory:`
179-
- SSL modes: `sslmode=disable` (no SSL), `sslmode=require` (SSL without cert verification), `sslmode=verify-ca` (PostgreSQL only, CA verification), `sslmode=verify-full` (PostgreSQL and Oracle, CA + hostname verification). Use `sslrootcert` to specify CA certificate path for verify modes.
179+
- SSL modes: `sslmode=disable` (no SSL), `sslmode=require` (SSL without cert verification), `sslmode=verify-ca` (PostgreSQL only, CA verification), `sslmode=verify-full` (PostgreSQL and Oracle, CA + hostname verification). Use `sslrootcert` to specify CA certificate path for verify modes. PostgreSQL client certificate auth: `sslcert` + `sslkey` (PEM, unencrypted, both required; `sslmode` must be `require`, `verify-ca` or `verify-full` — `disable` or unset is rejected) in the DSN or TOML source.
180180

181181
## Testing Approach
182182

‎dbhub.toml.example‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,20 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
7070
# sslmode = "verify-ca"
7171
# sslrootcert = "~/.ssl/rds-combined-ca-bundle.pem"
7272

73+
# PostgreSQL with client certificate authentication (pg_hba.conf `hostssl ... cert`)
74+
# [[sources]]
75+
# id = "pg_client_cert"
76+
# type = "postgres"
77+
# host = "db.example.internal"
78+
# port = 5432
79+
# database = "app"
80+
# user = "app"
81+
# password = "${PG_PASSWORD}"
82+
# sslmode = "verify-ca"
83+
# sslrootcert = "~/.certs/ca.crt"
84+
# sslcert = "~/.certs/client.crt"
85+
# sslkey = "~/.certs/client.key"
86+
7387
# Production PostgreSQL (behind SSH bastion, lazy connection)
7488
# [[sources]]
7589
# id = "prod_pg"
@@ -411,6 +425,8 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
411425
# sslmode = "verify-ca" # SSL with CA certificate verification (PostgreSQL only)
412426
# sslmode = "verify-full" # SSL with CA + hostname verification (PostgreSQL only)
413427
# sslrootcert = "~/.ssl/ca.pem" # CA certificate path (requires verify-ca or verify-full)
428+
# sslcert = "~/.ssl/client.crt" # PEM client certificate (PostgreSQL only; set with sslkey, needs sslmode require/verify-*)
429+
# sslkey = "~/.ssl/client.key" # Unencrypted PEM private key for sslcert (PostgreSQL only)
414430
#
415431
# SQL Server Authentication:
416432
# authentication = "ntlm" # Windows/NTLM auth (requires domain)

‎docs/config/command-line.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ This page covers command-line flags and environment variables. For TOML configur
254254
|-----------|-----------|-------------|---------|
255255
| `sslmode` | PostgreSQL, MySQL, MariaDB, SQL Server, Oracle | SSL mode: `disable`, `require`, `verify-ca`, `verify-full` | `?sslmode=require` |
256256
| `sslrootcert` | PostgreSQL | CA certificate path (requires `verify-ca` or `verify-full`) | `?sslrootcert=~/.ssl/ca.pem` |
257+
| `sslcert` | PostgreSQL | PEM client certificate for client certificate authentication (set with `sslkey`; requires `require`, `verify-ca` or `verify-full`) | `?sslcert=~/.ssl/client.crt` |
258+
| `sslkey` | PostgreSQL | Unencrypted PEM private key for `sslcert` | `?sslkey=~/.ssl/client.key` |
257259
| `instanceName` | SQL Server | Named instance | `?instanceName=SQLEXPRESS` |
258260
| `authentication` | SQL Server | Auth method: `ntlm`, `azure-active-directory-access-token` | `?authentication=ntlm` |
259261
| `domain` | SQL Server | Windows domain (with `authentication=ntlm`) | `?domain=CORP` |
@@ -280,6 +282,7 @@ This page covers command-line flags and environment variables. For TOML configur
280282
postgres://user:password@localhost:5432/dbname?sslmode=disable
281283
postgres://user:password@localhost:5432/dbname?sslmode=require
282284
postgres://user:password@rds-host:5432/dbname?sslmode=verify-ca&sslrootcert=~/.ssl/rds-ca.pem
285+
postgres://user:password@pg-host:5432/dbname?sslmode=require&sslcert=~/.ssl/client.crt&sslkey=~/.ssl/client.key
283286
sqlserver://jsmith:secret@localhost:1433/mydb?authentication=ntlm&domain=CORP
284287
oracle://app:secret@db.example.com:2484/PROD?sslmode=verify-full
285288
```

‎docs/config/toml.mdx‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,46 @@ Sources define database connections. Each source represents a database that DBHu
419419
</Tip>
420420
</ParamField>
421421

422+
### sslcert / sslkey
423+
424+
<ParamField path="sslcert" type="string">
425+
Path to a PEM client certificate for **client certificate authentication**. Use when the server enforces `hostssl ... cert` or `clientcert=verify-ca` / `verify-full` in `pg_hba.conf` and rejects connections with `connection requires a valid client certificate`.
426+
427+
Must be set together with `sslkey`, and requires `sslmode` to be `require`, `verify-ca` or `verify-full` (the certificate is only presented over TLS). It is independent of `sslrootcert`: `require` + `sslcert`/`sslkey` authenticates the client without verifying the server.
428+
429+
Supports `~/` path expansion.
430+
431+
**PostgreSQL only.**
432+
</ParamField>
433+
434+
<ParamField path="sslkey" type="string">
435+
Path to the PEM private key for `sslcert`. The key must be **unencrypted** (an encrypted key is rejected at startup; `sslpassword` is not supported). DER-encoded keys are not accepted.
436+
437+
Keep the key file private (`chmod 0600`); DBHub reads it once at startup and never logs its contents.
438+
439+
Supports `~/` path expansion.
440+
441+
**PostgreSQL only.**
442+
443+
```toml
444+
[[sources]]
445+
id = "dev"
446+
type = "postgres"
447+
host = "db.example.internal"
448+
database = "app"
449+
user = "app"
450+
password = "${PG_PASSWORD}"
451+
sslmode = "verify-ca"
452+
sslrootcert = "~/.certs/ca.crt"
453+
sslcert = "~/.certs/client.crt"
454+
sslkey = "~/.certs/client.key"
455+
```
456+
457+
<Tip>
458+
Can also be set via DSN query parameters: `?sslmode=require&sslcert=/path/to/client.crt&sslkey=/path/to/client.key`
459+
</Tip>
460+
</ParamField>
461+
422462
### instanceName
423463

424464
<ParamField path="instanceName" type="string">
@@ -856,6 +896,8 @@ default = 10
856896
| `pool_max_connections` | integer | ❌ | PostgreSQL pool size per process and source (default: `10`, range: `1`-`1000`) |
857897
| `sslmode` | string | ❌ | SSL mode: `disable`, `require`, `verify-ca`, `verify-full` |
858898
| `sslrootcert` | string | ❌ | CA certificate path (PostgreSQL only, requires `verify-ca` or `verify-full`) |
899+
| `sslcert` | string | ❌ | PEM client certificate path for client certificate authentication (PostgreSQL only, requires `sslkey` and TLS enabled) |
900+
| `sslkey` | string | ❌ | Unencrypted PEM private key path for `sslcert` (PostgreSQL only) |
859901
| `instanceName` | string | ❌ | SQL Server named instance |
860902
| `authentication` | string | ❌ | SQL Server auth method |
861903
| `domain` | string | ❌ | Windows domain (NTLM) |

‎src/config/__tests__/toml-loader.test.ts‎

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,190 @@ sslrootcert = "/nonexistent/ca.pem"
871871
});
872872
});
873873

874+
describe('client certificate (sslcert/sslkey) validation', () => {
875+
let certPath: string;
876+
let keyPath: string;
877+
878+
beforeEach(() => {
879+
certPath = path.join(tempDir, 'client.crt');
880+
keyPath = path.join(tempDir, 'client.key');
881+
fs.writeFileSync(certPath, 'cert-content');
882+
fs.writeFileSync(keyPath, 'key-content');
883+
});
884+
885+
const writeSource = (extra: string, type = 'postgres'): void => {
886+
fs.writeFileSync(
887+
path.join(tempDir, 'dbhub.toml'),
888+
`
889+
[[sources]]
890+
id = "test_db"
891+
type = "${type}"
892+
host = "localhost"
893+
database = "testdb"
894+
user = "user"
895+
password = "pass"
896+
${extra}
897+
`
898+
);
899+
};
900+
901+
it.each(['require', 'verify-ca', 'verify-full'])(
902+
'should accept sslcert + sslkey with sslmode = "%s"',
903+
(sslmode) => {
904+
writeSource(`sslmode = "${sslmode}"\nsslcert = '${certPath}'\nsslkey = '${keyPath}'`);
905+
906+
const result = loadTomlConfig();
907+
908+
expect(result?.sources[0].sslcert).toBe(certPath);
909+
expect(result?.sources[0].sslkey).toBe(keyPath);
910+
expect(buildDSNFromSource(result!.sources[0])).toBe(
911+
`postgres://user:pass@localhost:5432/testdb?sslmode=${sslmode}` +
912+
`&sslcert=${encodeURIComponent(certPath)}&sslkey=${encodeURIComponent(keyPath)}`
913+
);
914+
}
915+
);
916+
917+
it('should accept sslcert + sslkey together with sslrootcert', () => {
918+
const caPath = path.join(tempDir, 'ca.pem');
919+
fs.writeFileSync(caPath, 'ca-content');
920+
writeSource(
921+
`sslmode = "verify-full"\nsslrootcert = '${caPath}'\nsslcert = '${certPath}'\nsslkey = '${keyPath}'`
922+
);
923+
924+
const result = loadTomlConfig();
925+
926+
expect(buildDSNFromSource(result!.sources[0])).toBe(
927+
`postgres://user:pass@localhost:5432/testdb?sslmode=verify-full` +
928+
`&sslrootcert=${encodeURIComponent(caPath)}` +
929+
`&sslcert=${encodeURIComponent(certPath)}&sslkey=${encodeURIComponent(keyPath)}`
930+
);
931+
});
932+
933+
// expandHomeDir binds os.homedir at import time, so a spy cannot redirect
934+
// it; write the fixtures under the real home directory instead.
935+
const withHomeCerts = (fn: (relDir: string, homeCert: string, homeKey: string) => void): void => {
936+
const relDir = `.dbhub-test-${process.pid}-${Date.now()}`;
937+
const absDir = path.join(os.homedir(), relDir);
938+
fs.mkdirSync(absDir, { recursive: true });
939+
try {
940+
fs.writeFileSync(path.join(absDir, 'client.crt'), 'cert');
941+
fs.writeFileSync(path.join(absDir, 'client.key'), 'key');
942+
fn(relDir, path.join(absDir, 'client.crt'), path.join(absDir, 'client.key'));
943+
} finally {
944+
fs.rmSync(absDir, { recursive: true, force: true });
945+
}
946+
};
947+
948+
it('should expand ~ in sslcert and sslkey', () => {
949+
withHomeCerts((relDir, homeCert, homeKey) => {
950+
writeSource(`sslmode = "require"\nsslcert = "~/${relDir}/client.crt"\nsslkey = "~/${relDir}/client.key"`);
951+
952+
const result = loadTomlConfig();
953+
954+
expect(result?.sources[0].sslcert).toBe(homeCert);
955+
expect(result?.sources[0].sslkey).toBe(homeKey);
956+
});
957+
});
958+
959+
it('should reject sslcert/sslkey for non-PostgreSQL sources', () => {
960+
writeSource(`sslmode = "require"\nsslcert = '${certPath}'\nsslkey = '${keyPath}'`, 'mysql');
961+
962+
expect(() => loadTomlConfig()).toThrow('sslcert/sslkey but they are only supported for PostgreSQL');
963+
});
964+
965+
it('should reject sslcert without sslkey', () => {
966+
writeSource(`sslmode = "require"\nsslcert = '${certPath}'`);
967+
968+
expect(() => loadTomlConfig()).toThrow('has sslcert without sslkey');
969+
});
970+
971+
it('should reject sslkey without sslcert', () => {
972+
writeSource(`sslmode = "require"\nsslkey = '${keyPath}'`);
973+
974+
expect(() => loadTomlConfig()).toThrow('has sslkey without sslcert');
975+
});
976+
977+
it('should reject sslcert/sslkey when sslmode = "disable"', () => {
978+
writeSource(`sslmode = "disable"\nsslcert = '${certPath}'\nsslkey = '${keyPath}'`);
979+
980+
expect(() => loadTomlConfig()).toThrow(
981+
"sslcert/sslkey but sslmode is 'disable'. sslcert/sslkey require sslmode 'require', 'verify-ca' or 'verify-full'"
982+
);
983+
});
984+
985+
it('should reject sslcert/sslkey when sslmode is not set', () => {
986+
writeSource(`sslcert = '${certPath}'\nsslkey = '${keyPath}'`);
987+
988+
expect(() => loadTomlConfig()).toThrow("sslcert/sslkey but sslmode is 'not set'");
989+
});
990+
991+
it('should reject sslkey when the file does not exist', () => {
992+
writeSource(`sslmode = "require"\nsslcert = '${certPath}'\nsslkey = "/nonexistent/client.key"`);
993+
994+
expect(() => loadTomlConfig()).toThrow(
995+
"sslkey file not found or not accessible: '/nonexistent/client.key'"
996+
);
997+
});
998+
999+
it('should reject sslcert when the path is a directory', () => {
1000+
writeSource(`sslmode = "require"\nsslcert = '${tempDir}'\nsslkey = '${keyPath}'`);
1001+
1002+
expect(() => loadTomlConfig()).toThrow(`sslcert path is not a regular file: '${tempDir}'`);
1003+
});
1004+
1005+
it('should populate sslcert and sslkey fields from DSN query parameters', () => {
1006+
fs.writeFileSync(
1007+
path.join(tempDir, 'dbhub.toml'),
1008+
`
1009+
[[sources]]
1010+
id = "test_db"
1011+
dsn = "postgres://user:pass@localhost:5432/db?sslmode=require&sslcert=${encodeURIComponent(certPath)}&sslkey=${encodeURIComponent(keyPath)}"
1012+
`
1013+
);
1014+
1015+
const result = loadTomlConfig();
1016+
1017+
expect(result?.sources[0].sslcert).toBe(certPath);
1018+
expect(result?.sources[0].sslkey).toBe(keyPath);
1019+
});
1020+
1021+
it('should reject a sslcert field that conflicts with the DSN', () => {
1022+
const otherCert = path.join(tempDir, 'other.crt');
1023+
fs.writeFileSync(otherCert, 'other');
1024+
fs.writeFileSync(
1025+
path.join(tempDir, 'dbhub.toml'),
1026+
`
1027+
[[sources]]
1028+
id = "test_db"
1029+
dsn = "postgres://user:pass@localhost:5432/db?sslmode=require&sslcert=${encodeURIComponent(otherCert)}&sslkey=${encodeURIComponent(keyPath)}"
1030+
sslcert = '${certPath}'
1031+
sslkey = '${keyPath}'
1032+
`
1033+
);
1034+
1035+
expect(() => loadTomlConfig()).toThrow('conflicting sslcert');
1036+
});
1037+
1038+
it('should accept a sslkey field that matches the DSN after ~ expansion', () => {
1039+
withHomeCerts((relDir, homeCert, homeKey) => {
1040+
fs.writeFileSync(
1041+
path.join(tempDir, 'dbhub.toml'),
1042+
`
1043+
[[sources]]
1044+
id = "test_db"
1045+
dsn = "postgres://user:pass@localhost:5432/db?sslmode=require&sslcert=${encodeURIComponent(homeCert)}&sslkey=${encodeURIComponent(homeKey)}"
1046+
sslcert = "~/${relDir}/client.crt"
1047+
sslkey = "~/${relDir}/client.key"
1048+
`
1049+
);
1050+
1051+
const result = loadTomlConfig();
1052+
1053+
expect(result?.sources[0].sslkey).toBe(homeKey);
1054+
});
1055+
});
1056+
});
1057+
8741058
describe('SQL Server authentication validation', () => {
8751059
it('should accept authentication = "ntlm" with domain', () => {
8761060
const tomlContent = `
@@ -1613,6 +1797,51 @@ collation = "utf8mb4_0900_ai_ci"
16131797
);
16141798
});
16151799

1800+
it('should merge sslcert and sslkey fields into a postgres DSN for require', () => {
1801+
const source: SourceConfig = {
1802+
id: 'test',
1803+
type: 'postgres',
1804+
dsn: 'postgres://user:pass@localhost:5432/db',
1805+
sslmode: 'require',
1806+
sslcert: '/etc/ssl/client cert.crt',
1807+
sslkey: '/etc/ssl/client.key',
1808+
};
1809+
1810+
const dsn = buildDSNFromSource(source);
1811+
1812+
expect(dsn).toBe(
1813+
'postgres://user:pass@localhost:5432/db?sslmode=require&sslcert=' +
1814+
encodeURIComponent('/etc/ssl/client cert.crt') +
1815+
'&sslkey=' +
1816+
encodeURIComponent('/etc/ssl/client.key')
1817+
);
1818+
});
1819+
1820+
it('should not duplicate sslcert/sslkey already present in the DSN', () => {
1821+
const source: SourceConfig = {
1822+
id: 'test',
1823+
type: 'postgres',
1824+
dsn: 'postgres://user:pass@localhost:5432/db?sslmode=require&sslcert=%2Fc.crt&sslkey=%2Fc.key',
1825+
sslcert: '/c.crt',
1826+
sslkey: '/c.key',
1827+
};
1828+
1829+
expect(buildDSNFromSource(source)).toBe(source.dsn);
1830+
});
1831+
1832+
it('should not merge sslcert/sslkey when sslmode is disable', () => {
1833+
const source: SourceConfig = {
1834+
id: 'test',
1835+
type: 'postgres',
1836+
dsn: 'postgres://user:pass@localhost:5432/db',
1837+
sslmode: 'disable',
1838+
sslcert: '/c.crt',
1839+
sslkey: '/c.key',
1840+
};
1841+
1842+
expect(buildDSNFromSource(source)).toBe('postgres://user:pass@localhost:5432/db?sslmode=disable');
1843+
});
1844+
16161845
it('should not merge sslrootcert when sslmode is not a verify mode', () => {
16171846
const source: SourceConfig = {
16181847
id: 'test',

0 commit comments

Comments
 (0)