Skip to content

Commit ec7a2fc

Browse files
feat: add ssh key authentication support
1 parent 2482bad commit ec7a2fc

File tree

8 files changed

+288
-11
lines changed

8 files changed

+288
-11
lines changed

README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,77 @@ conn := clickhouse.OpenDB(&clickhouse.Options{
308308
})
309309
```
310310

311+
## SSH Authentication (Native Protocol)
312+
313+
ClickHouse-go supports SSH key-based authentication (requires ClickHouse server with SSH auth enabled).
314+
315+
**Options struct:**
316+
```go
317+
conn, err := clickhouse.Open(&clickhouse.Options{
318+
Addr: []string{"127.0.0.1:9000"},
319+
Auth: clickhouse.Auth{
320+
Database: "default",
321+
Username: "default",
322+
},
323+
SSHKeyFile: "/path/to/id_ed25519",
324+
SSHKeyPassphrase: "your_passphrase_if_any",
325+
})
326+
```
327+
328+
**DSN parameters:**
329+
- `ssh_key_file` — path to SSH private key (RSA, ECDSA, Ed25519)
330+
- `ssh_key_passphrase` — passphrase for encrypted key (optional)
331+
332+
Example DSN:
333+
```
334+
clickhouse://default@127.0.0.1:9000/default?ssh_key_file=/path/to/id_ed25519&ssh_key_passphrase=your_passphrase_if_any
335+
```
336+
337+
See [`examples/ssh_auth.go`](examples/ssh_auth.go) for a complete example.
338+
339+
## SSH Key Authentication
340+
341+
ClickHouse SSH key authentication is supported for users configured with SSH keys on the server. You can authenticate using either a file-based SSH private key or an in-memory/custom SSH signer.
342+
343+
### File-based SSH key
344+
345+
```go
346+
conn, err := clickhouse.Open(&clickhouse.Options{
347+
Addr: []string{"127.0.0.1:9000"},
348+
Auth: clickhouse.Auth{
349+
Database: "default",
350+
Username: "default",
351+
},
352+
SSHKeyFile: "/path/to/id_ed25519",
353+
SSHKeyPassphrase: "your_passphrase_if_any",
354+
})
355+
```
356+
357+
Or via DSN:
358+
359+
```
360+
clickhouse://default@127.0.0.1:9000/default?ssh_key_file=/path/to/id_ed25519&ssh_key_passphrase=your_passphrase_if_any
361+
```
362+
363+
### In-memory or custom SSH signer
364+
365+
```go
366+
keyData, err := os.ReadFile("/path/to/id_ed25519")
367+
signer, err := ssh.ParsePrivateKey(keyData)
368+
conn, err := clickhouse.Open(&clickhouse.Options{
369+
Addr: []string{"127.0.0.1:9000"},
370+
Auth: clickhouse.Auth{
371+
Database: "default",
372+
Username: "default",
373+
},
374+
SSHSigner: signer,
375+
})
376+
```
377+
378+
If both `SSHSigner` and `SSHKeyFile` are set, `SSHSigner` takes precedence.
379+
380+
---
381+
311382
## Client info
312383

313384

clickhouse_options.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"time"
3131

3232
"github.com/ClickHouse/ch-go/compress"
33+
"golang.org/x/crypto/ssh"
3334
)
3435

3536
type CompressionMethod byte
@@ -162,6 +163,11 @@ type Options struct {
162163
// Use this instead of Auth.Username and Auth.Password if you're using JWT auth.
163164
GetJWT GetJWTFunc
164165

166+
// SSH authentication.
167+
SSHKeyFile string // Path to SSH private key file (optional)
168+
SSHKeyPassphrase string // Passphrase for SSH key (if encrypted, optional)
169+
SSHSigner ssh.Signer // In-memory or custom SSH signer (takes precedence if set)
170+
165171
scheme string
166172
ReadTimeout time.Duration
167173
}
@@ -327,6 +333,10 @@ func (o *Options) fromDSN(in string) error {
327333
return fmt.Errorf("clickhouse [dsn parse]: http_proxy: %s", err)
328334
}
329335
o.HTTPProxyURL = proxyURL
336+
case "ssh_key_file":
337+
o.SSHKeyFile = params.Get(v)
338+
case "ssh_key_passphrase":
339+
o.SSHKeyPassphrase = params.Get(v)
330340
default:
331341
switch p := strings.ToLower(params.Get(v)); p {
332342
case "true":

conn_handshake.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ package clickhouse
1919

2020
import (
2121
_ "embed"
22+
"encoding/base64"
2223
"fmt"
2324
"time"
2425

26+
chssh "github.com/ClickHouse/ch-go/ssh"
2527
"github.com/ClickHouse/clickhouse-go/v2/lib/proto"
28+
"golang.org/x/crypto/ssh"
2629
)
2730

2831
func (c *connect) handshake(auth Auth) error {
@@ -79,6 +82,63 @@ func (c *connect) handshake(auth Auth) error {
7982
c.debugf("[handshake] downgrade client proto")
8083
}
8184
c.debugf("[handshake] <- %s", c.server)
85+
86+
// Handle SSH authentication if configured
87+
if c.opt.SSHKeyFile != "" {
88+
if err := c.performSSHAuthentication(); err != nil {
89+
return err
90+
}
91+
}
92+
93+
return nil
94+
}
95+
96+
func (c *connect) performSSHAuthentication() error {
97+
var sshKey ssh.Signer
98+
if c.opt.SSHSigner != nil {
99+
sshKey = c.opt.SSHSigner
100+
} else if c.opt.SSHKeyFile != "" {
101+
var err error
102+
sshKey, err = chssh.LoadPrivateKeyFromFile(c.opt.SSHKeyFile, c.opt.SSHKeyPassphrase)
103+
if err != nil {
104+
return fmt.Errorf("failed to load SSH key: %w", err)
105+
}
106+
} else {
107+
return fmt.Errorf("no SSH key provided: set SSHSigner or SSHKeyFile")
108+
}
109+
110+
c.buffer.Reset()
111+
c.buffer.PutByte(proto.ClientSSHChallengeRequest)
112+
if err := c.flush(); err != nil {
113+
return fmt.Errorf("send SSH challenge request: %w", err)
114+
}
115+
116+
packet, err := c.reader.ReadByte()
117+
if err != nil {
118+
return fmt.Errorf("read SSH challenge response: %w", err)
119+
}
120+
if packet != proto.ServerSSHChallenge {
121+
return fmt.Errorf("unexpected packet [%d] from server during SSH authentication", packet)
122+
}
123+
124+
challenge, err := c.reader.Str()
125+
if err != nil {
126+
return fmt.Errorf("read SSH challenge string: %w", err)
127+
}
128+
129+
sig, err := sshKey.Sign(nil, []byte(challenge))
130+
if err != nil {
131+
return fmt.Errorf("sign SSH challenge: %w", err)
132+
}
133+
signature := base64.StdEncoding.EncodeToString(sig.Blob)
134+
135+
c.buffer.Reset()
136+
c.buffer.PutByte(proto.ClientSSHChallengeResponse)
137+
c.buffer.PutString(signature)
138+
if err := c.flush(); err != nil {
139+
return fmt.Errorf("send SSH challenge response: %w", err)
140+
}
141+
82142
return nil
83143
}
84144

conn_handshake_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package clickhouse
2+
3+
import (
4+
"crypto/rand"
5+
"crypto/rsa"
6+
"crypto/x509"
7+
"encoding/pem"
8+
"os"
9+
"testing"
10+
11+
"golang.org/x/crypto/ssh"
12+
)
13+
14+
func TestSSHAuthenticationOptions(t *testing.T) {
15+
t.Run("MissingKeyFile", func(t *testing.T) {
16+
opt := &Options{
17+
SSHKeyFile: "/nonexistent/path/to/key",
18+
}
19+
c := &connect{opt: opt}
20+
err := c.performSSHAuthentication()
21+
if err == nil {
22+
t.Fatal("expected error for missing SSH key file")
23+
}
24+
})
25+
26+
t.Run("InvalidKeyFile", func(t *testing.T) {
27+
f, err := os.CreateTemp("", "invalid_key*")
28+
if err != nil {
29+
t.Fatal(err)
30+
}
31+
defer os.Remove(f.Name())
32+
f.WriteString("not a key")
33+
f.Close()
34+
opt := &Options{
35+
SSHKeyFile: f.Name(),
36+
}
37+
c := &connect{opt: opt}
38+
err = c.performSSHAuthentication()
39+
if err == nil {
40+
t.Fatal("expected error for invalid SSH key file")
41+
}
42+
})
43+
44+
t.Run("WrongPassphrase", func(t *testing.T) {
45+
t.Skip("Needs a real encrypted key for full test")
46+
// Provide a valid encrypted key and wrong passphrase, expect error
47+
})
48+
49+
t.Run("Integration", func(t *testing.T) {
50+
t.Skip("Integration test: requires ClickHouse server with SSH auth enabled and valid key")
51+
// Provide valid key, connect, expect success
52+
})
53+
54+
t.Run("InMemorySSHSigner", func(t *testing.T) {
55+
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
56+
if err != nil {
57+
t.Fatal(err)
58+
}
59+
privateKeyPEM := &pem.Block{
60+
Type: "RSA PRIVATE KEY",
61+
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
62+
}
63+
pemBytes := pem.EncodeToMemory(privateKeyPEM)
64+
signer, err := ssh.ParsePrivateKey(pemBytes)
65+
if err != nil {
66+
t.Fatal(err)
67+
}
68+
opt := &Options{
69+
SSHSigner: signer,
70+
}
71+
c := &connect{opt: opt}
72+
err = c.performSSHAuthentication()
73+
if err == nil {
74+
t.Fatal("expected error for missing server challenge (no connection)")
75+
}
76+
})
77+
}

examples/ssh_auth.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"os"
8+
9+
"github.com/ClickHouse/clickhouse-go/v2"
10+
"golang.org/x/crypto/ssh"
11+
)
12+
13+
func main() {
14+
// File-based SSH key
15+
conn, err := clickhouse.Open(&clickhouse.Options{
16+
Addr: []string{"127.0.0.1:9000"},
17+
Auth: clickhouse.Auth{
18+
Database: "default",
19+
Username: "default",
20+
},
21+
SSHKeyFile: "/path/to/id_ed25519",
22+
SSHKeyPassphrase: "your_passphrase_if_any",
23+
})
24+
if err != nil {
25+
log.Fatalf("failed to open connection: %v", err)
26+
}
27+
if err := conn.Ping(context.Background()); err != nil {
28+
log.Fatalf("failed to ping: %v", err)
29+
}
30+
fmt.Println("SSH authentication succeeded (file-based)")
31+
32+
// In-memory SSH signer
33+
keyData, err := os.ReadFile("/path/to/id_ed25519")
34+
if err != nil {
35+
log.Fatalf("failed to read key: %v", err)
36+
}
37+
signer, err := ssh.ParsePrivateKey(keyData)
38+
if err != nil {
39+
log.Fatalf("failed to parse key: %v", err)
40+
}
41+
conn2, err := clickhouse.Open(&clickhouse.Options{
42+
Addr: []string{"127.0.0.1:9000"},
43+
Auth: clickhouse.Auth{
44+
Database: "default",
45+
Username: "default",
46+
},
47+
SSHSigner: signer,
48+
})
49+
if err != nil {
50+
log.Fatalf("failed to open connection (SSHSigner): %v", err)
51+
}
52+
if err := conn2.Ping(context.Background()); err != nil {
53+
log.Fatalf("failed to ping (SSHSigner): %v", err)
54+
}
55+
fmt.Println("SSH authentication succeeded (SSHSigner)")
56+
}

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ go 1.23.0
55
toolchain go1.24.1
66

77
require (
8-
github.com/ClickHouse/ch-go v0.66.1
8+
github.com/ClickHouse/ch-go v0.67.0
99
github.com/andybalholm/brotli v1.2.0
1010
github.com/docker/docker v28.3.2+incompatible
1111
github.com/docker/go-units v0.5.0
@@ -16,6 +16,7 @@ require (
1616
github.com/stretchr/testify v1.10.0
1717
github.com/testcontainers/testcontainers-go v0.38.0
1818
go.opentelemetry.io/otel/trace v1.37.0
19+
golang.org/x/crypto v0.40.0
1920
golang.org/x/net v0.42.0
2021
gopkg.in/yaml.v3 v3.0.1
2122
)
@@ -73,6 +74,5 @@ require (
7374
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect
7475
go.opentelemetry.io/otel/metric v1.37.0 // indirect
7576
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
76-
golang.org/x/crypto v0.40.0 // indirect
7777
golang.org/x/sys v0.34.0 // indirect
7878
)

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af
44
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
55
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
66
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
7-
github.com/ClickHouse/ch-go v0.66.1 h1:LQHFslfVYZsISOY0dnOYOXGkOUvpv376CCm8g7W74A4=
8-
github.com/ClickHouse/ch-go v0.66.1/go.mod h1:NEYcg3aOFv2EmTJfo4m2WF7sHB/YFbLUuIWv9iq76xY=
7+
github.com/ClickHouse/ch-go v0.67.0 h1:18MQF6vZHj+4/hTRaK7JbS/TIzn4I55wC+QzO24uiqc=
8+
github.com/ClickHouse/ch-go v0.67.0/go.mod h1:2MSAeyVmgt+9a2k2SQPPG1b4qbTPzdGDpf1+bcHh+18=
99
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
1010
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
1111
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=
@@ -173,8 +173,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMey
173173
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
174174
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
175175
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
176-
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
177-
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
176+
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
177+
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
178178
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
179179
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
180180
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=

lib/proto/const.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@ const (
4141
)
4242

4343
const (
44-
ClientHello = 0
45-
ClientQuery = 1
46-
ClientData = 2
47-
ClientCancel = 3
48-
ClientPing = 4
44+
ClientHello = 0
45+
ClientQuery = 1
46+
ClientData = 2
47+
ClientCancel = 3
48+
ClientPing = 4
49+
ClientSSHChallengeRequest = 11
50+
ClientSSHChallengeResponse = 12
4951
)
5052

5153
const (
@@ -80,4 +82,5 @@ const (
8082
ServerReadTaskRequest = 13
8183
ServerProfileEvents = 14
8284
ServerTreeReadTaskRequest = 15
85+
ServerSSHChallenge = 18
8386
)

0 commit comments

Comments
 (0)