1package quic
2
3import (
4 "bytes"
5 "context"
6 "crypto/rand"
7 "crypto/sha256"
8 "encoding/base64"
9 "encoding/binary"
10 "encoding/hex"
11 "encoding/json"
12 "io"
13 "net"
14 "strconv"
15 "sync"
16 "testing"
17 "time"
18
19 "github.com/bnema/vev/internal/protocol/wire"
20 "github.com/stretchr/testify/require"
21)
22
23// dialTestAddr composes the loopback address for the test server's
24// published port, mirroring the dialer's target-host resolution.
25func dialTestAddr(t *testing.T, readiness Readiness) string {
26 t.Helper()
27 return net.JoinHostPort("127.0.0.1", strconv.Itoa(readiness.Port))
28}
29
30// decodeBootstrapFingerprint decodes the readiness pin for raw
31// endpoint dials that bypass the authenticated Dial helper.
32func decodeBootstrapFingerprint(t *testing.T, readiness Readiness) ([]byte, error) {
33 t.Helper()
34 return hex.DecodeString(readiness.Fingerprint)
35}
36
37func TestBootstrapRoundTrip(t *testing.T) {
38 server, readiness, err := NewServer()
39 require.NoError(t, err)
40 defer func() { _ = server.Close() }()
41
42 raw, err := EncodeReadiness(readiness)
43 require.NoError(t, err)
44 parsed, err := ParseReadiness(append(append([]byte(nil), raw...), '\n'))
45 require.NoError(t, err)
46 require.Equal(t, readiness.Token, parsed.Token)
47
48 accepted := make(chan wire.Transport, 1)
49 acceptErr := make(chan error, 1)
50 go func() {
51 transport, err := server.Accept(context.Background())
52 if err != nil {
53 acceptErr <- err
54 return
55 }
56 accepted <- transport
57 }()
58
59 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
60 defer cancel()
61 client, err := Dial(ctx, dialTestAddr(t, readiness), readiness, Config{}, 10*time.Second)
62 require.NoError(t, err)
63 defer func() { _ = client.Close() }()
64
65 select {
66 case transport := <-accepted:
67 defer func() { _ = transport.Close() }()
68 payload := []byte("bootstrap session stays exact")
69 require.NoError(t, client.Send(wire.Envelope{Payload: payload}))
70 got, err := transport.Recv()
71 require.NoError(t, err)
72 require.Equal(t, payload, got.Payload)
73 case err := <-acceptErr:
74 t.Fatalf("bootstrap accept: %v", err)
75 case <-ctx.Done():
76 t.Fatal("bootstrap accept timed out")
77 }
78}
79
80func TestBootstrapTokenSingleUse(t *testing.T) {
81 server, readiness, err := NewServer()
82 require.NoError(t, err)
83 defer func() { _ = server.Close() }()
84
85 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
86 defer cancel()
87 first, err := Dial(ctx, dialTestAddr(t, readiness), readiness, Config{}, 10*time.Second)
88 require.NoError(t, err)
89 defer func() { _ = first.Close() }()
90
91 accepted := make(chan wire.Transport, 1)
92 go func() {
93 transport, err := server.Accept(context.Background())
94 if err == nil {
95 accepted <- transport
96 }
97 }()
98 select {
99 case transport := <-accepted:
100 _ = transport.Close()
101 case <-time.After(10 * time.Second):
102 t.Fatal("first bootstrap accept timed out")
103 }
104
105 // A second dial reuses the consumed token: auth must fail.
106 second, err := Dial(ctx, dialTestAddr(t, readiness), readiness, Config{}, 10*time.Second)
107 if err == nil {
108 _ = second.Close()
109 }
110 secondAccept := make(chan wire.Transport, 1)
111 go func() {
112 transport, err := server.Accept(context.Background())
113 if err == nil {
114 secondAccept <- transport
115 }
116 }()
117 select {
118 case transport := <-secondAccept:
119 _ = transport.Close()
120 t.Fatal("consumed bootstrap token was accepted twice")
121 case <-time.After(5 * time.Second):
122 }
123}
124
125func TestBootstrapWrongTokenFails(t *testing.T) {
126 server, readiness, err := NewServer()
127 require.NoError(t, err)
128 defer func() { _ = server.Close() }()
129
130 // Wrong but well-formed token: decoding succeeds, auth must fail.
131 wrong := make([]byte, bootstrapTokenBytes)
132 for i := range wrong {
133 wrong[i] = 0xA5
134 }
135 readiness.Token = base64.StdEncoding.EncodeToString(wrong)
136 accepted := make(chan wire.Transport, 1)
137 go func() {
138 transport, err := server.Accept(context.Background())
139 if err == nil {
140 accepted <- transport
141 }
142 }()
143 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
144 defer cancel()
145 client, err := Dial(ctx, dialTestAddr(t, readiness), readiness, Config{}, 5*time.Second)
146 if err == nil {
147 _ = client.Close()
148 }
149 select {
150 case transport := <-accepted:
151 _ = transport.Close()
152 t.Fatal("wrong bootstrap token was accepted")
153 case <-time.After(5 * time.Second):
154 }
155 _ = server
156}
157
158func TestBootstrapMalformedReadiness(t *testing.T) {
159 server, readiness, err := NewServer()
160 require.NoError(t, err)
161 defer func() { _ = server.Close() }()
162 raw, err := EncodeReadiness(readiness)
163 require.NoError(t, err)
164
165 mutate := func(apply func(*Readiness)) []byte {
166 cloned := readiness
167 apply(&cloned)
168 mutated, err := json.Marshal(cloned)
169 require.NoError(t, err)
170 return mutated
171 }
172
173 for _, line := range [][]byte{
174 nil,
175 {},
176 []byte("not json"),
177 []byte("{}"),
178 make([]byte, bootstrapMaxReadiness+1),
179 // Wrong schema version.
180 mutate(func(r *Readiness) { r.Version = bootstrapSchemaVersion + 1 }),
181 // Non-port address values.
182 mutate(func(r *Readiness) { r.Port = 0 }),
183 mutate(func(r *Readiness) { r.Port = 65536 }),
184 // Malformed token/nonce encodings.
185 mutate(func(r *Readiness) { r.Token = "00" }),
186 mutate(func(r *Readiness) { r.Nonce = "not-base64!!" }),
187 // Expiry in the past and implausibly far in the future.
188 mutate(func(r *Readiness) { r.ExpiresAt = time.Now().Add(-10 * time.Minute).Unix() }),
189 mutate(func(r *Readiness) { r.ExpiresAt = time.Now().Add(10 * time.Minute).Unix() }),
190 } {
191 _, err := ParseReadiness(line)
192 require.Error(t, err)
193 }
194
195 // The minted record itself parses.
196 _, err = ParseReadiness(append(append([]byte(nil), raw...), '\n'))
197 require.NoError(t, err)
198}
199
200// TestBootstrapConcurrentTokenUse proves the token is consumed exactly
201// once: two simultaneous valid dials admit at most one session.
202func TestBootstrapConcurrentTokenUse(t *testing.T) {
203 server, readiness, err := NewServer()
204 require.NoError(t, err)
205 defer func() { _ = server.Close() }()
206
207 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
208 defer cancel()
209 acceptCtx, stopAccept := context.WithCancel(ctx)
210 defer stopAccept()
211 addr := dialTestAddr(t, readiness)
212 const racers = 2
213 accepted := make(chan wire.Transport, racers)
214 var wg sync.WaitGroup
215 for range racers {
216 wg.Add(1)
217 go func() {
218 defer wg.Done()
219 transport, err := server.Accept(acceptCtx)
220 if err == nil {
221 accepted <- transport
222 }
223 }()
224 }
225 dialed := make(chan wire.Transport, racers)
226 var dialWg sync.WaitGroup
227 for range racers {
228 dialWg.Add(1)
229 go func() {
230 defer dialWg.Done()
231 client, err := Dial(ctx, addr, readiness, Config{}, 10*time.Second)
232 if err == nil {
233 dialed <- client
234 }
235 }()
236 }
237 // The first admitted session stops the other Accept: a consumed
238 // token can never admit again, so waiting for expiry is pointless.
239 first := <-accepted
240 stopAccept()
241 wg.Wait()
242 close(accepted)
243 count := 1
244 _ = first.Close()
245 for transport := range accepted {
246 count++
247 _ = transport.Close()
248 }
249 require.Equal(t, 1, count, "exactly one concurrent token use must succeed")
250 dialWg.Wait()
251 close(dialed)
252 for client := range dialed {
253 _ = client.Close()
254 }
255}
256
257// TestBootstrapOversizedAuthRecordFails proves an unauthenticated peer
258// cannot force a large pre-auth allocation: the bounded auth record is
259// rejected before JSON parsing. The dial bypasses Dial's auth send and
260// emits the oversized record directly on the single stream.
261func TestBootstrapOversizedAuthRecordFails(t *testing.T) {
262 server, readiness, err := NewServer()
263 require.NoError(t, err)
264 defer func() { _ = server.Close() }()
265
266 accepted := make(chan wire.Transport, 1)
267 go func() {
268 transport, err := server.Accept(context.Background())
269 if err == nil {
270 accepted <- transport
271 }
272 }()
273 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
274 defer cancel()
275 fingerprint, err := decodeBootstrapFingerprint(t, readiness)
276 require.NoError(t, err)
277 raw, err := DialConfig(dialTestAddr(t, readiness), "vev-bootstrap", fingerprint, Config{}, 5*time.Second).Dial(ctx)
278 require.NoError(t, err)
279 defer func() { _ = raw.Close() }()
280 over := bytes.Repeat([]byte("x"), bootstrapMaxAuthRecord+1)
281 require.NoError(t, raw.Send(wire.Envelope{Payload: over}))
282 // The server must close the failed auth without admitting a session.
283 select {
284 case transport := <-accepted:
285 _ = transport.Close()
286 t.Fatal("oversized auth record was accepted")
287 case <-time.After(5 * time.Second):
288 }
289}
290
291// newBootstrapTestServer builds a Server with known token material and an
292// explicit admission bound so tests can observe admission holding and
293// expiry internals without waiting on wall-clock bootstrap windows.
294func newBootstrapTestServer(t *testing.T, maxPending int, expiry time.Duration) (*Server, Readiness) {
295 t.Helper()
296 cert, fingerprint, err := GenerateEphemeralCert()
297 require.NoError(t, err)
298 listener, err := ListenConfig("127.0.0.1:0", cert, Config{HandshakeIdleTimeout: bootstrapAuthDeadline}, maxPending)
299 require.NoError(t, err)
300 token := make([]byte, bootstrapTokenBytes)
301 _, err = rand.Read(token)
302 require.NoError(t, err)
303 nonce := make([]byte, bootstrapNonceBytes)
304 _, err = rand.Read(nonce)
305 require.NoError(t, err)
306 tokenVerifier := sha256.Sum256(token)
307 nonceVerifier := sha256.Sum256(nonce)
308 expires := time.Now().Add(expiry)
309 server := &Server{
310 listener: listener,
311 tokenVerifier: tokenVerifier[:],
312 nonceVerifier: nonceVerifier[:],
313 expires: expires,
314 }
315 return server, Readiness{
316 Version: bootstrapSchemaVersion,
317 Port: listenerPort(listener),
318 Token: base64.StdEncoding.EncodeToString(token),
319 Nonce: base64.StdEncoding.EncodeToString(nonce),
320 Fingerprint: hex.EncodeToString(fingerprint),
321 ExpiresAt: expires.Unix(),
322 }
323}
324
325// TestBootstrapListenerIsRemotelyReachable proves the production listener
326// binds the wildcard rather than loopback and serves both address families,
327// so the remote SSH peer that resolved the target host can dial it directly.
328func TestBootstrapListenerIsRemotelyReachable(t *testing.T) {
329 dialWildcard := func(t *testing.T, host string) {
330 t.Helper()
331 server, readiness, err := NewServer()
332 require.NoError(t, err)
333 defer func() { _ = server.Close() }()
334 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
335 defer cancel()
336 accepted := make(chan wire.Transport, 1)
337 acceptErr := make(chan error, 1)
338 go func() {
339 transport, err := server.Accept(ctx)
340 if err != nil {
341 acceptErr <- err
342 return
343 }
344 accepted <- transport
345 }()
346 addr := net.JoinHostPort(host, strconv.Itoa(readiness.Port))
347 client, err := Dial(ctx, addr, readiness, Config{}, 5*time.Second)
348 require.NoError(t, err)
349 defer func() { _ = client.Close() }()
350 select {
351 case transport := <-accepted:
352 _ = transport.Close()
353 case err := <-acceptErr:
354 t.Fatalf("accept over %s: %v", host, err)
355 case <-time.After(10 * time.Second):
356 t.Fatalf("accept over %s timed out", host)
357 }
358 }
359
360 server, _, err := NewServer()
361 require.NoError(t, err)
362 defer func() { _ = server.Close() }()
363 host, _, err := net.SplitHostPort(server.Addr())
364 require.NoError(t, err)
365 ip := net.ParseIP(host)
366 require.NotNil(t, ip, "listener address %q has no IP host", server.Addr())
367 require.True(t, ip.IsUnspecified(), "bootstrap listener must bind the wildcard, got %s", server.Addr())
368 require.False(t, ip.IsLoopback())
369
370 t.Run("ipv4", func(t *testing.T) { dialWildcard(t, "127.0.0.1") })
371 t.Run("ipv6", func(t *testing.T) {
372 if ip.To4() != nil {
373 t.Skip("listener bound IPv4-only")
374 }
375 probe, err := net.Listen("tcp", "[::1]:0")
376 if err != nil {
377 t.Skip("IPv6 loopback unavailable")
378 }
379 _ = probe.Close()
380 dialWildcard(t, "::1")
381 })
382}
383
384// TestBootstrapAdmissionHeldThroughAuthentication proves an unauthenticated
385// connection keeps its admission slot: the bound must count peers parked in
386// authentication, not only connections whose stream contract completed.
387func TestBootstrapAdmissionHeldThroughAuthentication(t *testing.T) {
388 server, readiness := newBootstrapTestServer(t, 1, time.Minute)
389 defer func() { _ = server.Close() }()
390 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
391 defer cancel()
392 acceptDone := make(chan struct{})
393 go func() {
394 defer close(acceptDone)
395 _, _ = server.Accept(ctx)
396 }()
397 fingerprint, err := decodeBootstrapFingerprint(t, readiness)
398 require.NoError(t, err)
399 // The raw dial sends only the dial probe: the connection stays
400 // unauthenticated and must hold the single admission slot.
401 client, err := DialConfig(dialTestAddr(t, readiness), "vev-bootstrap", fingerprint, Config{}, 5*time.Second).Dial(ctx)
402 require.NoError(t, err)
403 defer func() { _ = client.Close() }()
404 require.Eventually(t, func() bool {
405 server.listener.pendingMu.Lock()
406 defer server.listener.pendingMu.Unlock()
407 return server.listener.pending == 1
408 }, 3*time.Second, 10*time.Millisecond, "unauthenticated connection must hold its admission slot")
409 cancel()
410 <-acceptDone
411}
412
413// TestBootstrapOversizedAuthHeaderRejectedBeforePayload proves the 512-byte
414// auth framing limit is enforced on the length prefix: a peer advertising a
415// huge frame and never sending its body is rejected promptly instead of
416// forcing a large pre-auth allocation and stalling until the auth deadline.
417func TestBootstrapOversizedAuthHeaderRejectedBeforePayload(t *testing.T) {
418 server, readiness, err := NewServer()
419 require.NoError(t, err)
420 defer func() { _ = server.Close() }()
421 accepted := make(chan wire.Transport, 1)
422 go func() {
423 transport, err := server.Accept(context.Background())
424 if err == nil {
425 accepted <- transport
426 }
427 }()
428 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
429 defer cancel()
430 fingerprint, err := decodeBootstrapFingerprint(t, readiness)
431 require.NoError(t, err)
432 raw, err := DialConfig(dialTestAddr(t, readiness), "vev-bootstrap", fingerprint, Config{}, 5*time.Second).Dial(ctx)
433 require.NoError(t, err)
434 defer func() { _ = raw.Close() }()
435 // Advertise a 1 MiB auth frame and never send its body: only the
436 // four-byte length prefix exists on the wire.
437 _, err = raw.(*Transport).stream.Write(binary.BigEndian.AppendUint32(nil, 1<<20))
438 require.NoError(t, err)
439 start := time.Now()
440 _, err = raw.Recv()
441 require.Error(t, err)
442 require.Less(t, time.Since(start), 2*time.Second,
443 "oversized auth header must be rejected before the payload is read")
444 select {
445 case transport := <-accepted:
446 _ = transport.Close()
447 t.Fatal("oversized auth header admitted a session")
448 default:
449 }
450}
451
452// TestBootstrapConsumeIsExpiryGuardedAndAtomic proves the verifier
453// comparison, the locked expiry recheck, and one-time token consumption.
454func TestBootstrapConsumeIsExpiryGuardedAndAtomic(t *testing.T) {
455 server, readiness := newBootstrapTestServer(t, 1, time.Minute)
456 defer func() { _ = server.Close() }()
457 token, err := base64.StdEncoding.DecodeString(readiness.Token)
458 require.NoError(t, err)
459 nonce, err := base64.StdEncoding.DecodeString(readiness.Nonce)
460 require.NoError(t, err)
461 record := authRecord{Token: token, Nonce: nonce}
462
463 wrong := append([]byte(nil), token...)
464 wrong[0] ^= 0xFF
465 require.ErrorIs(t, server.consume(authRecord{Token: wrong, Nonce: nonce}), errBootstrapAuth)
466 require.False(t, server.consumed)
467
468 require.NoError(t, server.consume(record))
469 require.True(t, server.consumed)
470 require.ErrorIs(t, server.consume(record), errBootstrapConsumed)
471
472 // Expiry is rechecked under the consumption lock: a valid record that
473 // raced the credential lifetime can never authenticate after expiry.
474 server.consumed = false
475 server.expires = time.Now().Add(-time.Millisecond)
476 require.ErrorIs(t, server.consume(record), errBootstrapExpired)
477 require.False(t, server.consumed)
478}
479
480// TestBootstrapServerStoresVerifierOnly proves the server keeps a digest of
481// the credential rather than the reusable secret itself.
482func TestBootstrapServerStoresVerifierOnly(t *testing.T) {
483 server, readiness, err := NewServer()
484 require.NoError(t, err)
485 defer func() { _ = server.Close() }()
486 token, err := base64.StdEncoding.DecodeString(readiness.Token)
487 require.NoError(t, err)
488 tokenSum := sha256.Sum256(token)
489 require.Equal(t, tokenSum[:], server.tokenVerifier)
490 require.NotEqual(t, token, server.tokenVerifier)
491 nonce, err := base64.StdEncoding.DecodeString(readiness.Nonce)
492 require.NoError(t, err)
493 nonceSum := sha256.Sum256(nonce)
494 require.Equal(t, nonceSum[:], server.nonceVerifier)
495}
496
497// blockingSendTransport is a wire.Transport whose Send blocks until Close,
498// reading the payload in a loop so a caller that erased the record before
499// joining the send goroutine would race the reader.
500type blockingSendTransport struct {
501 closed chan struct{}
502 sendDone chan struct{}
503}
504
505func newBlockingSendTransport() *blockingSendTransport {
506 return &blockingSendTransport{closed: make(chan struct{}), sendDone: make(chan struct{})}
507}
508
509func (b *blockingSendTransport) Send(envelope wire.Envelope) error {
510 defer close(b.sendDone)
511 for {
512 select {
513 case <-b.closed:
514 return io.EOF
515 default:
516 }
517 var seen byte
518 for _, value := range envelope.Payload {
519 seen ^= value
520 }
521 _ = seen
522 time.Sleep(50 * time.Microsecond)
523 }
524}
525
526func (b *blockingSendTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
527
528func (b *blockingSendTransport) Close() error {
529 select {
530 case <-b.closed:
531 default:
532 close(b.closed)
533 }
534 return nil
535}
536
537// TestSendAuthRecordJoinsBlockedSendBeforeErase proves Dial's timeout path
538// closes the transport and joins the blocked send goroutine before returning:
539// the caller's deferred record erase can never race the sender. The join is
540// asserted directly and the payload is erased afterwards, so a missing join is
541// both a test failure and a race-detector report.
542func TestSendAuthRecordJoinsBlockedSendBeforeErase(t *testing.T) {
543 transport := newBlockingSendTransport()
544 record := []byte("one-time-bootstrap-token-and-nonce")
545 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
546 defer cancel()
547
548 err := sendAuthRecord(ctx, transport, record)
549 require.ErrorIs(t, err, errBootstrapAuth)
550 require.ErrorIs(t, err, context.DeadlineExceeded)
551
552 select {
553 case <-transport.sendDone:
554 default:
555 t.Fatal("sendAuthRecord returned while the send goroutine was still running")
556 }
557 zeroBytes(record)
558}