Quellcode durchsuchen

Merge pull request #8 from dolonet/fix/toml-section-order

fix: move [secrets] after global keys in config examples
pull/434/head
dolonet vor 1 Monat
Ursprung
Commit
ab39fbb441
Es ist kein Account mit der E-Mail-Adresse des Committers verbunden
5 geänderte Dateien mit 411 neuen und 28 gelöschten Zeilen
  1. 1
    1
      Dockerfile
  2. 6
    4
      README.md
  3. 10
    7
      example.config.toml
  4. 122
    16
      mtglib/internal/tls/fake/client_side.go
  5. 272
    0
      mtglib/internal/tls/fake/client_side_test.go

+ 1
- 1
Dockerfile Datei anzeigen

@@ -33,7 +33,7 @@ RUN go mod download
33 33
 COPY . /app
34 34
 
35 35
 RUN set -x \
36
-  && version="$(git describe --exact-match HEAD 2>/dev/null || git describe --tags --always)" \
36
+  && version="$(git describe --exact-match HEAD 2>/dev/null || git describe --tags --always 2>/dev/null || echo dev)" \
37 37
   && go build \
38 38
       -trimpath \
39 39
       -mod=readonly \

+ 6
- 4
README.md Datei anzeigen

@@ -71,12 +71,13 @@ Minimal config:
71 71
 
72 72
 ```toml
73 73
 bind-to = "0.0.0.0:443"
74
+api-bind-to = "127.0.0.1:9090"
74 75
 
76
+# [secrets] must be the last section in the global scope —
77
+# in TOML, all keys after a [section] become part of that table.
75 78
 [secrets]
76 79
 alice = "ee..."
77 80
 bob   = "ee..."
78
-
79
-api-bind-to = "127.0.0.1:9090"
80 81
 ```
81 82
 
82 83
 Run:
@@ -154,12 +155,13 @@ mtg-multi generate-secret --hex storage.googleapis.com
154 155
 
155 156
 ```toml
156 157
 bind-to = "0.0.0.0:443"
158
+api-bind-to = "127.0.0.1:9090"
157 159
 
160
+# [secrets] должен быть последней секцией в глобальном scope —
161
+# в TOML все ключи после [section] становятся частью этой таблицы.
158 162
 [secrets]
159 163
 alice = "ee..."
160 164
 bob   = "ee..."
161
-
162
-api-bind-to = "127.0.0.1:9090"
163 165
 ```
164 166
 
165 167
 Запуск:

+ 10
- 7
example.config.toml Datei anzeigen

@@ -20,13 +20,6 @@ debug = true
20 20
 # should either be base64-encoded or starts with ee.
21 21
 secret = "ee367a189aee18fa31c190054efd4a8e9573746f726167652e676f6f676c65617069732e636f6d"
22 22
 
23
-# For multi-user support, use the [secrets] section instead of "secret".
24
-# Each key is a user name, used for per-user stats tracking.
25
-# All secrets must use the same hostname.
26
-# [secrets]
27
-# alice = "ee367a189aee18fa31c190054efd4a8e9573746f726167652e676f6f676c65617069732e636f6d"
28
-# bob = "ee0123456789abcdef0123456789abcd9573746f726167652e676f6f676c65617069732e636f6d"
29
-
30 23
 # Host:port pair to bind the built-in stats HTTP API server.
31 24
 # GET /stats returns per-user connection counts and traffic.
32 25
 # If not set, the stats API is not started.
@@ -118,6 +111,16 @@ tolerate-time-skewness = "5s"
118 111
 # Otherwise, chose a new DC.
119 112
 allow-fallback-on-unknown-dc = false
120 113
 
114
+# For multi-user support, use the [secrets] section instead of "secret".
115
+# Each key is a user name, used for per-user stats tracking.
116
+# All secrets must use the same hostname.
117
+#
118
+# IMPORTANT: [secrets] must appear after all global keys (like bind-to,
119
+# api-bind-to) — in TOML, keys after a [section] belong to that table.
120
+# [secrets]
121
+# alice = "ee367a189aee18fa31c190054efd4a8e9573746f726167652e676f6f676c65617069732e636f6d"
122
+# bob = "ee0123456789abcdef0123456789abcd9573746f726167652e676f6f676c65617069732e636f6d"
123
+
121 124
 # This section is relevant to communication with fronting domain. Usually
122 125
 # you do not need to setup anything here but there are plenty of cases, especially
123 126
 # if you put mtg behind load balancer, when some specific configuration is

+ 122
- 16
mtglib/internal/tls/fake/client_side.go Datei anzeigen

@@ -6,6 +6,7 @@ import (
6 6
 	"crypto/sha256"
7 7
 	"crypto/subtle"
8 8
 	"encoding/binary"
9
+	"errors"
9 10
 	"fmt"
10 11
 	"io"
11 12
 	"net"
@@ -23,6 +24,11 @@ const (
23 24
 	RandomOffset = 1 + 2 + 2 + 1 + 3 + 2
24 25
 
25 26
 	sniDNSNamesListType = 0
27
+
28
+	// maxContinuationRecords limits the number of continuation TLS records
29
+	// that reassembleTLSHandshake will read. This prevents resource exhaustion
30
+	// from adversarial fragmentation.
31
+	maxContinuationRecords = 10
26 32
 )
27 33
 
28 34
 var (
@@ -71,12 +77,18 @@ func ReadClientHelloMulti(
71 77
 	}
72 78
 	defer conn.SetReadDeadline(resetDeadline) //nolint: errcheck
73 79
 
80
+	reassembled, err := reassembleTLSHandshake(conn)
81
+	if err != nil {
82
+		return nil, fmt.Errorf("cannot reassemble TLS records: %w", err)
83
+	}
84
+
74 85
 	handshakeCopyBuf := &bytes.Buffer{}
75
-	reader := io.TeeReader(conn, handshakeCopyBuf)
86
+	reader := io.TeeReader(reassembled, handshakeCopyBuf)
76 87
 
77
-	reader, err := parseTLSHeader(reader)
78
-	if err != nil {
79
-		return nil, fmt.Errorf("cannot parse tls header: %w", err)
88
+	// Skip the TLS record header (validated during reassembly).
89
+	// The header still flows through TeeReader into handshakeCopyBuf for HMAC.
90
+	if _, err = io.CopyN(io.Discard, reader, tls.SizeHeader); err != nil {
91
+		return nil, fmt.Errorf("cannot skip tls header: %w", err)
80 92
 	}
81 93
 
82 94
 	reader, err = parseHandshakeHeader(reader)
@@ -135,17 +147,30 @@ func ReadClientHelloMulti(
135 147
 	return nil, ErrBadDigest
136 148
 }
137 149
 
138
-func parseTLSHeader(r io.Reader) (io.Reader, error) {
139
-	// record_type(1) + version(2) + size(2)
140
-	//   16 - type is 0x16 (handshake record)
141
-	//   03 01 - protocol version is "3,1" (also known as TLS 1.0)
142
-	//   00 f8 - 0xF8 (248) bytes of handshake message follows
143
-	header := [1 + 2 + 2]byte{}
144
-
145
-	if _, err := io.ReadFull(r, header[:]); err != nil {
150
+// reassembleTLSHandshake reads one or more TLS records from conn,
151
+// validates the record type and version, and reassembles fragmented
152
+// handshake payloads into a single TLS record.
153
+//
154
+// Per RFC 5246 Section 6.2.1, handshake messages may be fragmented
155
+// across multiple TLS records. DPI bypass tools like ByeDPI use this
156
+// to evade censorship.
157
+//
158
+// The returned buffer contains the full TLS record (header + payload)
159
+// so that callers can include the header in HMAC computation.
160
+func reassembleTLSHandshake(conn io.Reader) (*bytes.Buffer, error) {
161
+	header := [tls.SizeHeader]byte{}
162
+
163
+	if _, err := io.ReadFull(conn, header[:]); err != nil {
146 164
 		return nil, fmt.Errorf("cannot read record header: %w", err)
147 165
 	}
148 166
 
167
+	length := int64(binary.BigEndian.Uint16(header[3:]))
168
+	payload := &bytes.Buffer{}
169
+
170
+	if _, err := io.CopyN(payload, conn, length); err != nil {
171
+		return nil, fmt.Errorf("cannot read record payload: %w", err)
172
+	}
173
+
149 174
 	if header[0] != tls.TypeHandshake {
150 175
 		return nil, fmt.Errorf("unexpected record type %#x", header[0])
151 176
 	}
@@ -154,12 +179,93 @@ func parseTLSHeader(r io.Reader) (io.Reader, error) {
154 179
 		return nil, fmt.Errorf("unexpected protocol version %#x %#x", header[1], header[2])
155 180
 	}
156 181
 
157
-	length := int64(binary.BigEndian.Uint16(header[3:]))
158
-	buf := &bytes.Buffer{}
182
+	// Reassemble fragmented payload. continuationCount caps the total
183
+	// number of continuation records across both phases below.
184
+	continuationCount := 0
159 185
 
160
-	_, err := io.CopyN(buf, r, length)
186
+	// Phase 1: read continuation records until we have at least the
187
+	// 4-byte handshake header (type + uint24 length) to determine the
188
+	// expected total size.
189
+	for ; payload.Len() < 4 && continuationCount < maxContinuationRecords; continuationCount++ {
190
+		prevLen := payload.Len()
161 191
 
162
-	return buf, err
192
+		if err := readContinuationRecord(conn, payload); err != nil {
193
+			payload.Truncate(prevLen) // discard partial data on error
194
+
195
+			if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
196
+				break // no more records — let downstream parsing handle what we have
197
+			}
198
+
199
+			return nil, err
200
+		}
201
+	}
202
+
203
+	// Phase 2: we know the expected handshake size — read remaining
204
+	// continuation records until the payload is complete.
205
+	if payload.Len() >= 4 {
206
+		p := payload.Bytes()
207
+		expectedTotal := 4 + (int(p[1])<<16 | int(p[2])<<8 | int(p[3]))
208
+
209
+		if expectedTotal > 0xFFFF {
210
+			return nil, fmt.Errorf("handshake message too large: %d bytes", expectedTotal)
211
+		}
212
+
213
+		for ; payload.Len() < expectedTotal && continuationCount < maxContinuationRecords; continuationCount++ {
214
+			if err := readContinuationRecord(conn, payload); err != nil {
215
+				return nil, err
216
+			}
217
+		}
218
+
219
+		if payload.Len() < expectedTotal {
220
+			return nil, fmt.Errorf("cannot reassemble handshake: too many continuation records")
221
+		}
222
+
223
+		payload.Truncate(expectedTotal)
224
+	}
225
+
226
+	if payload.Len() > 0xFFFF {
227
+		return nil, fmt.Errorf("reassembled payload too large: %d bytes", payload.Len())
228
+	}
229
+
230
+	// Reconstruct a single TLS record with the reassembled payload.
231
+	result := &bytes.Buffer{}
232
+	result.Grow(tls.SizeHeader + payload.Len())
233
+	result.Write(header[:3])
234
+	binary.Write(result, binary.BigEndian, uint16(payload.Len())) //nolint:errcheck // bytes.Buffer.Write never fails
235
+	result.Write(payload.Bytes())
236
+
237
+	return result, nil
238
+}
239
+
240
+// readContinuationRecord reads the next TLS record header and appends its
241
+// full payload to dst. It returns an error if the record is not a handshake
242
+// record.
243
+func readContinuationRecord(conn io.Reader, dst *bytes.Buffer) error {
244
+	nextHeader := [tls.SizeHeader]byte{}
245
+
246
+	if _, err := io.ReadFull(conn, nextHeader[:]); err != nil {
247
+		return fmt.Errorf("cannot read continuation record header: %w", err)
248
+	}
249
+
250
+	if nextHeader[0] != tls.TypeHandshake {
251
+		return fmt.Errorf("unexpected continuation record type %#x", nextHeader[0])
252
+	}
253
+
254
+	if nextHeader[1] != 3 || nextHeader[2] != 1 {
255
+		return fmt.Errorf("unexpected continuation record version %#x %#x", nextHeader[1], nextHeader[2])
256
+	}
257
+
258
+	nextLength := int64(binary.BigEndian.Uint16(nextHeader[3:]))
259
+
260
+	if nextLength == 0 {
261
+		return fmt.Errorf("zero-length continuation record")
262
+	}
263
+
264
+	if _, err := io.CopyN(dst, conn, nextLength); err != nil {
265
+		return fmt.Errorf("cannot read continuation record payload: %w", err)
266
+	}
267
+
268
+	return nil
163 269
 }
164 270
 
165 271
 func parseHandshakeHeader(r io.Reader) (io.Reader, error) {

+ 272
- 0
mtglib/internal/tls/fake/client_side_test.go Datei anzeigen

@@ -530,3 +530,275 @@ func TestReadClientHelloMulti(t *testing.T) {
530 530
 	t.Parallel()
531 531
 	suite.Run(t, &ReadClientHelloMultiTestSuite{})
532 532
 }
533
+
534
+// --- Fragmented TLS record tests ---
535
+
536
+// fragmentTLSRecord splits a single TLS record into n TLS records by
537
+// dividing the payload into roughly equal parts. Each part gets its own
538
+// TLS record header with the same record type and version.
539
+func fragmentTLSRecord(t testing.TB, full []byte, n int) []byte {
540
+	t.Helper()
541
+
542
+	recordType := full[0]
543
+	version := full[1:3]
544
+	payload := full[tls.SizeHeader:]
545
+
546
+	chunkSize := len(payload) / n
547
+	result := &bytes.Buffer{}
548
+
549
+	for i := 0; i < n; i++ {
550
+		start := i * chunkSize
551
+		end := start + chunkSize
552
+
553
+		if i == n-1 {
554
+			end = len(payload)
555
+		}
556
+
557
+		chunk := payload[start:end]
558
+		result.WriteByte(recordType)
559
+		result.Write(version)
560
+		require.NoError(t, binary.Write(result, binary.BigEndian, uint16(len(chunk))))
561
+		result.Write(chunk)
562
+	}
563
+
564
+	return result.Bytes()
565
+}
566
+
567
+// splitPayloadAt creates two TLS records from a single record by splitting
568
+// the payload at the given byte position.
569
+func splitPayloadAt(t testing.TB, full []byte, pos int) []byte {
570
+	t.Helper()
571
+
572
+	payload := full[tls.SizeHeader:]
573
+	buf := &bytes.Buffer{}
574
+
575
+	buf.WriteByte(tls.TypeHandshake)
576
+	buf.Write(full[1:3])
577
+	require.NoError(t, binary.Write(buf, binary.BigEndian, uint16(pos)))
578
+	buf.Write(payload[:pos])
579
+
580
+	buf.WriteByte(tls.TypeHandshake)
581
+	buf.Write(full[1:3])
582
+	require.NoError(t, binary.Write(buf, binary.BigEndian, uint16(len(payload)-pos)))
583
+	buf.Write(payload[pos:])
584
+
585
+	return buf.Bytes()
586
+}
587
+
588
+type ParseClientHelloFragmentedTestSuite struct {
589
+	suite.Suite
590
+
591
+	secret   mtglib.Secret
592
+	snapshot *clientHelloSnapshot
593
+}
594
+
595
+func (s *ParseClientHelloFragmentedTestSuite) SetupSuite() {
596
+	parsed, err := mtglib.ParseSecret(
597
+		"ee367a189aee18fa31c190054efd4a8e9573746f726167652e676f6f676c65617069732e636f6d",
598
+	)
599
+	require.NoError(s.T(), err)
600
+
601
+	s.secret = parsed
602
+
603
+	fileData, err := os.ReadFile("testdata/client-hello-ok-19dfe38384b9884b.json")
604
+	require.NoError(s.T(), err)
605
+
606
+	s.snapshot = &clientHelloSnapshot{}
607
+	require.NoError(s.T(), json.Unmarshal(fileData, s.snapshot))
608
+}
609
+
610
+func (s *ParseClientHelloFragmentedTestSuite) makeConn(data []byte) *parseClientHelloConnMock {
611
+	readBuf := &bytes.Buffer{}
612
+	readBuf.Write(data)
613
+
614
+	connMock := &parseClientHelloConnMock{
615
+		readBuf: readBuf,
616
+	}
617
+
618
+	connMock.
619
+		On("SetReadDeadline", mock.AnythingOfType("time.Time")).
620
+		Twice().
621
+		Return(nil)
622
+
623
+	return connMock
624
+}
625
+
626
+func (s *ParseClientHelloFragmentedTestSuite) TestReassemblySuccess() {
627
+	full := s.snapshot.GetFull()
628
+
629
+	tests := []struct {
630
+		name string
631
+		data []byte
632
+	}{
633
+		{"two equal fragments", fragmentTLSRecord(s.T(), full, 2)},
634
+		{"three equal fragments", fragmentTLSRecord(s.T(), full, 3)},
635
+		{"single byte first fragment", splitPayloadAt(s.T(), full, 1)},
636
+		{"three byte first fragment", splitPayloadAt(s.T(), full, 3)},
637
+	}
638
+
639
+	for _, tt := range tests {
640
+		s.Run(tt.name, func() {
641
+			connMock := s.makeConn(tt.data)
642
+			defer connMock.AssertExpectations(s.T())
643
+
644
+			hello, err := fake.ReadClientHello(
645
+				connMock,
646
+				s.secret.Key[:],
647
+				s.secret.Host,
648
+				TolerateTime,
649
+			)
650
+			s.Require().NoError(err)
651
+
652
+			s.Equal(s.snapshot.GetRandom(), hello.Random[:])
653
+			s.Equal(s.snapshot.GetSessionID(), hello.SessionID)
654
+			s.Equal(uint16(s.snapshot.CipherSuite), hello.CipherSuite)
655
+		})
656
+	}
657
+}
658
+
659
+func (s *ParseClientHelloFragmentedTestSuite) TestReassemblyErrors() {
660
+	full := s.snapshot.GetFull()
661
+	payload := full[tls.SizeHeader:]
662
+
663
+	tests := []struct {
664
+		name      string
665
+		buildData func() []byte
666
+		errMsg    string
667
+	}{
668
+		{
669
+			name: "wrong continuation record type",
670
+			buildData: func() []byte {
671
+				buf := &bytes.Buffer{}
672
+				buf.WriteByte(tls.TypeHandshake)
673
+				buf.Write(full[1:3])
674
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
675
+				buf.Write(payload[:10])
676
+				// Wrong type: application data instead of handshake
677
+				buf.WriteByte(tls.TypeApplicationData)
678
+				buf.Write(full[1:3])
679
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(payload)-10)))
680
+				buf.Write(payload[10:])
681
+				return buf.Bytes()
682
+			},
683
+			errMsg: "unexpected continuation record type",
684
+		},
685
+		{
686
+			name: "too many continuation records",
687
+			buildData: func() []byte {
688
+				// Handshake header claiming 256 bytes, but we only send 1 byte per continuation
689
+				handshakePayload := []byte{0x01, 0x00, 0x01, 0x00}
690
+				buf := &bytes.Buffer{}
691
+				buf.WriteByte(tls.TypeHandshake)
692
+				buf.Write([]byte{3, 1})
693
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(handshakePayload))))
694
+				buf.Write(handshakePayload)
695
+				for range 11 {
696
+					buf.WriteByte(tls.TypeHandshake)
697
+					buf.Write([]byte{3, 1})
698
+					require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(1)))
699
+					buf.WriteByte(0xAB)
700
+				}
701
+				return buf.Bytes()
702
+			},
703
+			errMsg: "too many continuation records",
704
+		},
705
+		{
706
+			name: "zero-length continuation record",
707
+			buildData: func() []byte {
708
+				buf := &bytes.Buffer{}
709
+				buf.WriteByte(tls.TypeHandshake)
710
+				buf.Write(full[1:3])
711
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
712
+				buf.Write(payload[:10])
713
+				// Valid header but zero-length payload
714
+				buf.WriteByte(tls.TypeHandshake)
715
+				buf.Write(full[1:3])
716
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(0)))
717
+				return buf.Bytes()
718
+			},
719
+			errMsg: "zero-length continuation record",
720
+		},
721
+		{
722
+			name: "wrong continuation record version",
723
+			buildData: func() []byte {
724
+				buf := &bytes.Buffer{}
725
+				buf.WriteByte(tls.TypeHandshake)
726
+				buf.Write(full[1:3])
727
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
728
+				buf.Write(payload[:10])
729
+				// Wrong version: 3.3 instead of 3.1
730
+				buf.WriteByte(tls.TypeHandshake)
731
+				buf.Write([]byte{3, 3})
732
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(payload)-10)))
733
+				buf.Write(payload[10:])
734
+				return buf.Bytes()
735
+			},
736
+			errMsg: "unexpected continuation record version",
737
+		},
738
+		{
739
+			name: "handshake message too large",
740
+			buildData: func() []byte {
741
+				// Handshake header claiming 0x010000 (65536) bytes — exceeds 0xFFFF limit
742
+				handshakePayload := []byte{0x01, 0x01, 0x00, 0x00}
743
+				buf := &bytes.Buffer{}
744
+				buf.WriteByte(tls.TypeHandshake)
745
+				buf.Write([]byte{3, 1})
746
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(handshakePayload))))
747
+				buf.Write(handshakePayload)
748
+				return buf.Bytes()
749
+			},
750
+			errMsg: "handshake message too large",
751
+		},
752
+		{
753
+			name: "truncated continuation record header",
754
+			buildData: func() []byte {
755
+				buf := &bytes.Buffer{}
756
+				buf.WriteByte(tls.TypeHandshake)
757
+				buf.Write(full[1:3])
758
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
759
+				buf.Write(payload[:10])
760
+				// Connection ends mid-header (only 2 bytes)
761
+				buf.WriteByte(tls.TypeHandshake)
762
+				buf.WriteByte(3)
763
+				return buf.Bytes()
764
+			},
765
+			errMsg: "cannot read continuation record header",
766
+		},
767
+		{
768
+			name: "truncated continuation record payload",
769
+			buildData: func() []byte {
770
+				buf := &bytes.Buffer{}
771
+				buf.WriteByte(tls.TypeHandshake)
772
+				buf.Write(full[1:3])
773
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10)))
774
+				buf.Write(payload[:10])
775
+				// Claims 100 bytes but no payload follows
776
+				buf.WriteByte(tls.TypeHandshake)
777
+				buf.Write(full[1:3])
778
+				require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(100)))
779
+				return buf.Bytes()
780
+			},
781
+			errMsg: "cannot read continuation record payload",
782
+		},
783
+	}
784
+
785
+	for _, tt := range tests {
786
+		s.Run(tt.name, func() {
787
+			connMock := s.makeConn(tt.buildData())
788
+			defer connMock.AssertExpectations(s.T())
789
+
790
+			_, err := fake.ReadClientHello(
791
+				connMock,
792
+				s.secret.Key[:],
793
+				s.secret.Host,
794
+				TolerateTime,
795
+			)
796
+			s.ErrorContains(err, tt.errMsg)
797
+		})
798
+	}
799
+}
800
+
801
+func TestParseClientHelloFragmented(t *testing.T) {
802
+	t.Parallel()
803
+	suite.Run(t, &ParseClientHelloFragmentedTestSuite{})
804
+}

Laden…
Abbrechen
Speichern