bootstrap_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close