krabitls 0.3.0

A TLS13 client lib for microcontrollers.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
//! `krabitls` — a sans-io, `no_std` TLS 1.3 client for a fixed embedded
//! profile.
//!
//! Public surface lives in [`client`] (connection facade) and
//! [`backends`] (config markers + RustCrypto trait impls). Everything
//! else is internal.
//!
//! # Security
//!
//! A hobby project — don't use it for anything you care about. The crypto is
//! hand-rolled, unaudited, not constant-time, and has no scalar blinding. The
//! bundled trust is pin-a-pubkey or trust-SAN — no CA bundle or chain walking —
//! but verification is a pluggable `VerifyStrategy`, so a caller can
//! supply their own. See the README for the full threat model.
//!
//! # Quick start
//!
//! Drive a handshake with the bundled [`client::DefaultStream`] +
//! [`client::DefaultScratch`]:
//!
//! ```ignore
//! use krabitls::client::{
//!     ClientParams, DefaultScratch, DefaultStream, PinnedPubkey,
//! };
//!
//! let mut scratch = DefaultScratch::new();
//! let params = ClientParams::pinned(
//!     "example.com",
//!     PinnedPubkey::Ed25519(server_pubkey),
//! )?;
//!
//! let mut tls = DefaultStream::connect(
//!     &params, &mut scratch, transport, &mut rng,
//! )?;
//! tls.write_all(b"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")?;
//!
//! let mut buf = [0u8; 1024];
//! let n = tls.read(&mut buf)?;
//! tls.close()?;
//! ```
//!
//! `transport` is any [`client::Transport`] (typically a blocking
//! `TcpStream` wrapper); `rng` is any [`rand_core::TryCryptoRng`].

#![cfg_attr(not(test), no_std)]

pub(crate) mod aead;
pub mod backends;
pub(crate) mod bigint;
pub mod client;
pub(crate) mod client_flight;
pub(crate) mod connection;
pub(crate) mod errors;
pub(crate) mod hkdf;
pub(crate) mod identity;
pub(crate) mod newtype;
pub(crate) mod reassembler;
pub(crate) mod server_flight;
pub(crate) mod traits;

use errors::{ClientHelloError, ParseError, Write24Error};

#[cfg(all(test, feature = "cipher-aes"))]
pub(crate) use {
    aead::RecordKeys,
    hkdf::{application_traffic_secrets, master_secret},
    server_flight::{tests::verify_self_signed_cert, verify_server_flight},
};
#[cfg(test)]
pub(crate) use {
    aead::{aead_nonce, split_inner_plaintext},
    hkdf::{derive_secret, handshake_secret, handshake_traffic_secrets, hkdf_expand_label},
};

use embedded_io::Write;

/// Compile-time hex decoder for `testdata/*.hex` fixtures.
#[cfg(any(test, feature = "dev-utils"))]
pub const fn hex_decode<const N: usize>(s: &str) -> [u8; N] {
    let bytes = s.as_bytes();
    let mut out = [0u8; N];
    let mut i = 0;
    let mut o = 0;
    while i < bytes.len() {
        let c = bytes[i];
        if c == b' ' || c == b'\t' || c == b'\n' || c == b'\r' {
            i += 1;
            continue;
        }
        if c == b'#' {
            while i < bytes.len() && bytes[i] != b'\n' {
                i += 1;
            }
            continue;
        }
        let hi = hex_nibble(bytes[i]);
        if i + 1 >= bytes.len() {
            panic!("hex_decode: dangling nibble at end of input");
        }
        let lo = hex_nibble(bytes[i + 1]);
        if o >= N {
            panic!("hex_decode: more bytes in input than the declared N");
        }
        out[o] = (hi << 4) | lo;
        i += 2;
        o += 1;
    }
    if o != N {
        panic!("hex_decode: fewer bytes in input than the declared N");
    }
    out
}

#[cfg(any(test, feature = "dev-utils"))]
const fn hex_nibble(c: u8) -> u8 {
    match c {
        b'0'..=b'9' => c - b'0',
        b'a'..=b'f' => c - b'a' + 10,
        b'A'..=b'F' => c - b'A' + 10,
        _ => panic!("hex_decode: non-hex byte in input"),
    }
}

pub(crate) mod consts {
    pub const CT_HANDSHAKE: u8 = 22;
    pub const CT_APPLICATION_DATA: u8 = 23;
    pub const CT_ALERT: u8 = 21;
    /// Middlebox-compat ChangeCipherSpec — accepted and dropped without
    /// bumping `seq_in` in TLS 1.3.
    pub const CT_CHANGE_CIPHER_SPEC: u8 = 0x14;
    /// `warning` (level 1) + `close_notify` (description 0) — the only alert
    /// krabitls ever sends.
    pub const CLOSE_NOTIFY_ALERT: [u8; 2] = [0x01, 0x00];

    /// RFC 8446 mandates 0x0303 in the record header and in
    /// `ClientHello.legacy_version`, even when negotiating TLS 1.3.
    pub const LEGACY_VERSION: u16 = 0x0303;
    pub const TLS_1_3: u16 = 0x0304;

    pub const HS_CLIENT_HELLO: u8 = 1;
    pub const HS_SERVER_HELLO: u8 = 2;
    pub const HS_NEW_SESSION_TICKET: u8 = 4;
    pub const HS_ENCRYPTED_EXTENSIONS: u8 = 8;
    pub const HS_CERTIFICATE_REQUEST: u8 = 13;
    pub const HS_CERTIFICATE: u8 = 11;
    pub const HS_CERTIFICATE_VERIFY: u8 = 15;
    pub const HS_FINISHED: u8 = 20;
    pub const HS_KEY_UPDATE: u8 = 24;

    /// TLS 1.3 `TLSInnerPlaintext` content-type byte appended before
    /// encryption; counts against the peer's `record_size_limit` (RFC 8449 §4).
    pub const CONTENT_TYPE_LEN: usize = 1;

    pub const CIPHER_AES_128_GCM_SHA256: u16 = 0x1301;
    pub const CIPHER_CHACHA20_POLY1305_SHA256: u16 = 0x1303;
    pub const NAMED_GROUP_X25519: u16 = 0x001D;
    /// `X25519MLKEM768` hybrid group (draft-ietf-tls-ecdhe-mlkem). Unconditional
    /// so the key-share writer can name it from a `cfg!(feature = "mlkem")`-false
    /// branch; only advertised when the feature is on.
    pub const NAMED_GROUP_X25519MLKEM768: u16 = 0x11EC;
    pub const SIG_SCHEME_ED25519: u16 = 0x0807;
    /// `rsa_pss_rsae_sha256` — RSASSA-PSS with the leaf's RSAE key encoding,
    /// MGF1-SHA-256, salt_len = hash output (32 B). RFC 8446 §4.2.3.
    // Unconditional so it can be named from `cfg!(feature = "rsa")`-false
    // branches in the ClientHello writer; the actual emission stays gated.
    pub const SIG_SCHEME_RSA_PSS_RSAE_SHA256: u16 = 0x0804;
    /// `mldsa44` / `mldsa65` / `mldsa87` — pure ML-DSA (FIPS 204) over the
    /// CertificateVerify content, empty context. Codepoints from
    /// draft-ietf-tls-mldsa. Unconditional so the ClientHello writer can name
    /// them from a `cfg!(feature = "mldsa")`-false branch; emission and
    /// verification are gated.
    pub const SIG_SCHEME_MLDSA44: u16 = 0x0904;
    pub const SIG_SCHEME_MLDSA65: u16 = 0x0905;
    pub const SIG_SCHEME_MLDSA87: u16 = 0x0906;

    pub const EXT_SERVER_NAME: u16 = 0;
    pub const EXT_SUPPORTED_GROUPS: u16 = 10;
    pub const EXT_SIGNATURE_ALGORITHMS: u16 = 13;
    pub const EXT_RECORD_SIZE_LIMIT: u16 = 28;
    pub const EXT_SUPPORTED_VERSIONS: u16 = 43;
    pub const EXT_KEY_SHARE: u16 = 51;
    /// `name_type` value inside the SNI extension (RFC 6066 §3). Krabitls
    /// only ever writes `host_name`; we don't carry other NameType values.
    pub const SNI_NAME_TYPE_HOST_NAME: u8 = 0;

    /// Magic value a TLS-1.3-capable server places in `ServerHello.random`
    /// when the message is actually a HelloRetryRequest. From RFC 8446
    /// §4.1.3: `SHA-256("HelloRetryRequest")`.
    pub const HRR_RANDOM: [u8; 32] = [
        0xCF, 0x21, 0xAD, 0x74, 0xE5, 0x9A, 0x61, 0x11, 0xBE, 0x1D, 0x8C, 0x02, 0x1E, 0x65, 0xB8,
        0x91, 0xC2, 0xA2, 0x11, 0x16, 0x7A, 0xBB, 0x8C, 0x5E, 0x07, 0x9E, 0x09, 0xE2, 0xC8, 0xA8,
        0x33, 0x9C,
    ];

    /// Last 8 bytes of `ServerHello.random` when a TLS-1.3-capable server
    /// has been downgraded to TLS 1.2. RFC 8446 §4.1.3.
    pub const DOWNGRADE_TLS12: [u8; 8] = *b"DOWNGRD\x01";
    /// Same as `DOWNGRADE_TLS12` but for downgrade to TLS 1.1 or earlier.
    pub const DOWNGRADE_TLS11_OR_BELOW: [u8; 8] = *b"DOWNGRD\x00";
}

use consts::*;

const EXT_SUPPORTED_VERSIONS_TOTAL: u16 = 4 + 3;
const EXT_SUPPORTED_GROUPS_TOTAL: u16 = 4 + 4;
// Schemes advertised in signature_algorithms: ed25519 always, rsa_pss when
// `rsa` is on, the three ML-DSA schemes when `mldsa` is on. Single source for
// both the ext sizing and the writer below.
const SIG_SCHEME_COUNT: u16 = 1 + cfg!(feature = "rsa") as u16 + 3 * cfg!(feature = "mldsa") as u16;
// 4-byte ext header + 2-byte list-len + 2 bytes per scheme.
const EXT_SIGNATURE_ALGORITHMS_TOTAL: u16 = 4 + 2 + 2 * SIG_SCHEME_COUNT;
// The single named group we advertise in supported_groups + key_share:
// X25519MLKEM768 under `mlkem`, otherwise plain X25519.
const KEY_SHARE_GROUP: u16 = if cfg!(feature = "mlkem") {
    NAMED_GROUP_X25519MLKEM768
} else {
    NAMED_GROUP_X25519
};
// key_exchange byte length of our key_share entry. X25519MLKEM768 prepends the
// ML-KEM-768 encapsulation key (1184) to the X25519 public key (32); plain
// X25519 is 32.
#[cfg(feature = "mlkem")]
const KEY_SHARE_KEY_LEN: usize = backends::mlkem::MLKEM768_EK_BYTES + 32;
#[cfg(not(feature = "mlkem"))]
const KEY_SHARE_KEY_LEN: usize = 32;
// key_exchange is a u16-prefixed wire field; a future KEM whose key overflowed
// u16 would silently truncate the length prefixes derived from it.
const _: () = assert!(KEY_SHARE_KEY_LEN <= u16::MAX as usize);
const KEY_SHARE_KEY_LEN_U16: u16 = KEY_SHARE_KEY_LEN as u16;
// client_shares entry: group (2) + key_len (2) + key.
const KEY_SHARE_LIST_LEN: u16 = 4 + KEY_SHARE_KEY_LEN_U16;
// key_share extension_data: client_shares list_len (2) + the single entry.
const KEY_SHARE_EXT_DATA_LEN: u16 = 2 + KEY_SHARE_LIST_LEN;
// ext header (4) + extension_data.
const EXT_KEY_SHARE_TOTAL: u16 = 4 + KEY_SHARE_EXT_DATA_LEN;

// At least one cipher feature must be on. We can't form a valid
// ClientHello otherwise — there's no cipher_suite to advertise.
#[cfg(not(any(feature = "cipher-aes", feature = "chacha20")))]
compile_error!(
    "krabitls requires at least one of `cipher-aes` (default) or `chacha20` to provide a cipher suite"
);

const CH_CIPHER_SUITES_COUNT: usize =
    cfg!(feature = "cipher-aes") as usize + cfg!(feature = "chacha20") as usize;

/// Wire size of the RFC 8449 `record_size_limit` extension: 4-byte header
/// (ext_type + ext_data_len) + 2-byte value.
const EXT_RECORD_SIZE_LIMIT_TOTAL: u16 = 4 + 2;

/// Runtime narrowing of the compile-time suite advertisement.
///
/// Compile-time capability is fixed by the `chacha20` Cargo feature:
/// `Default` matches whatever was compiled in. `AesOnly` forces AES-only
/// advertisement even when `chacha20` is enabled — the facade uses this
/// to honour a caller's `ClientParams::aes_only()` request without
/// recompiling the crate.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SuiteList {
    #[default]
    Default,
    #[cfg(feature = "cipher-aes")]
    AesOnly,
    #[cfg(feature = "chacha20")]
    ChaChaOnly,
}

/// Options for the opts-aware ClientHello writer and its typestate
/// wrapper, [`crate::TlsConnection::<Init>::write_client_hello_to_slice_with`].
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct ClientHelloOptions<'a> {
    /// SNI hostname bytes. `None` omits the extension.
    pub hostname: Option<&'a [u8]>,
    /// RFC 8449 `record_size_limit` value to advertise. `None` omits the
    /// extension. Must be in `[64, 2^14 + 1]`; the writer enforces this
    /// and returns [`ClientHelloError::RecordSizeLimitOutOfRange`] otherwise.
    pub record_size_limit: Option<u16>,
    /// Suite list to advertise. See [`SuiteList`].
    pub suites: SuiteList,
    /// ML-KEM-768 encapsulation key for the `X25519MLKEM768` key_share. Set by
    /// the connection layer once it has generated the ephemeral KEM keypair;
    /// the writer prepends it to the X25519 public key. `None` is a bug under
    /// `mlkem` (the writer errors rather than emit a short key_share).
    #[cfg(feature = "mlkem")]
    pub mlkem_ek: Option<&'a [u8; backends::mlkem::MLKEM768_EK_BYTES]>,
}

/// Fixed-extension total when the caller supplies no SNI.
const CH_EXTENSIONS_FIXED_TOTAL: u16 = EXT_SUPPORTED_VERSIONS_TOTAL
    + EXT_SUPPORTED_GROUPS_TOTAL
    + EXT_SIGNATURE_ALGORITHMS_TOTAL
    + EXT_KEY_SHARE_TOTAL;

/// Total wire size of the server_name extension for a given hostname length.
const fn sni_ext_total(hostname_len: usize) -> usize {
    // 4 (ext type+len header) + 2 (list_len) + 1 (name_type) + 2 (hostname_len) + N
    9 + hostname_len
}

/// Number of cipher_suites advertised on the wire for the given suite list.
const fn ch_n_suites(suites: SuiteList) -> usize {
    match suites {
        SuiteList::Default => CH_CIPHER_SUITES_COUNT,
        #[cfg(feature = "cipher-aes")]
        SuiteList::AesOnly => 1,
        #[cfg(feature = "chacha20")]
        SuiteList::ChaChaOnly => 1,
    }
}

/// Wire size of `cipher_suites`: 2-byte length prefix + 2 bytes per suite.
const fn ch_cipher_suites_field_len(suites: SuiteList) -> usize {
    2 + 2 * ch_n_suites(suites)
}

/// Total wire size of the variable extensions: fixed extensions + optional
/// SNI + optional `record_size_limit`.
const fn ch_extensions_total(sni_host_len: Option<usize>, has_record_size_limit: bool) -> usize {
    let sni = match sni_host_len {
        None => 0,
        Some(n) => sni_ext_total(n),
    };
    let rsl = if has_record_size_limit {
        EXT_RECORD_SIZE_LIMIT_TOTAL as usize
    } else {
        0
    };
    CH_EXTENSIONS_FIXED_TOTAL as usize + sni + rsl
}

/// ClientHello body length (the bytes after the 4-byte handshake header).
const fn ch_body_len(
    suites: SuiteList,
    sni_host_len: Option<usize>,
    has_record_size_limit: bool,
) -> usize {
    2 + 32
        + 1
        + ch_cipher_suites_field_len(suites)
        + (1 + 1)
        + 2
        + ch_extensions_total(sni_host_len, has_record_size_limit)
}

/// Total wire size of a ClientHello with the given dimensions (record
/// header + handshake header + body). Source of truth for both
/// [`client_hello_len`] and [`client_hello_len_with`].
const fn ch_total_len(
    suites: SuiteList,
    sni_host_len: Option<usize>,
    has_record_size_limit: bool,
) -> usize {
    5 + 4 + ch_body_len(suites, sni_host_len, has_record_size_limit)
}

/// Compute the exact serialized size of a ClientHello with the given
/// hostname option, using the compile-time default suite list and no
/// `record_size_limit` extension. Use [`client_hello_len_with`] when
/// emitting opts-driven extensions or narrowing the suite advertisement.
pub(crate) const fn client_hello_len(hostname_len: Option<usize>) -> usize {
    ch_total_len(SuiteList::Default, hostname_len, false)
}

/// Opts-aware sibling of [`client_hello_len`]. Returns the exact byte size
/// the corresponding opts-aware ClientHello writer call will emit for the
/// supplied options. Single source of truth shared with the writer — the
/// two cannot drift on the new (`record_size_limit` / `SuiteList::AesOnly`)
/// paths.
pub(crate) const fn client_hello_len_with(opts: &ClientHelloOptions<'_>) -> usize {
    let sni_host_len = match opts.hostname {
        None => None,
        Some(h) => Some(h.len()),
    };
    ch_total_len(opts.suites, sni_host_len, opts.record_size_limit.is_some())
}

/// Serialized size of the ClientHello the default-opts writer produces
/// when no SNI is supplied. 117 bytes by default, 119 with
/// `feature = "rsa"` (one extra scheme entry), 123 with `feature = "mldsa"`
/// (three extra scheme entries) in the signature_algorithms extension.
pub(crate) const CLIENT_HELLO_LEN: usize = client_hello_len(None);

// Combo-independent pin: 117-byte baseline (1 suite, ed25519-only) + 2 per
// extra advertised suite + 2 per signature scheme beyond the baseline ed25519.
// The per-scheme term is derived from `SIG_SCHEME_COUNT` so the scheme count
// and its byte contribution can't drift apart when the advertised set changes.
const _: () = assert!(
    CLIENT_HELLO_LEN
        == 117
            + 2 * CH_CIPHER_SUITES_COUNT.saturating_sub(1)
            + 2 * (SIG_SCHEME_COUNT as usize - 1)
            // key_share grows from the 32-byte X25519 baseline by the ML-KEM ek.
            + (KEY_SHARE_KEY_LEN - 32)
);

/// Big-endian byte-emission helpers layered on top of [`embedded_io::Write`].
trait WriteExt: Write {
    fn write_u8(&mut self, n: u8) -> Result<(), Self::Error> {
        self.write_all(&[n])
    }

    fn write_u16(&mut self, n: u16) -> Result<(), Self::Error> {
        self.write_all(&n.to_be_bytes())
    }

    /// Writes the low 3 bytes of `n` big-endian. Returns
    /// [`Write24Error::Overflow`] if `n > 0xFF_FFFF`. Checked in both
    /// debug and release — a silently truncated handshake length would
    /// corrupt the TLS framing.
    fn write_u24(&mut self, n: u32) -> Result<(), Write24Error<Self::Error>> {
        if n > 0xFF_FFFF {
            return Err(Write24Error::Overflow);
        }
        let bytes = n.to_be_bytes();
        self.write_all(&bytes[1..])?;
        Ok(())
    }
}

impl<W: Write + ?Sized> WriteExt for W {}

/// TLS 1.3 plaintext fragment maximum (RFC 8446 §5.1). The record body of
/// a ClientHello must not exceed this; `write_client_hello` enforces it.
const TLS_PLAINTEXT_MAX: usize = 1 << 14;

/// Serialize a TLS 1.3 ClientHello into `out` using caller-supplied
/// [`ClientHelloOptions`]. `opts.hostname` (if `Some`) produces an SNI
/// extension (RFC 6066 §3); `opts.record_size_limit` (if `Some`) adds
/// the RFC 8449 extension; `opts.suites` narrows the suite advertisement.
/// Returns bytes written, equal to [`client_hello_len_with`]`(opts)`.
pub(crate) fn write_client_hello_with<W: Write>(
    out: &mut W,
    random: &[u8; 32],
    x25519_pub: &[u8; 32],
    opts: &ClientHelloOptions<'_>,
) -> Result<usize, ClientHelloError<W::Error>> {
    let hostname = opts.hostname;
    let host_len = hostname.map(|h| h.len()).unwrap_or(0);
    if host_len > u16::MAX as usize {
        return Err(ClientHelloError::HostnameTooLong);
    }
    // RFC 8449 §4: record_size_limit must be in [64, 2^14 + 1].
    if let Some(rsl) = opts.record_size_limit
        && !(64..=16385).contains(&rsl)
    {
        return Err(ClientHelloError::RecordSizeLimitOutOfRange);
    }

    // Compute the exact total in `usize` BEFORE any narrowing cast: an
    // oversized hostname can otherwise wrap intermediate `as u16` casts
    // and slip past the cap check, then panic on `host_len as u16` or
    // emit wrapped length fields on the wire. Route through the same
    // const helpers the public `client_hello_len_with` uses so the writer
    // and the sizer cannot drift on the new (RFC 8449 / `AesOnly`) paths.
    let total_len = client_hello_len_with(opts);
    if total_len > 5 + TLS_PLAINTEXT_MAX {
        return Err(ClientHelloError::MessageTooLong);
    }
    // Past this point every sub-length is provably ≤ total_len
    // ≤ 5 + 2^14 < u16::MAX, so the casts below are non-truncating.

    let sni_host_len = hostname.map(|h| h.len());
    let has_rsl = opts.record_size_limit.is_some();
    let n_suites = ch_n_suites(opts.suites);
    let extensions_total = ch_extensions_total(sni_host_len, has_rsl);
    let body_len = ch_body_len(opts.suites, sni_host_len, has_rsl);
    let hs_len = 4 + body_len;

    // ChaCha appears in the wire output iff the feature was compiled in
    // AND the runtime opts allow the default suite list. Only bound under
    // the feature — no-chacha20 builds have no use for the binding.
    #[cfg(feature = "chacha20")]
    let advertise_chacha = matches!(opts.suites, SuiteList::Default | SuiteList::ChaChaOnly);

    out.write_u8(CT_HANDSHAKE)?;
    out.write_u16(LEGACY_VERSION)?;
    out.write_u16(hs_len as u16)?;

    out.write_u8(HS_CLIENT_HELLO)?;
    out.write_u24(body_len as u32)?;

    out.write_u16(LEGACY_VERSION)?;
    out.write_all(random)?;
    out.write_u8(0)?;
    out.write_u16((2 * n_suites) as u16)?;
    // ChaCha first when offered (server preference convention). Then AES
    // when both `cipher-aes` is on and the suite list isn't ChaCha-only.
    #[cfg(feature = "chacha20")]
    if advertise_chacha {
        out.write_u16(CIPHER_CHACHA20_POLY1305_SHA256)?;
    }
    #[cfg(all(feature = "cipher-aes", feature = "chacha20"))]
    let advertise_aes = !matches!(opts.suites, SuiteList::ChaChaOnly);
    #[cfg(all(feature = "cipher-aes", not(feature = "chacha20")))]
    let advertise_aes = true;
    #[cfg(feature = "cipher-aes")]
    if advertise_aes {
        out.write_u16(CIPHER_AES_128_GCM_SHA256)?;
    }
    out.write_u8(1)?;
    out.write_u8(0)?;
    out.write_u16(extensions_total as u16)?;

    out.write_u16(EXT_SUPPORTED_VERSIONS)?;
    out.write_u16(3)?;
    out.write_u8(2)?;
    out.write_u16(TLS_1_3)?;

    out.write_u16(EXT_SUPPORTED_GROUPS)?;
    out.write_u16(4)?;
    out.write_u16(2)?;
    out.write_u16(KEY_SHARE_GROUP)?;

    out.write_u16(EXT_SIGNATURE_ALGORITHMS)?;
    out.write_u16(2 + 2 * SIG_SCHEME_COUNT)?; // ext_data: list_len field (2) + schemes
    out.write_u16(2 * SIG_SCHEME_COUNT)?; // supported_signature_algorithms list_len
    out.write_u16(SIG_SCHEME_ED25519)?;
    if cfg!(feature = "rsa") {
        out.write_u16(SIG_SCHEME_RSA_PSS_RSAE_SHA256)?;
    }
    if cfg!(feature = "mldsa") {
        out.write_u16(SIG_SCHEME_MLDSA44)?;
        out.write_u16(SIG_SCHEME_MLDSA65)?;
        out.write_u16(SIG_SCHEME_MLDSA87)?;
    }

    if let Some(h) = hostname {
        let host_len = h.len() as u16;
        let list_len: u16 = 1 + 2 + host_len;
        let ext_data_len: u16 = 2 + list_len;
        out.write_u16(EXT_SERVER_NAME)?;
        out.write_u16(ext_data_len)?;
        out.write_u16(list_len)?;
        out.write_u8(SNI_NAME_TYPE_HOST_NAME)?;
        out.write_u16(host_len)?;
        out.write_all(h)?;
    }

    if let Some(value) = opts.record_size_limit {
        out.write_u16(EXT_RECORD_SIZE_LIMIT)?;
        out.write_u16(2)?;
        out.write_u16(value)?;
    }

    // Single key_share entry. Under `mlkem` the key_exchange is
    // `ML-KEM-768 ek (1184) || X25519 pub (32)` (draft-ietf-tls-ecdhe-mlkem
    // orders ML-KEM first for X25519MLKEM768); otherwise just the X25519 pub.
    out.write_u16(EXT_KEY_SHARE)?;
    out.write_u16(KEY_SHARE_EXT_DATA_LEN)?;
    out.write_u16(KEY_SHARE_LIST_LEN)?;
    out.write_u16(KEY_SHARE_GROUP)?;
    out.write_u16(KEY_SHARE_KEY_LEN_U16)?;
    #[cfg(feature = "mlkem")]
    out.write_all(
        opts.mlkem_ek
            .ok_or(ClientHelloError::MissingMlKemKeyShare)?,
    )?;
    out.write_all(x25519_pub)?;

    Ok(total_len)
}

/// Parsed view of a ServerHello, with borrows into the caller's input.
///
/// Returned by [`parse_server_hello`]. Lifetime is tied to the input slice
/// so the random and X25519 share don't need to be copied out unless the
/// caller chooses to.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub(crate) struct ServerHelloView<'a> {
    /// `ServerHello.random` (32 bytes).
    pub random: &'a [u8; 32],
    /// Echoed `legacy_session_id` — empty in our profile.
    pub session_id_echo: &'a [u8],
    /// Selected cipher suite. `TLS_AES_128_GCM_SHA256` (`0x1301`), or
    /// `TLS_CHACHA20_POLY1305_SHA256` (`0x1303`) when `feature = "chacha20"`
    /// is enabled. Callers must dispatch their record-layer code on this value.
    pub cipher_suite: u16,
    /// Selected TLS version (from `supported_versions`). Validated to be `0x0304`.
    pub selected_version: u16,
    /// Server's ephemeral X25519 public key (32 bytes). Under `mlkem` this is
    /// the trailing 32 bytes of the `X25519MLKEM768` server key_share.
    pub x25519_share: &'a [u8; 32],
    /// ML-KEM-768 ciphertext from the `X25519MLKEM768` server key_share, to
    /// decapsulate against our ephemeral KEM keypair.
    #[cfg(feature = "mlkem")]
    pub mlkem_ct: &'a [u8; backends::mlkem::MLKEM768_CT_BYTES],
}

/// Parse a complete TLS record carrying a `server_hello` handshake message.
///
/// Validates the locked profile (TLS 1.3, x25519, AES-128-GCM-SHA256 or
/// `TLS_CHACHA20_POLY1305_SHA256` under `feature = "chacha20"`) and returns
/// a [`ServerHelloView`] borrowing into `input`.
pub(crate) fn parse_server_hello(input: &[u8]) -> Result<ServerHelloView<'_>, ParseError> {
    let mut r = Reader::new(input);

    let content_type = r.u8()?;
    if content_type != CT_HANDSHAKE {
        return Err(ParseError::UnexpectedContentType(content_type));
    }
    let record_version = r.u16()?;
    if record_version != LEGACY_VERSION {
        return Err(ParseError::UnexpectedLegacyVersion(record_version));
    }
    let record_body = r.vec_u16()?;
    if !r.at_end() {
        return Err(ParseError::TrailingBytes);
    }

    let mut hr = Reader::new(record_body);
    let hs_type = hr.u8()?;
    if hs_type != HS_SERVER_HELLO {
        return Err(ParseError::UnexpectedHandshakeType(hs_type));
    }
    let hs_body = hr.vec_u24()?;
    if !hr.at_end() {
        return Err(ParseError::LengthMismatch);
    }

    let mut b = Reader::new(hs_body);
    let legacy_version = b.u16()?;
    if legacy_version != LEGACY_VERSION {
        return Err(ParseError::UnexpectedLegacyVersion(legacy_version));
    }
    let random: &[u8; 32] = b.take_array()?;
    // HelloRetryRequest is signalled by a magic ServerHello.random value (RFC 8446
    // §4.1.3). Our locked profile already advertises the only group the server can
    // pick, so HRR can only mean misconfiguration or an attack — refuse it.
    if random == &HRR_RANDOM {
        return Err(ParseError::HelloRetryRequested);
    }
    // Downgrade sentinels live in the last 8 bytes of random. A real TLS 1.3-only
    // server would never emit these; an MITM forcing TLS 1.2 would.
    // Slice equality avoids the `try_into().expect(...)` path (the
    // sentinels are `&[u8; 8]`, comparing them against `&[u8]` works
    // via `PartialEq<[u8; 8]> for [u8]`).
    let suffix = &random[24..];
    if suffix == DOWNGRADE_TLS12 || suffix == DOWNGRADE_TLS11_OR_BELOW {
        return Err(ParseError::DowngradeDetected);
    }

    let session_id_echo = b.vec_u8()?;
    // We always send an empty legacy_session_id; the server MUST echo it back
    // unchanged (RFC 8446 §4.1.3).
    if !session_id_echo.is_empty() {
        return Err(ParseError::UnexpectedSessionIdEcho);
    }
    let cipher_suite = b.u16()?;
    let suite_accepted = cipher_suite == CIPHER_AES_128_GCM_SHA256
        || (cfg!(feature = "chacha20") && cipher_suite == CIPHER_CHACHA20_POLY1305_SHA256);
    if !suite_accepted {
        return Err(ParseError::UnsupportedCipherSuite(cipher_suite));
    }
    let compression = b.u8()?;
    if compression != 0 {
        return Err(ParseError::UnexpectedCompressionMethod(compression));
    }
    let ext_body = b.vec_u16()?;
    if !b.at_end() {
        return Err(ParseError::TrailingBytes);
    }

    let mut selected_version: Option<u16> = None;
    let mut x25519_share: Option<&[u8; 32]> = None;
    #[cfg(feature = "mlkem")]
    let mut mlkem_ct: Option<&[u8; backends::mlkem::MLKEM768_CT_BYTES]> = None;

    let mut e = Reader::new(ext_body);
    while !e.at_end() {
        let ext_type = e.u16()?;
        let ext_data = e.vec_u16()?;
        match ext_type {
            EXT_SUPPORTED_VERSIONS => {
                if selected_version.is_some() {
                    return Err(ParseError::DuplicateExtension(ext_type));
                }
                // In ServerHello, the body is exactly one ProtocolVersion (u16).
                if ext_data.len() != 2 {
                    return Err(ParseError::BadSupportedVersions);
                }
                let v = u16::from_be_bytes([ext_data[0], ext_data[1]]);
                if v != TLS_1_3 {
                    return Err(ParseError::BadSupportedVersions);
                }
                selected_version = Some(v);
            }
            EXT_KEY_SHARE => {
                if x25519_share.is_some() {
                    return Err(ParseError::DuplicateExtension(ext_type));
                }
                // ServerHello key_share: single KeyShareEntry = group(u16) + key(u16-len-prefixed).
                let mut kr = Reader::new(ext_data);
                let group = kr.u16()?;
                if group != KEY_SHARE_GROUP {
                    return Err(ParseError::BadKeyShare);
                }
                let key = kr.vec_u16()?;
                if !kr.at_end() {
                    return Err(ParseError::BadKeyShare);
                }
                // Under `mlkem` the key is `ML-KEM-768 ct (1088) || X25519 (32)`.
                #[cfg(feature = "mlkem")]
                {
                    let (ct, x) = key
                        .split_at_checked(backends::mlkem::MLKEM768_CT_BYTES)
                        .ok_or(ParseError::BadKeyShare)?;
                    mlkem_ct = Some(ct.try_into().map_err(|_| ParseError::BadKeyShare)?);
                    x25519_share = Some(x.try_into().map_err(|_| ParseError::BadKeyShare)?);
                }
                #[cfg(not(feature = "mlkem"))]
                {
                    x25519_share = Some(key.try_into().map_err(|_| ParseError::BadKeyShare)?);
                }
            }
            // RFC 8446 §4.1.4: a client that receives an unrecognized extension
            // in ServerHello MUST abort with `illegal_parameter`. We didn't ask
            // for it, so it shouldn't be here.
            _ => return Err(ParseError::UnknownExtension(ext_type)),
        }
    }

    Ok(ServerHelloView {
        random,
        session_id_echo,
        cipher_suite,
        selected_version: selected_version.ok_or(ParseError::BadSupportedVersions)?,
        x25519_share: x25519_share.ok_or(ParseError::BadKeyShare)?,
        #[cfg(feature = "mlkem")]
        mlkem_ct: mlkem_ct.ok_or(ParseError::BadKeyShare)?,
    })
}

struct Reader<'a> {
    buf: &'a [u8],
    pos: usize,
}

impl<'a> Reader<'a> {
    fn new(buf: &'a [u8]) -> Self {
        Self { buf, pos: 0 }
    }

    fn remaining(&self) -> usize {
        self.buf.len() - self.pos
    }

    fn at_end(&self) -> bool {
        self.pos == self.buf.len()
    }

    fn take(&mut self, n: usize) -> Result<&'a [u8], ParseError> {
        if self.remaining() < n {
            return Err(ParseError::Truncated);
        }
        let slice = &self.buf[self.pos..self.pos + n];
        self.pos += n;
        Ok(slice)
    }

    fn take_array<const N: usize>(&mut self) -> Result<&'a [u8; N], ParseError> {
        let slice = self.take(N)?;
        // `take(N)` guarantees `slice.len() == N`, so the `try_from` here
        // is statically infallible — but we map the error rather than
        // `expect(...)`-ing to keep the panic-machinery path out of the
        // binary. The dead `Err` arm is dropped by codegen.
        <&[u8; N]>::try_from(slice).map_err(|_| ParseError::Truncated)
    }

    fn u8(&mut self) -> Result<u8, ParseError> {
        Ok(self.take(1)?[0])
    }

    fn u16(&mut self) -> Result<u16, ParseError> {
        let bytes = self.take(2)?;
        Ok(u16::from_be_bytes([bytes[0], bytes[1]]))
    }

    fn u24(&mut self) -> Result<u32, ParseError> {
        let bytes = self.take(3)?;
        Ok(u32::from_be_bytes([0, bytes[0], bytes[1], bytes[2]]))
    }

    fn vec_u8(&mut self) -> Result<&'a [u8], ParseError> {
        let n = self.u8()? as usize;
        self.take(n)
    }

    fn vec_u16(&mut self) -> Result<&'a [u8], ParseError> {
        let n = self.u16()? as usize;
        self.take(n)
    }

    fn vec_u24(&mut self) -> Result<&'a [u8], ParseError> {
        // 24-bit lengths exceed `u16::MAX` and so don't fit a 16-bit `usize`.
        // Use `try_into` instead of `as usize` so the truncation surfaces
        // as a clean parse error rather than a silent slice underflow.
        let n: usize = self.u24()?.try_into().map_err(|_| ParseError::Truncated)?;
        self.take(n)
    }
}

#[cfg(test)]
mod tests;