Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Unreleased

* DTLS 1.2 DTLS 1.3, parser reject ApplicationData in epoch 0/plaintext #90
* DTLS 1.3 reject plaintext records with non-zero epoch #90
* Silently discard invalid records and process subsequent valid records #90

# 0.4.2

* Downgrade rand to 0.9 to avoid double chacha20 dep #84
Expand Down
10 changes: 9 additions & 1 deletion src/dtls12/incoming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,15 @@ impl Record {
// ONLY COPY: UDP packet slice -> pooled buffer
let mut buffer = Buf::new();
buffer.extend_from_slice(record_slice);
let parsed = ParsedRecord::parse(&buffer, cs, 0)?;
let parsed = match ParsedRecord::parse(&buffer, cs, 0) {
Ok(p) => p,
Err(e) => {
// RFC 6347 §4.1.2.7: Invalid records SHOULD be silently discarded.
// This includes epoch 0 records with invalid ContentType.
trace!("Discarding record: parse failed: {}", e);
return Ok(None);
}
};
let parsed = Box::new(parsed);
let record = Record { buffer, parsed };

Expand Down
93 changes: 93 additions & 0 deletions src/dtls12/message/record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ impl DTLSRecord {
}

let (input, epoch) = be_u16(input)?; // u16

// Epoch 0 records are plaintext in DTLS 1.2. Reject plaintext
// ApplicationData before record protection is active, and only accept
// the epoch-0 content types this implementation supports.
if epoch == 0 {
match content_type {
ContentType::ChangeCipherSpec | ContentType::Alert | ContentType::Handshake => {}
_ => {
return Err(Err::Failure(nom::error::Error::new(
input,
nom::error::ErrorKind::Tag,
)));
}
}
}
let (input, sequence_number) = be_u48(input)?; // u48
let (input, length) = be_u16(input)?; // u16

Expand Down Expand Up @@ -162,4 +177,82 @@ mod tests {
parsed.serialize(RECORD, &mut serialized);
assert_eq!(&*serialized, RECORD);
}

#[test]
fn epoch_0_content_type_whitelist() {
// Epoch 0 is plaintext (RFC 6347 §4.1: epoch starts at 0, incremented by each CCS).
// Only ChangeCipherSpec(20), Alert(21), and Handshake(22) can legitimately
// appear unencrypted. ApplicationData in epoch 0 is rejected at parse time.

fn build_epoch_0_record(content_type: u8) -> Vec<u8> {
vec![
content_type, // ContentType
0xFE,
0xFD, // version: DTLS 1.2
0x00,
0x00, // epoch: 0 (plaintext)
0x00,
0x00,
0x00,
0x00,
0x00,
0x01, // sequence_number
0x00,
0x02, // length: 2
0xAA,
0xBB, // fragment payload
]
}

// ALLOWED: ChangeCipherSpec (20)
let ccs = build_epoch_0_record(0x14);
assert!(
DTLSRecord::parse(&ccs, 0, 0).is_ok(),
"ChangeCipherSpec should be allowed in epoch 0"
);

// ALLOWED: Alert (21)
let alert = build_epoch_0_record(0x15);
assert!(
DTLSRecord::parse(&alert, 0, 0).is_ok(),
"Alert should be allowed in epoch 0"
);

// ALLOWED: Handshake (22)
let handshake = build_epoch_0_record(0x16);
assert!(
DTLSRecord::parse(&handshake, 0, 0).is_ok(),
"Handshake should be allowed in epoch 0"
);

// REJECTED: ApplicationData (23)
let app_data = build_epoch_0_record(0x17);
assert!(
DTLSRecord::parse(&app_data, 0, 0).is_err(),
"ApplicationData must be rejected in epoch 0"
);

// REJECTED: Ack (26) - valid in DTLS 1.3 but not DTLS 1.2
let ack = build_epoch_0_record(0x1A);
assert!(
DTLSRecord::parse(&ack, 0, 0).is_err(),
"Ack must be rejected in DTLS 1.2 epoch 0"
);

// REJECTED: Unknown ContentType (0x99)
let unknown = build_epoch_0_record(0x99);
assert!(
DTLSRecord::parse(&unknown, 0, 0).is_err(),
"Unknown ContentType must be rejected in epoch 0"
);

// Verify that epoch 1+ allows ApplicationData (no whitelist restriction)
let mut epoch_1_app_data = build_epoch_0_record(0x17);
epoch_1_app_data[3] = 0x00; // epoch high byte
epoch_1_app_data[4] = 0x01; // epoch low byte = 1
assert!(
DTLSRecord::parse(&epoch_1_app_data, 0, 0).is_ok(),
"ApplicationData should be allowed in epoch 1+"
);
}
}
130 changes: 130 additions & 0 deletions src/dtls13/message/record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ impl Dtls13Record {
let (input, content_type) = ContentType::parse(input)?; // u8
let (input, version) = ProtocolVersion::parse(input)?; // u16

// RFC 9147 §4.1: Only alert(21), handshake(22), and ack(26) are valid
// plaintext content types in DTLS 1.3. Reject all others.
match content_type {
ContentType::Alert | ContentType::Handshake | ContentType::Ack => {}
_ => {
return Err(Err::Failure(nom::error::Error::new(
input,
nom::error::ErrorKind::Tag,
)));
}
}

// Accept DTLS 1.0 or 1.2 in record layer per RFC 9147 §5.1
// (same legacy version handling as DTLS 1.2)
match version {
Expand All @@ -85,6 +97,16 @@ impl Dtls13Record {
}

let (input, epoch) = be_u16(input)?; // u16

// RFC 9147 §4.1: DTLSPlaintext records must use epoch 0.
// Epoch values other than 0 in plaintext format are invalid.
if epoch != 0 {
return Err(Err::Failure(nom::error::Error::new(
input,
nom::error::ErrorKind::Tag,
)));
}

let (input, sequence_number) = be_u48(input)?; // u48
let (input, length) = be_u16(input)?; // u16
let (rest, fragment_slice) = take(length as usize)(input)?;
Expand Down Expand Up @@ -222,3 +244,111 @@ impl fmt::Debug for Dtls13Record {
.finish()
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn plaintext_content_type_whitelist() {
// RFC 9147 §4.1: Only alert(21), handshake(22), and ack(26) are valid
// plaintext content types in DTLS 1.3. Plaintext records must use epoch 0.
// ApplicationData and other invalid types are rejected at parse time.

fn build_plaintext_record(content_type: u8) -> Vec<u8> {
vec![
content_type, // ContentType
0xFE,
0xFD, // version: DTLS 1.2
0x00,
0x00, // epoch: 0 (plaintext)
0x00,
0x00,
0x00,
0x00,
0x00,
0x01, // sequence_number
0x00,
0x02, // length: 2
0xAA,
0xBB, // fragment payload
]
}

fn build_plaintext_record_with_epoch(content_type: u8, epoch: u16) -> Vec<u8> {
vec![
content_type, // ContentType
0xFE,
0xFD, // version: DTLS 1.2
(epoch >> 8) as u8,
(epoch & 0xFF) as u8, // epoch
0x00,
0x00,
0x00,
0x00,
0x00,
0x01, // sequence_number
0x00,
0x02, // length: 2
0xAA,
0xBB, // fragment payload
]
}

// ALLOWED: Alert (21) with epoch 0
let alert = build_plaintext_record(0x15);
assert!(
Dtls13Record::parse(&alert, 0).is_ok(),
"Alert should be allowed in plaintext DTLS 1.3"
);

// ALLOWED: Handshake (22) with epoch 0
let handshake = build_plaintext_record(0x16);
assert!(
Dtls13Record::parse(&handshake, 0).is_ok(),
"Handshake should be allowed in plaintext DTLS 1.3"
);

// ALLOWED: Ack (26) with epoch 0
let ack = build_plaintext_record(0x1A);
assert!(
Dtls13Record::parse(&ack, 0).is_ok(),
"Ack should be allowed in plaintext DTLS 1.3"
);

// REJECTED: ApplicationData (23)
let app_data = build_plaintext_record(0x17);
assert!(
Dtls13Record::parse(&app_data, 0).is_err(),
"ApplicationData must be rejected in plaintext DTLS 1.3"
);

// REJECTED: ChangeCipherSpec (20) - valid in DTLS 1.2 but not DTLS 1.3
let ccs = build_plaintext_record(0x14);
assert!(
Dtls13Record::parse(&ccs, 0).is_err(),
"ChangeCipherSpec must be rejected in DTLS 1.3"
);

// REJECTED: Unknown ContentType (0xFF)
let unknown = build_plaintext_record(0xFF);
assert!(
Dtls13Record::parse(&unknown, 0).is_err(),
"Unknown ContentType must be rejected in plaintext DTLS 1.3"
);

// REJECTED: Plaintext format with epoch 1 (invalid per RFC 9147 §4.1)
let epoch_1_handshake = build_plaintext_record_with_epoch(0x16, 1);
assert!(
Dtls13Record::parse(&epoch_1_handshake, 0).is_err(),
"Plaintext format with epoch 1 must be rejected"
);

// REJECTED: Plaintext format with epoch 2 (should use unified header)
let epoch_2_handshake = build_plaintext_record_with_epoch(0x16, 2);
assert!(
Dtls13Record::parse(&epoch_2_handshake, 0).is_err(),
"Plaintext format with epoch 2 must be rejected"
);
}
}
Loading