-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmtls_e2e_test.go
More file actions
354 lines (290 loc) · 10.1 KB
/
mtls_e2e_test.go
File metadata and controls
354 lines (290 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"io"
"math/big"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"testing"
"time"
fiber "github.com/gofiber/fiber/v3"
"github.com/hyp3rd/hypercache"
"github.com/hyp3rd/hypercache/internal/constants"
"github.com/hyp3rd/hypercache/pkg/backend"
"github.com/hyp3rd/hypercache/pkg/httpauth"
)
// signedCert is one cert + matching private key, plus the DER bytes
// the CA pool needs to verify it. Returned by issueCert below so the
// E2E test can wire the same data into both PEM files (the binary
// reads them off disk) and the http.Client (the request side).
type signedCert struct {
cert *x509.Certificate
der []byte
key *ecdsa.PrivateKey
pemCert []byte
pemKey []byte
}
// issueCert produces a self-signed CA-or-leaf cert. When signer is
// nil the resulting cert self-signs (used for the CA root). When
// signer is non-nil the CA signs the leaf — that's how the client
// and server certs get a verifiable chain back to the test CA.
func issueCert(t *testing.T, cn string, isCA bool, signer *signedCert, sans []string) *signedCert {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key for %s: %v", cn, err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
}
if isCA {
template.IsCA = true
template.KeyUsage |= x509.KeyUsageCertSign
template.BasicConstraintsValid = true
}
for _, san := range sans {
if ip := net.ParseIP(san); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else {
template.DNSNames = append(template.DNSNames, san)
}
}
parent := template
parentKey := any(key)
if signer != nil {
parent = signer.cert
parentKey = signer.key
}
der, err := x509.CreateCertificate(rand.Reader, template, parent, &key.PublicKey, parentKey)
if err != nil {
t.Fatalf("create cert %s: %v", cn, err)
}
parsed, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("parse cert %s: %v", cn, err)
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
return &signedCert{
cert: parsed,
der: der,
key: key,
pemCert: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
pemKey: pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}),
}
}
// writePEM persists pem-encoded bytes to a tempfile and returns the
// path; sole purpose is feeding the binary's env-var-driven file
// paths.
func writePEM(t *testing.T, dir, name string, data []byte) string {
t.Helper()
path := filepath.Join(dir, name)
err := os.WriteFile(path, data, 0o600)
if err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}
// TestMTLS_E2E_ClientCertResolvesIdentity is the integration test
// that proves the full mTLS path is wired correctly: a real TLS
// handshake against the configured envConfig, with a client
// presenting a cert whose Subject CN matches a configured
// CertIdentity. The auth middleware must resolve the cert into
// an Identity with the right scopes and let the request through
// to the handler.
//
// Without this test, every individual unit test passes but the
// composition (env-vars → tls.Config → fiber listener →
// Policy.resolveCert) could be silently broken.
//
// Cannot t.Parallel() — binds to a fresh ephemeral port and
// shares the test-process server until t.Cleanup; sequential
// execution avoids any port-reuse / goroutine-leak surprises in
// fiber's listener teardown.
//
//nolint:paralleltest // intentional: real listener owned by this test
func TestMTLS_E2E_ClientCertResolvesIdentity(t *testing.T) {
if testing.Short() {
t.Skip("E2E mTLS test starts a real listener; skip with -short")
}
dir := t.TempDir()
ca := issueCert(t, "test-ca", true, nil, nil)
server := issueCert(t, "test-server", false, ca, []string{"127.0.0.1"})
client := issueCert(t, "test-client", false, ca, nil)
caPath := writePEM(t, dir, "ca.pem", ca.pemCert)
serverCertPath := writePEM(t, dir, "server.crt", server.pemCert)
serverKeyPath := writePEM(t, dir, "server.key", server.pemKey)
hc := newE2ECacheNode(t)
cfg := envConfig{
APIAddr: "127.0.0.1:0", // ephemeral port; real bind below
NodeID: "mtls-test-node",
APITLSCert: serverCertPath,
APITLSKey: serverKeyPath,
APITLSCA: caPath,
AuthPolicy: httpauth.Policy{
CertIdentities: []httpauth.CertIdentity{
{SubjectCN: "test-client", Scopes: []httpauth.Scope{httpauth.ScopeRead}},
},
},
}
addr, app := startTLSServer(t, cfg, hc)
clientTLS := buildClientTLS(t, ca.cert, client.der, client.key)
target := "https://" + addr + "/v1/owners/k"
//nolint:paralleltest // subtests share the parent's listener
t.Run("client cert with matching CN → 200", func(_ *testing.T) {
status := doMTLSRequest(t, target, clientTLS)
if status != http.StatusOK {
t.Fatalf("got status %d, want 200", status)
}
})
//nolint:paralleltest // subtests share the parent's listener
t.Run("client cert without matching CN → 401", func(_ *testing.T) {
// Issue a fresh client cert with a CN that is NOT in
// CertIdentities; the policy should reject it (cert is
// validly signed by the CA, but the CN does not map to
// any configured identity).
stranger := issueCert(t, "stranger", false, ca, nil)
strangerTLS := buildClientTLS(t, ca.cert, stranger.der, stranger.key)
status := doMTLSRequest(t, target, strangerTLS)
if status != http.StatusUnauthorized {
t.Fatalf("got status %d, want 401", status)
}
})
t.Cleanup(func() { _ = app.ShutdownWithContext(context.Background()) })
}
// newE2ECacheNode spins up a single-replica DistMemory hypercache
// for the E2E test, returns it bound to t.Cleanup. Replication=1
// avoids the wait-for-quorum dance — we only care about the auth
// middleware, not the cache semantics.
func newE2ECacheNode(t *testing.T) *hypercache.HyperCache[backend.DistMemory] {
t.Helper()
cfg, err := hypercache.NewConfig[backend.DistMemory](constants.DistMemoryBackend)
if err != nil {
t.Fatalf("new config: %v", err)
}
cfg.DistMemoryOptions = []backend.DistMemoryOption{
backend.WithDistNode("mtls-test-node", "127.0.0.1:0"),
backend.WithDistReplication(1),
}
hc, err := hypercache.New(t.Context(), hypercache.GetDefaultManager(), cfg)
if err != nil {
t.Fatalf("new hypercache: %v", err)
}
t.Cleanup(func() { _ = hc.Stop(context.Background()) })
return hc
}
// startTLSServer constructs the TLS config and binds to a real
// 127.0.0.1 port, hands the listener to fiber, and returns the
// resolved address. We bind ourselves rather than going through
// runClientAPI because runClientAPI binds inside a goroutine and
// the test needs the resolved port up-front.
func startTLSServer(t *testing.T, cfg envConfig, hc *hypercache.HyperCache[backend.DistMemory]) (string, *fiber.App) {
t.Helper()
tlsCfg, err := buildAPITLSConfig(cfg)
if err != nil {
t.Fatalf("build TLS config: %v", err)
}
if tlsCfg == nil {
t.Fatalf("expected non-nil TLS config")
}
ln, err := tls.Listen("tcp", cfg.APIAddr, tlsCfg)
if err != nil {
t.Fatalf("tls listen: %v", err)
}
app := fiber.New()
registerClientRoutes(app, cfg.AuthPolicy, &nodeContext{hc: hc, nodeID: cfg.NodeID})
go func() {
err := app.Listener(ln)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
t.Logf("listener exited: %v", err)
}
}()
// Wait briefly for fiber to start serving — without this the
// first request races the listener init and gets ECONNREFUSED.
// Use Dialer.DialContext rather than tls.Dial so the readiness
// probe inherits the test ctx (timeout/cancel propagate cleanly).
addr := ln.Addr().String()
awaitTLSReady(t, addr)
return addr, app
}
// awaitTLSReady polls the listener until it accepts a TLS dial,
// or the deadline expires. The skip-verify is intentional: this
// is a startup-readiness probe, not a real client. Cert validation
// happens on the actual test request a few lines later.
func awaitTLSReady(t *testing.T, addr string) {
t.Helper()
dialer := &tls.Dialer{
Config: &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // dev-mode readiness probe; not a trust boundary
MinVersion: tls.VersionTLS12,
},
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
conn, dialErr := dialer.DialContext(t.Context(), "tcp", addr)
if dialErr == nil {
_ = conn.Close()
return
}
time.Sleep(20 * time.Millisecond)
}
}
// buildClientTLS assembles the per-test http.Client TLS config:
// trust the test CA, present the test client's cert.
func buildClientTLS(t *testing.T, caCert *x509.Certificate, clientDER []byte, clientKey *ecdsa.PrivateKey) *tls.Config {
t.Helper()
pool := x509.NewCertPool()
pool.AddCert(caCert)
return &tls.Config{
RootCAs: pool,
Certificates: []tls.Certificate{{
Certificate: [][]byte{clientDER},
PrivateKey: clientKey,
}},
MinVersion: tls.VersionTLS12,
ServerName: "127.0.0.1",
}
}
// doMTLSRequest issues a GET against url with the supplied client
// TLS config and returns the status code. Body is drained and
// discarded — we only assert auth resolution, not handler logic.
func doMTLSRequest(t *testing.T, target string, tlsCfg *tls.Config) int {
t.Helper()
parsed, err := url.Parse(target)
if err != nil {
t.Fatalf("parse url: %v", err)
}
c := &http.Client{
Transport: &http.Transport{TLSClientConfig: tlsCfg},
Timeout: 3 * time.Second,
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, parsed.String(), http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err := c.Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode
}