Skip to content

Commit 9fd07a6

Browse files
authored
Reject plaintext ApplicationData records per RFC 6347/9147
1 parent 2cd3fc2 commit 9fd07a6

6 files changed

Lines changed: 684 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Unreleased
22

3+
* DTLS 1.2 DTLS 1.3, parser reject ApplicationData in epoch 0/plaintext #90
4+
* DTLS 1.3 reject plaintext records with non-zero epoch #90
5+
* Silently discard invalid records and process subsequent valid records #90
6+
37
# 0.4.2
48

59
* Downgrade rand to 0.9 to avoid double chacha20 dep #84

src/dtls12/incoming.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,15 @@ impl Record {
136136
// ONLY COPY: UDP packet slice -> pooled buffer
137137
let mut buffer = Buf::new();
138138
buffer.extend_from_slice(record_slice);
139-
let parsed = ParsedRecord::parse(&buffer, cs, 0)?;
139+
let parsed = match ParsedRecord::parse(&buffer, cs, 0) {
140+
Ok(p) => p,
141+
Err(e) => {
142+
// RFC 6347 §4.1.2.7: Invalid records SHOULD be silently discarded.
143+
// This includes epoch 0 records with invalid ContentType.
144+
trace!("Discarding record: parse failed: {}", e);
145+
return Ok(None);
146+
}
147+
};
140148
let parsed = Box::new(parsed);
141149
let record = Record { buffer, parsed };
142150

src/dtls12/message/record.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,21 @@ impl DTLSRecord {
6464
}
6565

6666
let (input, epoch) = be_u16(input)?; // u16
67+
68+
// Epoch 0 records are plaintext in DTLS 1.2. Reject plaintext
69+
// ApplicationData before record protection is active, and only accept
70+
// the epoch-0 content types this implementation supports.
71+
if epoch == 0 {
72+
match content_type {
73+
ContentType::ChangeCipherSpec | ContentType::Alert | ContentType::Handshake => {}
74+
_ => {
75+
return Err(Err::Failure(nom::error::Error::new(
76+
input,
77+
nom::error::ErrorKind::Tag,
78+
)));
79+
}
80+
}
81+
}
6782
let (input, sequence_number) = be_u48(input)?; // u48
6883
let (input, length) = be_u16(input)?; // u16
6984

@@ -162,4 +177,82 @@ mod tests {
162177
parsed.serialize(RECORD, &mut serialized);
163178
assert_eq!(&*serialized, RECORD);
164179
}
180+
181+
#[test]
182+
fn epoch_0_content_type_whitelist() {
183+
// Epoch 0 is plaintext (RFC 6347 §4.1: epoch starts at 0, incremented by each CCS).
184+
// Only ChangeCipherSpec(20), Alert(21), and Handshake(22) can legitimately
185+
// appear unencrypted. ApplicationData in epoch 0 is rejected at parse time.
186+
187+
fn build_epoch_0_record(content_type: u8) -> Vec<u8> {
188+
vec![
189+
content_type, // ContentType
190+
0xFE,
191+
0xFD, // version: DTLS 1.2
192+
0x00,
193+
0x00, // epoch: 0 (plaintext)
194+
0x00,
195+
0x00,
196+
0x00,
197+
0x00,
198+
0x00,
199+
0x01, // sequence_number
200+
0x00,
201+
0x02, // length: 2
202+
0xAA,
203+
0xBB, // fragment payload
204+
]
205+
}
206+
207+
// ALLOWED: ChangeCipherSpec (20)
208+
let ccs = build_epoch_0_record(0x14);
209+
assert!(
210+
DTLSRecord::parse(&ccs, 0, 0).is_ok(),
211+
"ChangeCipherSpec should be allowed in epoch 0"
212+
);
213+
214+
// ALLOWED: Alert (21)
215+
let alert = build_epoch_0_record(0x15);
216+
assert!(
217+
DTLSRecord::parse(&alert, 0, 0).is_ok(),
218+
"Alert should be allowed in epoch 0"
219+
);
220+
221+
// ALLOWED: Handshake (22)
222+
let handshake = build_epoch_0_record(0x16);
223+
assert!(
224+
DTLSRecord::parse(&handshake, 0, 0).is_ok(),
225+
"Handshake should be allowed in epoch 0"
226+
);
227+
228+
// REJECTED: ApplicationData (23)
229+
let app_data = build_epoch_0_record(0x17);
230+
assert!(
231+
DTLSRecord::parse(&app_data, 0, 0).is_err(),
232+
"ApplicationData must be rejected in epoch 0"
233+
);
234+
235+
// REJECTED: Ack (26) - valid in DTLS 1.3 but not DTLS 1.2
236+
let ack = build_epoch_0_record(0x1A);
237+
assert!(
238+
DTLSRecord::parse(&ack, 0, 0).is_err(),
239+
"Ack must be rejected in DTLS 1.2 epoch 0"
240+
);
241+
242+
// REJECTED: Unknown ContentType (0x99)
243+
let unknown = build_epoch_0_record(0x99);
244+
assert!(
245+
DTLSRecord::parse(&unknown, 0, 0).is_err(),
246+
"Unknown ContentType must be rejected in epoch 0"
247+
);
248+
249+
// Verify that epoch 1+ allows ApplicationData (no whitelist restriction)
250+
let mut epoch_1_app_data = build_epoch_0_record(0x17);
251+
epoch_1_app_data[3] = 0x00; // epoch high byte
252+
epoch_1_app_data[4] = 0x01; // epoch low byte = 1
253+
assert!(
254+
DTLSRecord::parse(&epoch_1_app_data, 0, 0).is_ok(),
255+
"ApplicationData should be allowed in epoch 1+"
256+
);
257+
}
165258
}

src/dtls13/message/record.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@ impl Dtls13Record {
7272
let (input, content_type) = ContentType::parse(input)?; // u8
7373
let (input, version) = ProtocolVersion::parse(input)?; // u16
7474

75+
// RFC 9147 §4.1: Only alert(21), handshake(22), and ack(26) are valid
76+
// plaintext content types in DTLS 1.3. Reject all others.
77+
match content_type {
78+
ContentType::Alert | ContentType::Handshake | ContentType::Ack => {}
79+
_ => {
80+
return Err(Err::Failure(nom::error::Error::new(
81+
input,
82+
nom::error::ErrorKind::Tag,
83+
)));
84+
}
85+
}
86+
7587
// Accept DTLS 1.0 or 1.2 in record layer per RFC 9147 §5.1
7688
// (same legacy version handling as DTLS 1.2)
7789
match version {
@@ -85,6 +97,16 @@ impl Dtls13Record {
8597
}
8698

8799
let (input, epoch) = be_u16(input)?; // u16
100+
101+
// RFC 9147 §4.1: DTLSPlaintext records must use epoch 0.
102+
// Epoch values other than 0 in plaintext format are invalid.
103+
if epoch != 0 {
104+
return Err(Err::Failure(nom::error::Error::new(
105+
input,
106+
nom::error::ErrorKind::Tag,
107+
)));
108+
}
109+
88110
let (input, sequence_number) = be_u48(input)?; // u48
89111
let (input, length) = be_u16(input)?; // u16
90112
let (rest, fragment_slice) = take(length as usize)(input)?;
@@ -222,3 +244,111 @@ impl fmt::Debug for Dtls13Record {
222244
.finish()
223245
}
224246
}
247+
248+
#[cfg(test)]
249+
mod tests {
250+
use super::*;
251+
252+
#[test]
253+
fn plaintext_content_type_whitelist() {
254+
// RFC 9147 §4.1: Only alert(21), handshake(22), and ack(26) are valid
255+
// plaintext content types in DTLS 1.3. Plaintext records must use epoch 0.
256+
// ApplicationData and other invalid types are rejected at parse time.
257+
258+
fn build_plaintext_record(content_type: u8) -> Vec<u8> {
259+
vec![
260+
content_type, // ContentType
261+
0xFE,
262+
0xFD, // version: DTLS 1.2
263+
0x00,
264+
0x00, // epoch: 0 (plaintext)
265+
0x00,
266+
0x00,
267+
0x00,
268+
0x00,
269+
0x00,
270+
0x01, // sequence_number
271+
0x00,
272+
0x02, // length: 2
273+
0xAA,
274+
0xBB, // fragment payload
275+
]
276+
}
277+
278+
fn build_plaintext_record_with_epoch(content_type: u8, epoch: u16) -> Vec<u8> {
279+
vec![
280+
content_type, // ContentType
281+
0xFE,
282+
0xFD, // version: DTLS 1.2
283+
(epoch >> 8) as u8,
284+
(epoch & 0xFF) as u8, // epoch
285+
0x00,
286+
0x00,
287+
0x00,
288+
0x00,
289+
0x00,
290+
0x01, // sequence_number
291+
0x00,
292+
0x02, // length: 2
293+
0xAA,
294+
0xBB, // fragment payload
295+
]
296+
}
297+
298+
// ALLOWED: Alert (21) with epoch 0
299+
let alert = build_plaintext_record(0x15);
300+
assert!(
301+
Dtls13Record::parse(&alert, 0).is_ok(),
302+
"Alert should be allowed in plaintext DTLS 1.3"
303+
);
304+
305+
// ALLOWED: Handshake (22) with epoch 0
306+
let handshake = build_plaintext_record(0x16);
307+
assert!(
308+
Dtls13Record::parse(&handshake, 0).is_ok(),
309+
"Handshake should be allowed in plaintext DTLS 1.3"
310+
);
311+
312+
// ALLOWED: Ack (26) with epoch 0
313+
let ack = build_plaintext_record(0x1A);
314+
assert!(
315+
Dtls13Record::parse(&ack, 0).is_ok(),
316+
"Ack should be allowed in plaintext DTLS 1.3"
317+
);
318+
319+
// REJECTED: ApplicationData (23)
320+
let app_data = build_plaintext_record(0x17);
321+
assert!(
322+
Dtls13Record::parse(&app_data, 0).is_err(),
323+
"ApplicationData must be rejected in plaintext DTLS 1.3"
324+
);
325+
326+
// REJECTED: ChangeCipherSpec (20) - valid in DTLS 1.2 but not DTLS 1.3
327+
let ccs = build_plaintext_record(0x14);
328+
assert!(
329+
Dtls13Record::parse(&ccs, 0).is_err(),
330+
"ChangeCipherSpec must be rejected in DTLS 1.3"
331+
);
332+
333+
// REJECTED: Unknown ContentType (0xFF)
334+
let unknown = build_plaintext_record(0xFF);
335+
assert!(
336+
Dtls13Record::parse(&unknown, 0).is_err(),
337+
"Unknown ContentType must be rejected in plaintext DTLS 1.3"
338+
);
339+
340+
// REJECTED: Plaintext format with epoch 1 (invalid per RFC 9147 §4.1)
341+
let epoch_1_handshake = build_plaintext_record_with_epoch(0x16, 1);
342+
assert!(
343+
Dtls13Record::parse(&epoch_1_handshake, 0).is_err(),
344+
"Plaintext format with epoch 1 must be rejected"
345+
);
346+
347+
// REJECTED: Plaintext format with epoch 2 (should use unified header)
348+
let epoch_2_handshake = build_plaintext_record_with_epoch(0x16, 2);
349+
assert!(
350+
Dtls13Record::parse(&epoch_2_handshake, 0).is_err(),
351+
"Plaintext format with epoch 2 must be rejected"
352+
);
353+
}
354+
}

0 commit comments

Comments
 (0)