Skip to main content

macula_rust/
handshake.rs

1//! The post-quantum connection handshake as wire 1.0 freezes it (WIRE_1_0.md
2//! section 4, macula#98): the opener, challenge, CONNECT, HELLO and status
3//! frames of DESIGN_PQ_HANDSHAKE_FRAMES and DESIGN_NEIGHBOUR_CHANNEL_BINDING, as
4//! CBOR bytes without the length prefix.
5//!
6//! The client opens with an opener naming the versions and profiles it speaks.
7//! The station picks the highest common version and the first profile in the
8//! client's list it supports, or refuses at once with a HELLO naming its own
9//! ranges. It answers with a challenge: the chosen version and profile, its own
10//! ranges, its carried identity key, its TLS binding and status statement, and
11//! a fresh nonce. The client checks the choice against both ranges (a downgrade
12//! is detected here), and the challenge against the node_id it dialed and the
13//! leaf it received, before it signs anything, and answers with CONNECT: its
14//! ranges again, the chosen profile, its identity and CONNECT keys, the CONNECT
15//! binding and status statement, and a proof (V3) by the CONNECT key over the
16//! session, the challenge and the CONNECT itself. The station checks CONNECT,
17//! the puzzle before any signature, and answers with HELLO, carrying its
18//! session proof (V2) over the HELLO tbs. Status frames renew a peer's
19//! statement on the open connection.
20//!
21//! Version 5 is the only version: every frame, before and after HELLO, carries
22//! it, and both ends bind to the TLS session through its exporter value E. Each
23//! end records the highest version it completed with each node_id and never
24//! completes below it.
25//!
26//! Every frame decodes under the decoding rule and must hold exactly the keys
27//! of its type, each of its type and length. Close reasons are local: a
28//! refusing station sends only a HELLO with one coarse refusal code.
29
30use std::fmt;
31use std::sync::Arc;
32
33use sha2::{Digest, Sha384};
34
35use crate::binding::{
36    verify_connect_binding, verify_status, verify_tls_binding, BindingError, SignedTbs,
37};
38use crate::cbor::{self, Value};
39use crate::node_key::{
40    carried_key_well_formed, node_id_of, puzzle_solved, signature_size, verify, KeyError, NodeKey,
41};
42use crate::profile::Profile;
43
44#[cfg(test)]
45mod tests;
46#[cfg(test)]
47mod wire_1_0_tests;
48
49/// The handshake and frame version of wire 1.0. A handshake version bump is a
50/// wire major: there is no version 6 inside wire 1.x.
51pub const VERSION: i64 = 5;
52
53/// The TLS 1.3 exporter label (RFC 8446 section 7.5) the handshake binds to,
54/// over the context client node_id || station node_id, 32 bytes.
55pub const EXPORTER_LABEL: &str = "EXPORTER-macula-session-v1";
56
57/// The capability bits CONNECT and HELLO carry (WIRE_1_0.md section 4.7): an
58/// unsigned integer below 2^53, bits 0 to [`MAX_CAPABILITY_BIT`] reserved. Bits
59/// never retire and are never reassigned; an unknown bit is ignored. Each one
60/// states its behaviour towards a peer that lacks it and towards one that
61/// claims it falsely: waste, never loss, both ways.
62pub const CAP_STATION: u64 = 1 << 0;
63/// A station that answers SWIM indirect probes.
64pub const CAP_SWIM_INDIRECT: u64 = 1 << 1;
65/// A peer that reads the relay code `overloaded` (macula#54), which a station
66/// sends only to a peer that declares it.
67pub const CAP_RELAY_OVERLOADED: u64 = 1 << 2;
68/// The highest bit of the reserved range.
69pub const MAX_CAPABILITY_BIT: u32 = 52;
70
71const NONCE_SIZE: usize = 32;
72const EXPORTER_SIZE: usize = 32;
73const MAX_PROTOCOL_INT: i128 = 1 << 53;
74const MLDSA_KEY_SIZE: usize = 2592;
75const MAX_PROFILES: usize = 8;
76const MAX_REFUSAL_CODE_BYTES: usize = 64;
77const CONNECT_PROOF_LABEL: &[u8] = b"MACULA-PQ-CONNECT-PROOF-V3";
78const SESSION_PROOF_LABEL: &[u8] = b"MACULA-PQ-SESSION-PROOF-V2";
79
80/// A TLS 1.3 session's exporter: label, context and length to bytes, or
81/// `None` when the session gives none. Both ends of one session export the
82/// same bytes.
83pub type Exporter = dyn Fn(&str, &[u8], usize) -> Option<Vec<u8>> + Send + Sync;
84
85/// The highest handshake version an end last completed with a node_id, 0 for
86/// none (WIRE_1_0.md section 4.6). It survives restarts where the host can
87/// write, and lasts the run where it cannot.
88pub type Floor = dyn Fn(&[u8; 32]) -> i64 + Send + Sync;
89
90const OPENER_KEYS: &[&str] = &["frame_type", "profiles", "version", "versions"];
91const CHALLENGE_KEYS: &[&str] = &[
92    "frame_type",
93    "identity_key",
94    "nonce",
95    "profile",
96    "profiles",
97    "tls_binding",
98    "tls_status",
99    "version",
100    "versions",
101];
102/// CONNECT always holds member_endorsement, empty when the node has none: one
103/// layout, so the wire does not tell whether a node holds an endorsement or a
104/// station asks for one.
105const CONNECT_KEYS: &[&str] = &[
106    "capabilities",
107    "connect_binding",
108    "connect_key",
109    "connect_status",
110    "frame_type",
111    "identity_key",
112    "member_endorsement",
113    "profile",
114    "profiles",
115    "proof",
116    "version",
117    "versions",
118];
119const HELLO_PROVED_KEYS: &[&str] = &[
120    "accepted",
121    "capabilities",
122    "frame_type",
123    "session_proof",
124    "version",
125];
126const HELLO_ACCEPTED_KEYS: &[&str] = &["accepted", "capabilities", "frame_type", "version"];
127const HELLO_REFUSED_KEYS: &[&str] = &[
128    "accepted",
129    "capabilities",
130    "frame_type",
131    "refusal_code",
132    "version",
133];
134const HELLO_EARLY_KEYS: &[&str] = &[
135    "accepted",
136    "frame_type",
137    "profiles",
138    "refusal_code",
139    "version",
140    "versions",
141];
142
143/// The one coarse reason a refusing HELLO carries.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum RefusalCode {
146    /// Ranges that share no version, or a frame of another version.
147    UnsupportedVersion,
148    /// Profile lists that share no profile (WIRE_1_0.md section 4.4).
149    ProfileUnsupported,
150    /// A node_id that misses the puzzle, which the client can check itself.
151    PuzzleInvalid,
152    /// A CONNECT past the station's session proof budget.
153    SessionProofRate,
154    /// A CONNECT that failed any other check. A refusal code this crate does
155    /// not know reads as this one.
156    NotAccepted,
157}
158
159impl RefusalCode {
160    const ALL: [RefusalCode; 5] = [
161        RefusalCode::UnsupportedVersion,
162        RefusalCode::ProfileUnsupported,
163        RefusalCode::PuzzleInvalid,
164        RefusalCode::SessionProofRate,
165        RefusalCode::NotAccepted,
166    ];
167
168    pub fn name(self) -> &'static str {
169        match self {
170            RefusalCode::UnsupportedVersion => "unsupported_version",
171            RefusalCode::ProfileUnsupported => "profile_unsupported",
172            RefusalCode::PuzzleInvalid => "puzzle_invalid",
173            RefusalCode::SessionProofRate => "session_proof_rate",
174            RefusalCode::NotAccepted => "not_accepted",
175        }
176    }
177
178    /// A HELLO's refusal_code, a per-hop enum (WIRE_1_0.md section 3.4): one
179    /// this crate does not know reads as `not_accepted`, counted.
180    fn read(name: &str) -> RefusalCode {
181        RefusalCode::ALL
182            .into_iter()
183            .find(|code| code.name() == name)
184            .unwrap_or_else(|| {
185                crate::frame::count_enum_fallback("refusal_code");
186                RefusalCode::NotAccepted
187            })
188    }
189}
190
191/// What one end speaks: the versions `[min, max]` and its profiles by name,
192/// in its order of preference, 1 to 8 of them. A peer may name profiles this
193/// crate does not know.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct Ranges {
196    pub min_version: i64,
197    pub max_version: i64,
198    pub profiles: Vec<String>,
199}
200
201impl Ranges {
202    /// This crate's ranges for `profile`: version 5 alone, the policy of N
203    /// and N-1 holding 5 alone in wire 1.0.
204    pub fn supported(profile: Profile) -> Ranges {
205        Ranges {
206            min_version: VERSION,
207            max_version: VERSION,
208            profiles: vec![profile.name().to_string()],
209        }
210    }
211
212    fn entries(&self) -> Vec<(Value, Value)> {
213        vec![
214            entry(
215                "versions",
216                Value::List(vec![
217                    Value::Int(i128::from(self.min_version)),
218                    Value::Int(i128::from(self.max_version)),
219                ]),
220            ),
221            entry(
222                "profiles",
223                Value::List(self.profiles.iter().map(Value::text).collect()),
224            ),
225        ]
226    }
227}
228
229/// The handshake's close reasons, named as macula names them. A binding or
230/// status statement that fails its check closes with its [`BindingError`].
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub enum HandshakeError {
233    /// A frame of another type than the one expected next.
234    UnexpectedFrame,
235    /// A frame of another version than 5, or ranges that share no version
236    /// (`unsupported_version`).
237    UnsupportedVersion,
238    /// Profile lists that share no profile, or a CONNECT naming a profile the
239    /// station did not choose (`profile_unsupported`).
240    ProfileUnsupported,
241    /// A challenge whose chosen version or profile is not the highest common
242    /// one of the client's ranges and the station's repeated ranges
243    /// (`downgrade_detected`).
244    DowngradeDetected,
245    /// A CONNECT whose versions or profiles differ from the opener's
246    /// (`negotiation_mismatch`).
247    NegotiationMismatch,
248    /// A chosen version below the one this end last completed with that
249    /// node_id (`version_below_floor`).
250    VersionBelowFloor,
251    /// A frame that does not decode exactly, or a carried key or proof of the
252    /// wrong form (`malformed_frame`).
253    Malformed,
254    /// A key that would serve two purposes: a CONNECT key that shares a half
255    /// with its identity key, or a key found in the leaf.
256    KeyPurposeReuse,
257    /// A station whose node_id is not the one dialed.
258    PeerIdentityMismatch {
259        expected: [u8; 32],
260        derived: [u8; 32],
261    },
262    /// A client whose node_id does not meet the puzzle.
263    PuzzleInvalid,
264    /// A CONNECT proof that does not verify.
265    ProofInvalid,
266    /// A HELLO that refuses the connection, with its code, and the station's
267    /// ranges when it refused before the challenge.
268    Refused {
269        code: RefusalCode,
270        station_ranges: Option<Ranges>,
271    },
272    /// A station session with a puzzle difficulty the design does not have.
273    InvalidStationSession,
274    /// A HELLO whose session proof does not verify under the station's
275    /// identity key over this session.
276    SessionProofInvalid,
277    /// A HELLO that accepts without a session proof.
278    SessionProofMissing,
279    /// A station past its session proof budget.
280    SessionProofRate,
281    /// A session whose TLS exporter gave no value.
282    ExporterUnavailable,
283    /// A binding or status statement that did not verify.
284    Binding(BindingError),
285    /// A key that could not sign, or randomness that could not be drawn.
286    Key(KeyError),
287}
288
289impl fmt::Display for HandshakeError {
290    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291        match self {
292            HandshakeError::UnexpectedFrame => f.write_str("unexpected frame"),
293            HandshakeError::UnsupportedVersion => f.write_str("unsupported_version"),
294            HandshakeError::ProfileUnsupported => f.write_str("profile_unsupported"),
295            HandshakeError::DowngradeDetected => f.write_str("downgrade_detected"),
296            HandshakeError::NegotiationMismatch => f.write_str("negotiation_mismatch"),
297            HandshakeError::VersionBelowFloor => f.write_str("version_below_floor"),
298            HandshakeError::Malformed => f.write_str("malformed frame"),
299            HandshakeError::KeyPurposeReuse => f.write_str("a key would serve two purposes"),
300            HandshakeError::PeerIdentityMismatch { expected, derived } => write!(
301                f,
302                "dialed node_id {}, but the station's key derives {}",
303                hex_of(expected),
304                hex_of(derived)
305            ),
306            HandshakeError::PuzzleInvalid => f.write_str("the node_id does not meet the puzzle"),
307            HandshakeError::ProofInvalid => f.write_str("the CONNECT proof does not verify"),
308            HandshakeError::Refused { code, .. } => {
309                write!(f, "the station refused the connection: {}", code.name())
310            }
311            HandshakeError::InvalidStationSession => {
312                f.write_str("the station session has an unknown puzzle difficulty")
313            }
314            HandshakeError::SessionProofInvalid => f.write_str("the session proof does not verify"),
315            HandshakeError::SessionProofMissing => {
316                f.write_str("the HELLO carries no session proof")
317            }
318            HandshakeError::SessionProofRate => {
319                f.write_str("the station's session proof budget is spent")
320            }
321            HandshakeError::ExporterUnavailable => f.write_str("the TLS exporter is unavailable"),
322            HandshakeError::Binding(e) => write!(f, "{e}"),
323            HandshakeError::Key(e) => write!(f, "{e}"),
324        }
325    }
326}
327
328impl std::error::Error for HandshakeError {}
329
330impl From<BindingError> for HandshakeError {
331    fn from(e: BindingError) -> Self {
332        HandshakeError::Binding(e)
333    }
334}
335
336impl HandshakeError {
337    /// The coarse code a refusing HELLO carries for this close reason.
338    fn wire_refusal(&self) -> RefusalCode {
339        match self {
340            HandshakeError::UnsupportedVersion => RefusalCode::UnsupportedVersion,
341            HandshakeError::PuzzleInvalid => RefusalCode::PuzzleInvalid,
342            HandshakeError::SessionProofRate => RefusalCode::SessionProofRate,
343            _ => RefusalCode::NotAccepted,
344        }
345    }
346}
347
348/// How a station treats a client's node_id puzzle.
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350pub enum PuzzleMode {
351    /// The puzzle is not checked.
352    Off,
353    /// An unsolved puzzle is accepted and reported.
354    LogOnly,
355    /// An unsolved puzzle is refused.
356    Enforce,
357}
358
359/// What a station found of a client's puzzle.
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum PuzzleResult {
362    Solved,
363    Unsolved,
364    NotChecked,
365}
366
367/// The version and profile a station chooses for a client: the highest
368/// version in both ranges and this crate's, and the first profile in the
369/// client's list the station supports.
370fn negotiate(client: &Ranges, station: &Ranges) -> Result<(i64, String), HandshakeError> {
371    let version = client.max_version.min(station.max_version).min(VERSION);
372    if version < client.min_version.max(station.min_version) {
373        return Err(HandshakeError::UnsupportedVersion);
374    }
375    client
376        .profiles
377        .iter()
378        .find(|p| station.profiles.contains(p))
379        .map(|p| (version, p.clone()))
380        .ok_or(HandshakeError::ProfileUnsupported)
381}
382
383/// The client's first frame on the control stream: its ranges, and nothing
384/// that relates to identity.
385pub fn opener(ranges: &Ranges) -> Vec<u8> {
386    encode_frame("opener", ranges.entries())
387}
388
389/// What a station chose for a client from its opener: the version and
390/// profile, and the client's ranges, which its CONNECT must repeat.
391#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct Negotiated {
393    pub version: i64,
394    pub profile: String,
395    pub client: Ranges,
396}
397
398/// The station's reading of the first frame against its own ranges, and the
399/// refusing HELLO to send before closing when there is one. A frame that is
400/// not an opener of exactly its keys is [`HandshakeError::Malformed`], with no
401/// HELLO. Ranges that share no version, or an opener of another version, are
402/// `unsupported_version`, and lists that share no profile
403/// `profile_unsupported`; each comes with a HELLO naming the station's
404/// ranges.
405pub fn read_opener(
406    opener: &[u8],
407    station: &Ranges,
408) -> (Result<Negotiated, HandshakeError>, Option<Vec<u8>>) {
409    let read = decode(opener, "opener", &[OPENER_KEYS]).and_then(|f| {
410        let client = f.ranges();
411        let (version, profile) = negotiate(&client, station)?;
412        Ok(Negotiated {
413            version,
414            profile,
415            client,
416        })
417    });
418    match &read {
419        Err(e @ (HandshakeError::UnsupportedVersion | HandshakeError::ProfileUnsupported)) => {
420            let hello = hello_early_refusal(e, station);
421            (read, Some(hello))
422        }
423        _ => (read, None),
424    }
425}
426
427/// What a station precomputes for its challenges: its carried identity key,
428/// and the TLS binding and status statement for the leaf it presents.
429#[derive(Debug, Clone)]
430pub struct StationMaterial {
431    pub profile: Profile,
432    pub identity_key: Vec<u8>,
433    pub tls_binding: SignedTbs,
434    pub tls_status: SignedTbs,
435}
436
437/// A station's challenge, with a fresh nonce: the version and profile it
438/// chose, and its own ranges. The station keeps the bytes it sends, for the
439/// proof check.
440pub fn challenge(
441    m: &StationMaterial,
442    n: &Negotiated,
443    station: &Ranges,
444) -> Result<Vec<u8>, HandshakeError> {
445    let mut nonce = [0u8; NONCE_SIZE];
446    aws_lc_rs::rand::fill(&mut nonce)
447        .map_err(|_| HandshakeError::Key(KeyError::RandomnessUnavailable))?;
448    let mut entries = station.entries();
449    entries.extend([
450        entry("nonce", Value::Bytes(nonce.to_vec())),
451        entry("profile", Value::text(n.profile.as_str())),
452        entry("identity_key", Value::Bytes(m.identity_key.clone())),
453        entry("tls_binding", m.tls_binding.to_value()),
454        entry("tls_status", m.tls_status.to_value()),
455    ]);
456    Ok(encode_frame("challenge", entries))
457}
458
459/// What a client brings to a handshake: its profile, the node_id it dialed,
460/// the leaf DER it received in this TLS handshake, its carried identity key,
461/// its CONNECT key with binding and status statement, its capability bits,
462/// the time in milliseconds, the realm membership endorsement CONNECT carries
463/// (empty for a node that holds none), the ranges its opener carried, its
464/// floor per station, and this connection's TLS exporter.
465pub struct ClientSession<'a> {
466    pub profile: Profile,
467    pub expected_node_id: [u8; 32],
468    pub leaf: &'a [u8],
469    pub identity_key: Vec<u8>,
470    pub connect_key: &'a NodeKey,
471    pub connect_binding: &'a SignedTbs,
472    pub connect_status: &'a SignedTbs,
473    pub capabilities: u64,
474    pub now_ms: i64,
475    pub member_endorsement: Vec<u8>,
476    pub ranges: Ranges,
477    pub floor: &'a Floor,
478    pub export: &'a Exporter,
479}
480
481/// What a client checks a challenge against: [`ClientSession`]'s parts that
482/// the checks read, with the CONNECT key as carried, so a transcript can be
483/// replayed without private keys.
484struct ChallengeCheck<'a> {
485    profile: Profile,
486    expected_node_id: [u8; 32],
487    leaf: &'a [u8],
488    identity_key: &'a [u8],
489    connect_public: &'a [u8],
490    now_ms: i64,
491    ranges: &'a Ranges,
492    floor: &'a Floor,
493    export: &'a Exporter,
494}
495
496/// What a client knows of the station once it has checked the challenge, and
497/// what its HELLO must answer.
498#[derive(Debug, Clone, PartialEq, Eq)]
499pub struct Station {
500    pub node_id: [u8; 32],
501    pub identity_key: Vec<u8>,
502    pub tls_binding: SignedTbs,
503    pub status_expires_at: i64,
504    pub binding_not_after: i64,
505    /// The version the station chose.
506    pub version: i64,
507    profile: Profile,
508    nonce: Vec<u8>,
509    leaf: Vec<u8>,
510    exporter_value: Vec<u8>,
511    challenge: Vec<u8>,
512    connect: Vec<u8>,
513    client_node_id: [u8; 32],
514}
515
516impl Station {
517    /// The client proof V3 over this session and the CONNECT tbs.
518    fn proof_message(&self, connect_tbs: &[u8]) -> Vec<u8> {
519        proof_message(
520            &self.nonce,
521            &self.node_id,
522            &self.client_node_id,
523            &self.leaf,
524            &self.challenge,
525            &self.exporter_value,
526            connect_tbs,
527        )
528    }
529}
530
531/// Checks a challenge, in macula's order, and signs nothing: the frame; the
532/// chosen version and profile against the client's ranges and the station's
533/// repeated ranges (`downgrade_detected`), and against the floor for the
534/// node_id dialed (`version_below_floor`); the station's carried key in the
535/// chosen profile; that each key in view serves one purpose; the station's
536/// node_id against the one dialed; the TLS binding against the leaf received;
537/// the status statement; and the session's exporter value.
538fn read_challenge(challenge: &[u8], s: &ChallengeCheck<'_>) -> Result<Station, HandshakeError> {
539    let f = decode(challenge, "challenge", &[CHALLENGE_KEYS])
540        .map_err(|e| refusal_before_challenge(challenge).unwrap_or(e))?;
541    let negotiated = negotiate(s.ranges, &f.ranges()).ok();
542    let chosen = negotiated.filter(|(version, profile)| {
543        i128::from(*version) == f.int("version")
544            && profile == f.text("profile")
545            && profile == s.profile.name()
546    });
547    let Some((version, _)) = chosen else {
548        return Err(HandshakeError::DowngradeDetected);
549    };
550    if version < (s.floor)(&s.expected_node_id) {
551        return Err(HandshakeError::VersionBelowFloor);
552    }
553    let station_key = f.bytes("identity_key");
554    if !carried_key_well_formed(station_key, s.profile) {
555        return Err(HandshakeError::Malformed);
556    }
557    if shares_a_half(s.identity_key, s.connect_public)
558        || in_leaf(station_key, s.leaf)
559        || in_leaf(s.connect_public, s.leaf)
560    {
561        return Err(HandshakeError::KeyPurposeReuse);
562    }
563    let station_node_id = node_id_of(station_key, s.profile);
564    if station_node_id != s.expected_node_id {
565        return Err(HandshakeError::PeerIdentityMismatch {
566            expected: s.expected_node_id,
567            derived: station_node_id,
568        });
569    }
570    let tls_binding = f.signed("tls_binding");
571    let binding = verify_tls_binding(&tls_binding, station_key, s.profile, s.leaf, s.now_ms)?;
572    let status = f.signed("tls_status");
573    let expires_at = verify_status(&status, &tls_binding, station_key, s.profile, s.now_ms)?;
574    let client_node_id = node_id_of(s.identity_key, s.profile);
575    let e = session_exported(s.export, &client_node_id, &station_node_id)?;
576    Ok(Station {
577        node_id: station_node_id,
578        identity_key: station_key.to_vec(),
579        tls_binding,
580        status_expires_at: expires_at,
581        binding_not_after: binding.not_after,
582        version,
583        profile: s.profile,
584        nonce: f.bytes("nonce").to_vec(),
585        leaf: s.leaf.to_vec(),
586        exporter_value: e,
587        challenge: challenge.to_vec(),
588        connect: Vec::new(),
589        client_node_id,
590    })
591}
592
593/// Checks a challenge and, when every check passes, returns the CONNECT to
594/// send, with its proof V3 by the CONNECT key. A refused challenge produces no
595/// CONNECT, and nothing is signed before every check has passed.
596pub fn answer_challenge(
597    challenge: &[u8],
598    s: &ClientSession<'_>,
599) -> Result<(Vec<u8>, Station), HandshakeError> {
600    let connect_public = s.connect_key.public_key();
601    let mut station = read_challenge(
602        challenge,
603        &ChallengeCheck {
604            profile: s.profile,
605            expected_node_id: s.expected_node_id,
606            leaf: s.leaf,
607            identity_key: &s.identity_key,
608            connect_public: &connect_public,
609            now_ms: s.now_ms,
610            ranges: &s.ranges,
611            floor: s.floor,
612            export: s.export,
613        },
614    )?;
615    let mut entries = s.ranges.entries();
616    entries.extend([
617        entry("profile", Value::text(s.profile.name())),
618        entry("identity_key", Value::Bytes(s.identity_key.clone())),
619        entry("connect_key", Value::Bytes(connect_public)),
620        entry("connect_binding", s.connect_binding.to_value()),
621        entry("connect_status", s.connect_status.to_value()),
622        entry("capabilities", Value::Int(i128::from(s.capabilities))),
623        entry(
624            "member_endorsement",
625            Value::Bytes(s.member_endorsement.clone()),
626        ),
627    ]);
628    let with_proof = |proof: Vec<u8>| {
629        let mut all = entries.clone();
630        all.push(entry("proof", Value::Bytes(proof)));
631        encode_frame("connect", all)
632    };
633    let tbs = with_proof(Vec::new());
634    let proof = s
635        .connect_key
636        .sign(&station.proof_message(&tbs))
637        .map_err(HandshakeError::Key)?;
638    station.connect = with_proof(proof);
639    Ok((station.connect.clone(), station))
640}
641
642/// E for the session, with the client's node_id first in the context (the
643/// initiator's).
644fn session_exported(
645    export: &Exporter,
646    client_node_id: &[u8; 32],
647    station_node_id: &[u8; 32],
648) -> Result<Vec<u8>, HandshakeError> {
649    let context = [client_node_id.as_slice(), station_node_id].concat();
650    match export(EXPORTER_LABEL, &context, EXPORTER_SIZE) {
651        Some(e) if e.len() == EXPORTER_SIZE => Ok(e),
652        _ => Err(HandshakeError::ExporterUnavailable),
653    }
654}
655
656/// Signs a session proof with the station's identity key for the client
657/// named, within the station's budget ([`HandshakeError::SessionProofRate`]
658/// past it).
659pub type SessionProofSigner =
660    dyn Fn(&[u8; 32], &[u8]) -> Result<Vec<u8>, HandshakeError> + Send + Sync;
661
662/// What a station brings to a CONNECT check: its profile, the negotiation its
663/// challenge carried and the challenge bytes it sent, the leaf DER this
664/// connection presented, its puzzle difficulty and mode, its capability bits,
665/// the time in milliseconds, its floor per client, its TLS exporter, and its
666/// session proof signer.
667#[derive(Clone)]
668pub struct StationSession {
669    pub profile: Profile,
670    pub negotiated: Negotiated,
671    pub challenge: Vec<u8>,
672    pub leaf: Vec<u8>,
673    pub puzzle_difficulty: u32,
674    pub puzzle_mode: PuzzleMode,
675    pub capabilities: u64,
676    pub now_ms: i64,
677    pub floor: Arc<Floor>,
678    pub export: Arc<Exporter>,
679    pub sign_session_proof: Arc<SessionProofSigner>,
680}
681
682impl fmt::Debug for StationSession {
683    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
684        f.debug_struct("StationSession")
685            .field("profile", &self.profile)
686            .field("negotiated", &self.negotiated)
687            .finish_non_exhaustive()
688    }
689}
690
691/// What a station knows of an accepted client.
692#[derive(Debug, Clone, PartialEq, Eq)]
693pub struct Client {
694    pub node_id: [u8; 32],
695    pub identity_key: Vec<u8>,
696    pub connect_key: Vec<u8>,
697    pub connect_binding: SignedTbs,
698    pub capabilities: u64,
699    pub status_expires_at: i64,
700    pub binding_not_after: i64,
701    pub puzzle: PuzzleResult,
702    /// The endorsement the CONNECT carried, empty when the client holds none.
703    /// Nothing here checks it: that is the station's policy.
704    pub member_endorsement: Vec<u8>,
705    /// The version the connection completed in.
706    pub version: i64,
707}
708
709/// Checks a CONNECT and returns the verdict with the HELLO to send. It
710/// checks, in macula's order: the frame; the client's ranges against its
711/// opener's (`negotiation_mismatch`) and the profile against the one chosen
712/// (`profile_unsupported`); the carried keys and the proof's length; that each
713/// key serves one purpose; the puzzle on the derived node_id before any
714/// signature; the floor for that node_id; the CONNECT binding and status
715/// statement; and the proof V3 against the challenge this station sent, the
716/// leaf it presented, the session and the CONNECT tbs. It signs the session
717/// proof only once every check has passed. On a refusal the error is the
718/// local close reason, and the HELLO refuses with one coarse code.
719pub fn accept_connect(
720    connect: &[u8],
721    s: &StationSession,
722) -> (Result<Client, HandshakeError>, Vec<u8>) {
723    match check_connect(connect, s) {
724        Ok((client, session_proof)) => {
725            let hello = hello_accepted(s.capabilities, &session_proof);
726            (Ok(client), hello)
727        }
728        Err(e) => {
729            let hello = hello_refused(e.wire_refusal());
730            (Err(e), hello)
731        }
732    }
733}
734
735/// The client and its session proof.
736fn check_connect(connect: &[u8], s: &StationSession) -> Result<(Client, Vec<u8>), HandshakeError> {
737    if s.puzzle_difficulty > 256 {
738        return Err(HandshakeError::InvalidStationSession);
739    }
740    let f = decode(connect, "connect", &[CONNECT_KEYS])?;
741    if f.ranges() != s.negotiated.client {
742        return Err(HandshakeError::NegotiationMismatch);
743    }
744    if f.text("profile") != s.negotiated.profile || s.negotiated.profile != s.profile.name() {
745        return Err(HandshakeError::ProfileUnsupported);
746    }
747    let (identity_key, connect_key, proof) = (
748        f.bytes("identity_key"),
749        f.bytes("connect_key"),
750        f.bytes("proof"),
751    );
752    check_carried_keys(identity_key, connect_key, proof, s)?;
753    let node_id = node_id_of(identity_key, s.profile);
754    let puzzle = puzzle_verdict(&node_id, s)?;
755    if s.negotiated.version < (s.floor)(&node_id) {
756        return Err(HandshakeError::VersionBelowFloor);
757    }
758    let (connect_binding, binding_not_after, expires_at) =
759        verified_connect_statements(&f, identity_key, connect_key, s)?;
760    let session_proof = proved_session(connect, proof, connect_key, &node_id, s)?;
761    Ok((
762        Client {
763            node_id,
764            identity_key: identity_key.to_vec(),
765            connect_key: connect_key.to_vec(),
766            connect_binding,
767            capabilities: f.uint("capabilities"),
768            status_expires_at: expires_at,
769            binding_not_after,
770            puzzle,
771            member_endorsement: f.bytes("member_endorsement").to_vec(),
772            version: s.negotiated.version,
773        },
774        session_proof,
775    ))
776}
777
778/// The CONNECT proof V3 checked against the challenge this station sent, the
779/// leaf it presented, the session with the client `node_id` and the CONNECT
780/// tbs; then, and only then, the session proof V2 over the HELLO tbs, signed.
781fn proved_session(
782    connect: &[u8],
783    proof: &[u8],
784    connect_key: &[u8],
785    node_id: &[u8; 32],
786    s: &StationSession,
787) -> Result<Vec<u8>, HandshakeError> {
788    let challenge = decode(&s.challenge, "challenge", &[CHALLENGE_KEYS])
789        .map_err(|_| HandshakeError::ProofInvalid)?;
790    let station_node_id = node_id_of(challenge.bytes("identity_key"), s.profile);
791    let e = session_exported(s.export.as_ref(), node_id, &station_node_id)?;
792    let tbs = frame_tbs(connect, "proof")?;
793    let message = proof_message(
794        challenge.bytes("nonce"),
795        &station_node_id,
796        node_id,
797        &s.leaf,
798        &s.challenge,
799        &e,
800        &tbs,
801    );
802    if !verify(&message, proof, connect_key, s.profile) {
803        return Err(HandshakeError::ProofInvalid);
804    }
805    let hello_tbs = hello_accepted(s.capabilities, &[]);
806    (s.sign_session_proof)(
807        node_id,
808        &session_proof_message(
809            &e,
810            &s.challenge,
811            connect,
812            &station_node_id,
813            node_id,
814            &hello_tbs,
815        ),
816    )
817}
818
819/// The CONNECT's carried keys and proof length: each key well formed and the
820/// proof a signature's length, then each key serving one purpose.
821fn check_carried_keys(
822    identity_key: &[u8],
823    connect_key: &[u8],
824    proof: &[u8],
825    s: &StationSession,
826) -> Result<(), HandshakeError> {
827    if !carried_key_well_formed(identity_key, s.profile)
828        || !carried_key_well_formed(connect_key, s.profile)
829        || proof.len() != signature_size(s.profile)
830    {
831        return Err(HandshakeError::Malformed);
832    }
833    if shares_a_half(identity_key, connect_key) || in_leaf(connect_key, &s.leaf) {
834        return Err(HandshakeError::KeyPurposeReuse);
835    }
836    Ok(())
837}
838
839/// The puzzle on the derived `node_id` in the station's mode, refused when
840/// unsolved and the station enforces it.
841fn puzzle_verdict(node_id: &[u8; 32], s: &StationSession) -> Result<PuzzleResult, HandshakeError> {
842    let puzzle = match s.puzzle_mode {
843        PuzzleMode::Off => PuzzleResult::NotChecked,
844        _ if puzzle_solved(node_id, s.puzzle_difficulty) => PuzzleResult::Solved,
845        _ => PuzzleResult::Unsolved,
846    };
847    if puzzle == PuzzleResult::Unsolved && s.puzzle_mode == PuzzleMode::Enforce {
848        return Err(HandshakeError::PuzzleInvalid);
849    }
850    Ok(puzzle)
851}
852
853/// The CONNECT binding and its status statement, verified at the station's
854/// time: the binding, its not_after, and the statement's expiry.
855fn verified_connect_statements(
856    f: &Fields,
857    identity_key: &[u8],
858    connect_key: &[u8],
859    s: &StationSession,
860) -> Result<(SignedTbs, i64, i64), HandshakeError> {
861    let connect_binding = f.signed("connect_binding");
862    let binding = verify_connect_binding(
863        &connect_binding,
864        identity_key,
865        s.profile,
866        connect_key,
867        s.now_ms,
868    )?;
869    let expires_at = verify_status(
870        &f.signed("connect_status"),
871        &connect_binding,
872        identity_key,
873        s.profile,
874        s.now_ms,
875    )?;
876    Ok((connect_binding, binding.not_after, expires_at))
877}
878
879/// A received frame with its signature field as the zero-length byte string,
880/// re-encoded deterministically: the CONNECT tbs a proof covers (WIRE_1_0.md
881/// section 4.2) or the HELLO tbs a session proof covers (4.3). A frame whose
882/// received bytes are not its own deterministic encoding is malformed, so the
883/// rebuild is exact.
884fn frame_tbs(raw: &[u8], signature_key: &str) -> Result<Vec<u8>, HandshakeError> {
885    let v = cbor::decode(raw).map_err(|_| HandshakeError::Malformed)?;
886    if cbor::encode(&v).ok().as_deref() != Some(raw) {
887        return Err(HandshakeError::Malformed);
888    }
889    let rebuilt = v.with_field(signature_key, Value::Bytes(Vec::new()));
890    cbor::encode(&rebuilt).map_err(|_| HandshakeError::Malformed)
891}
892
893/// What the client proof V3 signs (WIRE_1_0.md section 4.2): the label, a
894/// zero byte, the nonce, the station's and the client's node_ids, the SHA-384
895/// of the leaf DER and of the challenge bytes as received, E, and the SHA-384
896/// of the CONNECT tbs. Every field has a fixed width, so no two field
897/// sequences encode to the same bytes.
898fn proof_message(
899    nonce: &[u8],
900    station_node_id: &[u8; 32],
901    client_node_id: &[u8; 32],
902    leaf: &[u8],
903    challenge: &[u8],
904    e: &[u8],
905    connect_tbs: &[u8],
906) -> Vec<u8> {
907    let mut out = Vec::with_capacity(CONNECT_PROOF_LABEL.len() + 1 + NONCE_SIZE + 64 + 144 + 32);
908    out.extend_from_slice(CONNECT_PROOF_LABEL);
909    out.push(0);
910    out.extend_from_slice(nonce);
911    out.extend_from_slice(station_node_id);
912    out.extend_from_slice(client_node_id);
913    out.extend_from_slice(&Sha384::digest(leaf));
914    out.extend_from_slice(&Sha384::digest(challenge));
915    out.extend_from_slice(e);
916    out.extend_from_slice(&Sha384::digest(connect_tbs));
917    out
918}
919
920/// What the station's session proof V2 signs (WIRE_1_0.md section 4.3): the
921/// label, a zero byte, E, the SHA-384 of the challenge and of CONNECT, the
922/// station's and the client's node_ids, and the SHA-384 of the HELLO tbs,
923/// which covers the station's capabilities at any width and accepted. The
924/// SHA-384 of the challenge covers the chosen version and profile and the
925/// station's ranges, and that of CONNECT the client's.
926fn session_proof_message(
927    e: &[u8],
928    challenge: &[u8],
929    connect: &[u8],
930    station_node_id: &[u8; 32],
931    client_node_id: &[u8; 32],
932    hello_tbs: &[u8],
933) -> Vec<u8> {
934    let mut out = Vec::with_capacity(SESSION_PROOF_LABEL.len() + 1 + EXPORTER_SIZE + 144 + 64);
935    out.extend_from_slice(SESSION_PROOF_LABEL);
936    out.push(0);
937    out.extend_from_slice(e);
938    out.extend_from_slice(&Sha384::digest(challenge));
939    out.extend_from_slice(&Sha384::digest(connect));
940    out.extend_from_slice(station_node_id);
941    out.extend_from_slice(client_node_id);
942    out.extend_from_slice(&Sha384::digest(hello_tbs));
943    out
944}
945
946fn hello_accepted(capabilities: u64, session_proof: &[u8]) -> Vec<u8> {
947    encode_frame(
948        "hello",
949        vec![
950            entry("accepted", Value::Int(1)),
951            entry("capabilities", Value::Int(i128::from(capabilities))),
952            entry("session_proof", Value::Bytes(session_proof.to_vec())),
953        ],
954    )
955}
956
957fn hello_refused(code: RefusalCode) -> Vec<u8> {
958    encode_frame(
959        "hello",
960        vec![
961            entry("accepted", Value::Int(0)),
962            entry("refusal_code", Value::text(code.name())),
963            entry("capabilities", Value::Int(0)),
964        ],
965    )
966}
967
968/// The HELLO a station answers an opener with when the ranges share no
969/// version or the lists no profile, naming its own ranges.
970fn hello_early_refusal(e: &HandshakeError, station: &Ranges) -> Vec<u8> {
971    let code = match e {
972        HandshakeError::UnsupportedVersion => RefusalCode::UnsupportedVersion,
973        _ => RefusalCode::ProfileUnsupported,
974    };
975    let mut entries = station.entries();
976    entries.extend([
977        entry("accepted", Value::Int(0)),
978        entry("refusal_code", Value::text(code.name())),
979    ]);
980    encode_frame("hello", entries)
981}
982
983/// The client's reading of HELLO (`station`, as [`answer_challenge`] returned
984/// it): the station's capability bits, or why not. An accepting HELLO must
985/// carry a session proof that verifies under the station's identity key over
986/// this session and the HELLO tbs. A refusal is [`HandshakeError::Refused`],
987/// which names the station's ranges when it came before the challenge.
988pub fn read_hello(hello: &[u8], station: &Station) -> Result<u64, HandshakeError> {
989    let f = decode(
990        hello,
991        "hello",
992        &[
993            HELLO_PROVED_KEYS,
994            HELLO_ACCEPTED_KEYS,
995            HELLO_REFUSED_KEYS,
996            HELLO_EARLY_KEYS,
997        ],
998    )?;
999    let accepted = f.int("accepted");
1000    let code =
1001        f.0.get("refusal_code")
1002            .map(|_| RefusalCode::read(f.text("refusal_code")));
1003    let early = f.0.contains_key("versions");
1004    match (accepted, code, early) {
1005        (0, Some(code), true) => {
1006            return Err(HandshakeError::Refused {
1007                code,
1008                station_ranges: Some(f.ranges()),
1009            })
1010        }
1011        (0, Some(code), false) => {
1012            return Err(HandshakeError::Refused {
1013                code,
1014                station_ranges: None,
1015            })
1016        }
1017        (1, None, false) => {}
1018        _ => return Err(HandshakeError::Malformed),
1019    }
1020    let Some(Value::Bytes(proof)) = f.0.get("session_proof") else {
1021        return Err(HandshakeError::SessionProofMissing);
1022    };
1023    if proof.len() != signature_size(station.profile) {
1024        return Err(HandshakeError::Malformed);
1025    }
1026    let tbs = frame_tbs(hello, "session_proof")?;
1027    let message = session_proof_message(
1028        &station.exporter_value,
1029        &station.challenge,
1030        &station.connect,
1031        &station.node_id,
1032        &station.client_node_id,
1033        &tbs,
1034    );
1035    if !verify(&message, proof, &station.identity_key, station.profile) {
1036        return Err(HandshakeError::SessionProofInvalid);
1037    }
1038    Ok(f.uint("capabilities"))
1039}
1040
1041/// A station that shares no version or profile with the opener answers it
1042/// with a refusing HELLO in place of the challenge (WIRE_1_0.md section 4.4):
1043/// that HELLO as [`HandshakeError::Refused`] with the station's ranges, or
1044/// `None` for any other frame.
1045fn refusal_before_challenge(frame: &[u8]) -> Option<HandshakeError> {
1046    let f = decode(frame, "hello", &[HELLO_EARLY_KEYS]).ok()?;
1047    (f.int("accepted") == 0).then(|| HandshakeError::Refused {
1048        code: RefusalCode::read(f.text("refusal_code")),
1049        station_ranges: Some(f.ranges()),
1050    })
1051}
1052
1053/// A status frame carrying a fresh status statement, sent at every reissue.
1054pub fn status_frame(statement: &SignedTbs) -> Vec<u8> {
1055    encode_frame("status", vec![entry("statement", statement.to_value())])
1056}
1057
1058/// What a connection checks a peer's status frames against: the profile, the
1059/// identity key and binding the handshake verified, and the time in
1060/// milliseconds.
1061#[derive(Debug, Clone)]
1062pub struct Peer {
1063    pub profile: Profile,
1064    pub identity_key: Vec<u8>,
1065    pub binding: SignedTbs,
1066    pub now_ms: i64,
1067}
1068
1069/// Checks a peer's status frame, and returns when its statement expires. A
1070/// STATUS is a control frame, not a handshake frame: it is read as every
1071/// frame after HELLO is (WIRE_1_0.md section 3), so it may carry the optional
1072/// envelope keys and a non-critical `ext`, and any other extra key is
1073/// [`HandshakeError::Malformed`].
1074pub fn read_status(frame: &[u8], p: &Peer) -> Result<i64, HandshakeError> {
1075    let envelope = cbor::decode(frame)
1076        .ok()
1077        .and_then(|v| crate::frame::read_envelope(&v).ok())
1078        .filter(|e| e.frame_type == "status")
1079        .ok_or(HandshakeError::Malformed)?;
1080    read_status_frame(&envelope.frame, p)
1081}
1082
1083/// [`read_status`] of a frame whose envelope is already read, `ext` gone.
1084pub fn read_status_frame(frame: &Value, p: &Peer) -> Result<i64, HandshakeError> {
1085    let statement = crate::frame::read_control(frame)
1086        .ok()
1087        .and_then(|f| f.get("statement").map(SignedTbs::from_value))
1088        .ok_or(HandshakeError::Malformed)?
1089        .map_err(|_| HandshakeError::Malformed)?;
1090    Ok(verify_status(
1091        &statement,
1092        &p.binding,
1093        &p.identity_key,
1094        p.profile,
1095        p.now_ms,
1096    )?)
1097}
1098
1099/// A decoded handshake frame's values by their keys.
1100struct Fields(std::collections::HashMap<String, Value>);
1101
1102impl Fields {
1103    fn bytes(&self, key: &str) -> &[u8] {
1104        match self.0.get(key) {
1105            Some(Value::Bytes(b)) => b,
1106            _ => &[],
1107        }
1108    }
1109
1110    fn text(&self, key: &str) -> &str {
1111        match self.0.get(key) {
1112            Some(Value::Text(t)) => t,
1113            _ => "",
1114        }
1115    }
1116
1117    fn int(&self, key: &str) -> i128 {
1118        match self.0.get(key) {
1119            Some(Value::Int(n)) => *n,
1120            _ => -1,
1121        }
1122    }
1123
1124    fn uint(&self, key: &str) -> u64 {
1125        u64::try_from(self.int(key)).unwrap_or(0)
1126    }
1127
1128    fn signed(&self, key: &str) -> SignedTbs {
1129        self.0
1130            .get(key)
1131            .and_then(|v| SignedTbs::from_value(v).ok())
1132            .unwrap_or(SignedTbs {
1133                tbs: Vec::new(),
1134                signature: Vec::new(),
1135            })
1136    }
1137
1138    /// The frame's versions and profiles, already typed.
1139    fn ranges(&self) -> Ranges {
1140        let (min_version, max_version) = self
1141            .0
1142            .get("versions")
1143            .and_then(versions_of)
1144            .unwrap_or((0, 0));
1145        Ranges {
1146            min_version,
1147            max_version,
1148            profiles: self
1149                .0
1150                .get("profiles")
1151                .and_then(profiles_of)
1152                .unwrap_or_default(),
1153        }
1154    }
1155}
1156
1157/// A handshake frame read strictly, in macula's order: the decoding rule, the
1158/// version (another is `unsupported_version`), the frame type, exactly the
1159/// keys of one of the layouts, then the type and length of every field.
1160fn decode(frame: &[u8], frame_type: &str, layouts: &[&[&str]]) -> Result<Fields, HandshakeError> {
1161    let Ok(Value::Map(pairs)) = cbor::decode(frame) else {
1162        return Err(HandshakeError::Malformed);
1163    };
1164    let mut fields = std::collections::HashMap::with_capacity(pairs.len());
1165    let mut non_text = 0;
1166    for (key, value) in pairs {
1167        match key {
1168            Value::Text(name) => {
1169                fields.insert(name, value);
1170            }
1171            _ => non_text += 1,
1172        }
1173    }
1174    match fields.get("version") {
1175        Some(Value::Int(v)) if *v == i128::from(VERSION) => {}
1176        Some(Value::Int(_)) => return Err(HandshakeError::UnsupportedVersion),
1177        _ => return Err(HandshakeError::Malformed),
1178    }
1179    match fields.get("frame_type") {
1180        Some(Value::Text(t)) if t == frame_type => {}
1181        Some(Value::Text(_)) => return Err(HandshakeError::UnexpectedFrame),
1182        _ => return Err(HandshakeError::Malformed),
1183    }
1184    let mut keys: Vec<&str> = fields.keys().map(String::as_str).collect();
1185    keys.sort_unstable();
1186    let has_layout = layouts.contains(&keys.as_slice());
1187    if non_text > 0 || !has_layout || !fields.iter().all(|(k, v)| field_typed(k, v)) {
1188        return Err(HandshakeError::Malformed);
1189    }
1190    Ok(Fields(fields))
1191}
1192
1193fn field_typed(key: &str, v: &Value) -> bool {
1194    match key {
1195        "version" | "frame_type" => true,
1196        "profile" => matches!(v, Value::Text(_)),
1197        "versions" => versions_of(v).is_some(),
1198        "profiles" => profiles_of(v).is_some(),
1199        "nonce" => matches!(v, Value::Bytes(b) if b.len() == NONCE_SIZE),
1200        "identity_key" | "connect_key" | "proof" | "member_endorsement" | "session_proof" => {
1201            matches!(v, Value::Bytes(_))
1202        }
1203        "tls_binding" | "tls_status" | "connect_binding" | "connect_status" | "statement" => {
1204            SignedTbs::from_value(v).is_ok()
1205        }
1206        "capabilities" => matches!(v, Value::Int(n) if *n >= 0 && *n < MAX_PROTOCOL_INT),
1207        "accepted" => matches!(v, Value::Int(0 | 1)),
1208        "refusal_code" => {
1209            matches!(v, Value::Text(t) if !t.is_empty() && t.len() <= MAX_REFUSAL_CODE_BYTES)
1210        }
1211        _ => false,
1212    }
1213}
1214
1215/// versions: `[min, max]`, unsigned integers below 2^53 with min <= max.
1216fn versions_of(v: &Value) -> Option<(i64, i64)> {
1217    match v {
1218        Value::List(items) => match items.as_slice() {
1219            [Value::Int(lo), Value::Int(hi)] if *lo >= 0 && *hi < MAX_PROTOCOL_INT && lo <= hi => {
1220                Some((*lo as i64, *hi as i64))
1221            }
1222            _ => None,
1223        },
1224        _ => None,
1225    }
1226}
1227
1228/// profiles: 1 to 8 distinct profile names, as text.
1229fn profiles_of(v: &Value) -> Option<Vec<String>> {
1230    let Value::List(items) = v else {
1231        return None;
1232    };
1233    if items.is_empty() || items.len() > MAX_PROFILES {
1234        return None;
1235    }
1236    let mut out: Vec<String> = Vec::with_capacity(items.len());
1237    for item in items {
1238        match item {
1239            Value::Text(name) if !name.is_empty() && !out.contains(name) => out.push(name.clone()),
1240            _ => return None,
1241        }
1242    }
1243    Some(out)
1244}
1245
1246/// Whether `leaf` holds `key`'s ML-DSA-87 half.
1247fn in_leaf(key: &[u8], leaf: &[u8]) -> bool {
1248    key.len() >= MLDSA_KEY_SIZE
1249        && leaf
1250            .windows(MLDSA_KEY_SIZE)
1251            .any(|w| w == &key[..MLDSA_KEY_SIZE])
1252}
1253
1254/// Whether two carried keys share their ML-DSA-87 half, or a classical half.
1255fn shares_a_half(a: &[u8], b: &[u8]) -> bool {
1256    if a.len() < MLDSA_KEY_SIZE || b.len() < MLDSA_KEY_SIZE {
1257        return a == b;
1258    }
1259    let (classical_a, classical_b) = (&a[MLDSA_KEY_SIZE..], &b[MLDSA_KEY_SIZE..]);
1260    a[..MLDSA_KEY_SIZE] == b[..MLDSA_KEY_SIZE]
1261        || (!classical_a.is_empty() && classical_a == classical_b)
1262}
1263
1264/// A handshake frame: version 5, its type, and its own fields.
1265fn encode_frame(frame_type: &str, entries: Vec<(Value, Value)>) -> Vec<u8> {
1266    let mut all = vec![
1267        entry("version", Value::Int(i128::from(VERSION))),
1268        entry("frame_type", Value::text(frame_type)),
1269    ];
1270    all.extend(entries);
1271    cbor::encode(&Value::Map(all)).expect("a handshake frame's integers are all below 2^53")
1272}
1273
1274fn entry(key: &str, value: Value) -> (Value, Value) {
1275    (Value::text(key), value)
1276}
1277
1278fn hex_of(bytes: &[u8]) -> String {
1279    bytes.iter().map(|b| format!("{b:02x}")).collect()
1280}