Skip to main content

brynja_protocol/tls/
content_type.rs

1//! Closed TLS ContentType registry policy.
2
3use brynja_core::ProtocolVersion;
4
5use super::RecordError;
6
7/// The RFC 6520 Heartbeat extension code, retained only for rejection.
8pub const HEARTBEAT_EXTENSION_TYPE: u16 = 15;
9
10/// An assigned TLS ContentType value relevant to modern record framing.
11#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
12#[non_exhaustive]
13pub enum ContentType {
14    /// `change_cipher_spec` (20).
15    ChangeCipherSpec,
16    /// `alert` (21).
17    Alert,
18    /// `handshake` (22).
19    Handshake,
20    /// `application_data` (23).
21    ApplicationData,
22    /// Excluded RFC 6520 `heartbeat` (24).
23    Heartbeat,
24    /// DTLS 1.2 Connection ID content (25), reserved for a later owner.
25    Tls12Cid,
26    /// DTLS 1.3 acknowledgement content (26).
27    Ack,
28}
29
30/// The exact current classification of one wire byte.
31#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
32pub enum ContentTypeClass {
33    /// A currently assigned code.
34    Assigned(ContentType),
35    /// An unassigned code retained without coercion.
36    Unassigned,
37}
38
39/// One exact byte from the TLS ContentType registry.
40///
41/// Construction preserves unknown values. Admission remains a separate,
42/// profile-specific operation.
43///
44/// ```compile_fail
45/// let _ = brynja_protocol::ContentTypeCode(23);
46/// ```
47#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
48pub struct ContentTypeCode(u8);
49
50/// Closed record wire policy for one already selected protocol version.
51///
52/// The policy cannot be inferred from record-layer version bytes, so framing
53/// cannot negotiate, downgrade, or fall back between protocol versions.
54///
55/// ```compile_fail
56/// let _ = brynja_protocol::WirePolicy {
57///     version: brynja_core::ProtocolVersion::Tls13,
58/// };
59/// ```
60#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
61pub struct WirePolicy {
62    version: ProtocolVersion,
63}
64
65impl ContentType {
66    /// Returns the assigned registry byte.
67    #[must_use]
68    pub const fn code(self) -> u8 {
69        match self {
70            Self::ChangeCipherSpec => 20,
71            Self::Alert => 21,
72            Self::Handshake => 22,
73            Self::ApplicationData => 23,
74            Self::Heartbeat => 24,
75            Self::Tls12Cid => 25,
76            Self::Ack => 26,
77        }
78    }
79}
80
81impl ContentTypeCode {
82    /// Preserves and classifies one registry byte.
83    #[must_use]
84    pub const fn classify(code: u8) -> Self {
85        Self(code)
86    }
87
88    /// Returns the exact original wire byte.
89    #[must_use]
90    pub const fn code(self) -> u8 {
91        self.0
92    }
93
94    /// Returns the exact current registry class without fallback.
95    #[must_use]
96    pub const fn class(self) -> ContentTypeClass {
97        let assigned = match self.0 {
98            20 => Some(ContentType::ChangeCipherSpec),
99            21 => Some(ContentType::Alert),
100            22 => Some(ContentType::Handshake),
101            23 => Some(ContentType::ApplicationData),
102            24 => Some(ContentType::Heartbeat),
103            25 => Some(ContentType::Tls12Cid),
104            26 => Some(ContentType::Ack),
105            _ => None,
106        };
107        match assigned {
108            Some(content_type) => ContentTypeClass::Assigned(content_type),
109            None => ContentTypeClass::Unassigned,
110        }
111    }
112}
113
114impl WirePolicy {
115    /// Binds framing to an externally selected typed protocol version.
116    #[must_use]
117    pub const fn for_version(version: ProtocolVersion) -> Self {
118        Self { version }
119    }
120
121    /// Returns the externally selected version.
122    #[must_use]
123    pub const fn version(self) -> ProtocolVersion {
124        self.version
125    }
126
127    /// Rejects Heartbeat negotiation before extension state can be created.
128    ///
129    /// Other extension codes are not admitted by this method; they merely
130    /// remain outside this narrow exclusion check for their later owners.
131    pub const fn reject_heartbeat_negotiation(
132        self,
133        extension_type: u16,
134    ) -> Result<(), RecordError> {
135        let _ = self;
136        if extension_type == HEARTBEAT_EXTENSION_TYPE {
137            Err(RecordError::HeartbeatRejected)
138        } else {
139            Ok(())
140        }
141    }
142
143    /// Admits an already decrypted TLS 1.3 or DTLS 1.3 inner content type.
144    ///
145    /// Heartbeat is rejected for both families. Earlier profiles have no
146    /// inner-content envelope and fail with [`RecordError::ProfileMismatch`].
147    pub fn admit_inner_content_type(
148        self,
149        code: ContentTypeCode,
150    ) -> Result<ContentType, RecordError> {
151        let content_type = assigned(code)?;
152        if matches!(content_type, ContentType::Heartbeat) {
153            return Err(RecordError::HeartbeatRejected);
154        }
155        let admitted = match self.version {
156            ProtocolVersion::Tls13 => matches!(
157                content_type,
158                ContentType::Alert | ContentType::Handshake | ContentType::ApplicationData
159            ),
160            ProtocolVersion::Dtls13 => matches!(
161                content_type,
162                ContentType::Alert
163                    | ContentType::Handshake
164                    | ContentType::ApplicationData
165                    | ContentType::Ack
166            ),
167            _ => return Err(RecordError::ProfileMismatch),
168        };
169        if admitted {
170            Ok(content_type)
171        } else {
172            Err(RecordError::UnsupportedContentType)
173        }
174    }
175
176    pub(crate) fn admit_plaintext(self, code: ContentTypeCode) -> Result<ContentType, RecordError> {
177        let content_type = assigned(code)?;
178        if matches!(content_type, ContentType::Heartbeat) {
179            return Err(RecordError::HeartbeatRejected);
180        }
181        let admitted = match self.version {
182            ProtocolVersion::Tls12 => matches!(
183                content_type,
184                ContentType::ChangeCipherSpec
185                    | ContentType::Alert
186                    | ContentType::Handshake
187                    | ContentType::ApplicationData
188            ),
189            ProtocolVersion::Tls13 => {
190                if matches!(content_type, ContentType::ApplicationData) {
191                    return Err(RecordError::UnprotectedApplicationData);
192                }
193                matches!(
194                    content_type,
195                    ContentType::ChangeCipherSpec | ContentType::Alert | ContentType::Handshake
196                )
197            }
198            ProtocolVersion::Dtls12 => matches!(
199                content_type,
200                ContentType::ChangeCipherSpec
201                    | ContentType::Alert
202                    | ContentType::Handshake
203                    | ContentType::ApplicationData
204            ),
205            ProtocolVersion::Dtls13 => matches!(
206                content_type,
207                ContentType::Alert | ContentType::Handshake | ContentType::Ack
208            ),
209            _ => false,
210        };
211        if admitted {
212            Ok(content_type)
213        } else {
214            Err(RecordError::UnsupportedContentType)
215        }
216    }
217
218    pub(crate) fn admit_ciphertext(
219        self,
220        code: ContentTypeCode,
221    ) -> Result<ContentType, RecordError> {
222        if matches!(
223            code.class(),
224            ContentTypeClass::Assigned(ContentType::Heartbeat)
225        ) {
226            return Err(RecordError::HeartbeatRejected);
227        }
228        match self.version {
229            ProtocolVersion::Tls13 => {
230                if code.code() == ContentType::ApplicationData.code() {
231                    Ok(ContentType::ApplicationData)
232                } else {
233                    Err(RecordError::InvalidCiphertextType)
234                }
235            }
236            ProtocolVersion::Tls12 | ProtocolVersion::Dtls12 => self.admit_plaintext(code),
237            ProtocolVersion::Dtls13 => Err(RecordError::ProfileMismatch),
238            _ => Err(RecordError::ProfileMismatch),
239        }
240    }
241}
242
243fn assigned(code: ContentTypeCode) -> Result<ContentType, RecordError> {
244    match code.class() {
245        ContentTypeClass::Assigned(content_type) => Ok(content_type),
246        ContentTypeClass::Unassigned => Err(RecordError::UnsupportedContentType),
247    }
248}