Skip to main content

macula_rust/
handshake.rs

1//! macula 12's post-quantum connection handshake, as macula_handshake and
2//! macula-go build and check it: the opener, challenge, CONNECT, HELLO and
3//! status frames (D16, D22), as CBOR bytes without the length prefix.
4//!
5//! The client opens with an opener. The station answers with a challenge: its
6//! carried identity key, its TLS binding and status statement, and a fresh
7//! nonce. The client checks the challenge against the node_id it dialed and
8//! the leaf it received, before it signs anything, and answers with CONNECT:
9//! its identity and CONNECT keys, the CONNECT binding and status statement,
10//! and a proof by the CONNECT key. The station checks CONNECT, the puzzle
11//! before any signature, and answers with HELLO. Status frames renew a peer's
12//! statement on the open connection.
13//!
14//! Every frame decodes under the decoding rule and must hold exactly the keys
15//! of its type, each of its type and length. Close reasons are local: a
16//! refusing station sends only a HELLO with one coarse refusal code.
17//!
18//! Version 5 (macula 13.2, DESIGN_NEIGHBOUR_CHANNEL_BINDING) binds both ends
19//! to the TLS session. The opener and the challenge stay version 4; the client
20//! picks 4 or 5 in CONNECT, and the station answers HELLO in the same version.
21//! In version 5 the CONNECT proof (V2) also covers E, the session's TLS
22//! exporter value, and the client's capabilities, and HELLO carries the
23//! station's session proof, signed by its identity key over E, both frames and
24//! both node_ids, only after every check on CONNECT has passed. A station with
25//! no exporter answers a v5 CONNECT as an old station does, with
26//! `unsupported_version`.
27
28use std::fmt;
29use std::sync::Arc;
30
31use sha2::{Digest, Sha384};
32
33use crate::binding::{
34    verify_connect_binding, verify_status, verify_tls_binding, BindingError, SignedTbs,
35};
36use crate::cbor::{self, Value};
37use crate::node_key::{
38    carried_key_well_formed, node_id_of, puzzle_solved, signature_size, verify, KeyError, NodeKey,
39};
40use crate::profile::Profile;
41
42#[cfg(test)]
43mod v5_tests;
44
45/// The handshake's frame version: 4, as macula 12's. A peer on another version
46/// hears `unsupported_version`.
47pub const VERSION: i64 = 4;
48
49/// The channel-bound handshake: both proofs over the TLS session's exporter
50/// value.
51pub const VERSION_5: i64 = 5;
52
53/// The TLS 1.3 exporter label (RFC 8446 section 7.5) handshake v5 binds to,
54/// over the context client node_id || station node_id, 32 bytes.
55pub const EXPORTER_LABEL: &str = "EXPORTER-macula-session-v1";
56
57const NONCE_SIZE: usize = 32;
58const EXPORTER_SIZE: usize = 32;
59const MAX_PROTOCOL_INT: i64 = 1 << 53;
60const MLDSA_KEY_SIZE: usize = 2592;
61const CONNECT_PROOF_LABEL: &[u8] = b"MACULA-PQ-CONNECT-PROOF-V1";
62const CONNECT_PROOF_LABEL_V2: &[u8] = b"MACULA-PQ-CONNECT-PROOF-V2";
63const SESSION_PROOF_LABEL: &[u8] = b"MACULA-PQ-SESSION-PROOF-V1";
64
65/// A TLS 1.3 session's exporter: label, context and length to bytes, or
66/// `None` when the session gives none. Both ends of one session export the
67/// same bytes.
68pub type Exporter = dyn Fn(&str, &[u8], usize) -> Option<Vec<u8>> + Send + Sync;
69
70const OPENER_KEYS: &[&str] = &["frame_type", "version"];
71const CHALLENGE_KEYS: &[&str] = &[
72    "frame_type",
73    "identity_key",
74    "nonce",
75    "profile",
76    "tls_binding",
77    "tls_status",
78    "version",
79];
80/// CONNECT always holds member_endorsement, empty when the node has none, as
81/// macula 12's: one layout, so the wire does not tell whether a node holds an
82/// endorsement or a station asks for one.
83const CONNECT_KEYS: &[&str] = &[
84    "capabilities",
85    "connect_binding",
86    "connect_key",
87    "connect_status",
88    "frame_type",
89    "identity_key",
90    "member_endorsement",
91    "proof",
92    "version",
93];
94const HELLO_ACCEPTED_KEYS: &[&str] = &["accepted", "capabilities", "frame_type", "version"];
95const HELLO_REFUSED_KEYS: &[&str] = &[
96    "accepted",
97    "capabilities",
98    "frame_type",
99    "refusal_code",
100    "version",
101];
102const HELLO_PROVED_KEYS: &[&str] = &[
103    "accepted",
104    "capabilities",
105    "frame_type",
106    "session_proof",
107    "version",
108];
109const STATUS_KEYS: &[&str] = &["frame_type", "statement", "version"];
110
111/// The one coarse reason a refusing HELLO carries.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum RefusalCode {
114    /// Frames that are not version 4.
115    UnsupportedVersion,
116    /// A node_id that misses the puzzle, which the client can check itself.
117    PuzzleInvalid,
118    /// A v5 CONNECT past the station's session proof budget. Only a v5 HELLO
119    /// carries it.
120    SessionProofRate,
121    /// A CONNECT that failed any other check.
122    NotAccepted,
123}
124
125impl RefusalCode {
126    fn name(self) -> &'static str {
127        match self {
128            RefusalCode::UnsupportedVersion => "unsupported_version",
129            RefusalCode::PuzzleInvalid => "puzzle_invalid",
130            RefusalCode::SessionProofRate => "session_proof_rate",
131            RefusalCode::NotAccepted => "not_accepted",
132        }
133    }
134
135    fn parse(name: &str) -> Option<RefusalCode> {
136        match name {
137            "unsupported_version" => Some(RefusalCode::UnsupportedVersion),
138            "puzzle_invalid" => Some(RefusalCode::PuzzleInvalid),
139            "session_proof_rate" => Some(RefusalCode::SessionProofRate),
140            "not_accepted" => Some(RefusalCode::NotAccepted),
141            _ => None,
142        }
143    }
144}
145
146/// The handshake's close reasons, named as macula names them. A binding or
147/// status statement that fails its check closes with its [`BindingError`].
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum HandshakeError {
150    /// A frame of another type than the one expected next.
151    UnexpectedFrame,
152    /// A frame of another version than 4.
153    UnsupportedVersion,
154    /// A frame that does not decode exactly, or a carried key or proof of the
155    /// wrong form.
156    Malformed,
157    /// A challenge that names another profile.
158    ProfileMismatch,
159    /// A key that would serve two purposes: a CONNECT key that shares a half
160    /// with its identity key, or a key found in the leaf.
161    KeyPurposeReuse,
162    /// A station whose node_id is not the one dialed.
163    PeerIdentityMismatch {
164        expected: [u8; 32],
165        derived: [u8; 32],
166    },
167    /// A client whose node_id does not meet the puzzle.
168    PuzzleInvalid,
169    /// A CONNECT proof that does not verify.
170    ProofInvalid,
171    /// A HELLO that refuses the connection, with its code.
172    Refused(RefusalCode),
173    /// A station session with a puzzle difficulty the design does not have.
174    InvalidStationSession,
175    /// A v5 HELLO whose session proof does not verify under the station's
176    /// identity key over this session.
177    SessionProofInvalid,
178    /// A v5 HELLO that accepts without a session proof.
179    SessionProofMissing,
180    /// A station past its session proof budget.
181    SessionProofRate,
182    /// A v4 HELLO that accepts a v5 CONNECT: never taken as a v4 connection.
183    V4HelloToV5Connect,
184    /// A v5 session whose TLS exporter gave no value.
185    ExporterUnavailable,
186    /// A binding or status statement that did not verify.
187    Binding(BindingError),
188    /// A key that could not sign, or randomness that could not be drawn.
189    Key(KeyError),
190}
191
192impl fmt::Display for HandshakeError {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        match self {
195            HandshakeError::UnexpectedFrame => f.write_str("unexpected frame"),
196            HandshakeError::UnsupportedVersion => f.write_str("unsupported frame version"),
197            HandshakeError::Malformed => f.write_str("malformed frame"),
198            HandshakeError::ProfileMismatch => f.write_str("the peer names another profile"),
199            HandshakeError::KeyPurposeReuse => f.write_str("a key would serve two purposes"),
200            HandshakeError::PeerIdentityMismatch { expected, derived } => write!(
201                f,
202                "dialed node_id {}, but the station's key derives {}",
203                hex_of(expected),
204                hex_of(derived)
205            ),
206            HandshakeError::PuzzleInvalid => f.write_str("the node_id does not meet the puzzle"),
207            HandshakeError::ProofInvalid => f.write_str("the CONNECT proof does not verify"),
208            HandshakeError::Refused(code) => {
209                write!(f, "the station refused the connection: {}", code.name())
210            }
211            HandshakeError::InvalidStationSession => {
212                f.write_str("the station session has an unknown puzzle difficulty")
213            }
214            HandshakeError::SessionProofInvalid => f.write_str("the session proof does not verify"),
215            HandshakeError::SessionProofMissing => {
216                f.write_str("the HELLO carries no session proof")
217            }
218            HandshakeError::SessionProofRate => {
219                f.write_str("the station's session proof budget is spent")
220            }
221            HandshakeError::V4HelloToV5Connect => f.write_str("a v4 HELLO accepted a v5 CONNECT"),
222            HandshakeError::ExporterUnavailable => f.write_str("the TLS exporter is unavailable"),
223            HandshakeError::Binding(e) => write!(f, "{e}"),
224            HandshakeError::Key(e) => write!(f, "{e}"),
225        }
226    }
227}
228
229impl std::error::Error for HandshakeError {}
230
231impl From<BindingError> for HandshakeError {
232    fn from(e: BindingError) -> Self {
233        HandshakeError::Binding(e)
234    }
235}
236
237/// How a station treats a client's node_id puzzle.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub enum PuzzleMode {
240    /// The puzzle is not checked.
241    Off,
242    /// An unsolved puzzle is accepted and reported.
243    LogOnly,
244    /// An unsolved puzzle is refused.
245    Enforce,
246}
247
248/// What a station found of a client's puzzle.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum PuzzleResult {
251    Solved,
252    Unsolved,
253    NotChecked,
254}
255
256/// The client's first frame on the control stream. It carries nothing that
257/// relates to identity.
258pub fn opener() -> Vec<u8> {
259    encode_frame("opener", Vec::new())
260}
261
262/// The station's check of the first frame.
263pub fn read_opener(frame: &[u8]) -> Result<(), HandshakeError> {
264    decode(frame, "opener", &[OPENER_KEYS]).map(|_| ())
265}
266
267/// What a station precomputes for its challenges: its carried identity key,
268/// and the TLS binding and status statement for the leaf it presents.
269#[derive(Debug, Clone)]
270pub struct StationMaterial {
271    pub profile: Profile,
272    pub identity_key: Vec<u8>,
273    pub tls_binding: SignedTbs,
274    pub tls_status: SignedTbs,
275}
276
277/// A station's challenge, with a fresh nonce. The station keeps the bytes it
278/// sends, for the proof check.
279pub fn challenge(m: &StationMaterial) -> Result<Vec<u8>, HandshakeError> {
280    let mut nonce = [0u8; NONCE_SIZE];
281    aws_lc_rs::rand::fill(&mut nonce)
282        .map_err(|_| HandshakeError::Key(KeyError::RandomnessUnavailable))?;
283    Ok(encode_frame(
284        "challenge",
285        vec![
286            entry("nonce", Value::Bytes(nonce.to_vec())),
287            entry("profile", Value::text(m.profile.name())),
288            entry("identity_key", Value::Bytes(m.identity_key.clone())),
289            entry("tls_binding", m.tls_binding.to_value()),
290            entry("tls_status", m.tls_status.to_value()),
291        ],
292    ))
293}
294
295/// What a client brings to a handshake: its profile, the node_id it dialed,
296/// the leaf DER it received in this TLS handshake, its carried identity key,
297/// its CONNECT key with binding and status statement, its capability bits,
298/// the time in milliseconds, the realm membership endorsement CONNECT
299/// carries, empty for a node that holds none, the version CONNECT carries
300/// ([`VERSION`] or [`VERSION_5`]), and this connection's TLS exporter, which
301/// version 5 needs.
302pub struct ClientSession<'a> {
303    pub profile: Profile,
304    pub expected_node_id: [u8; 32],
305    pub leaf: &'a [u8],
306    pub identity_key: Vec<u8>,
307    pub connect_key: &'a NodeKey,
308    pub connect_binding: &'a SignedTbs,
309    pub connect_status: &'a SignedTbs,
310    pub capabilities: u64,
311    pub now_ms: i64,
312    pub member_endorsement: Vec<u8>,
313    pub version: i64,
314    pub export: Option<&'a Exporter>,
315}
316
317/// What a client knows of the station once it has checked the challenge, and
318/// what its HELLO must answer: the version the CONNECT carried and, in
319/// version 5, what the session proof covers.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct Station {
322    pub node_id: [u8; 32],
323    pub identity_key: Vec<u8>,
324    pub tls_binding: SignedTbs,
325    pub status_expires_at: i64,
326    pub binding_not_after: i64,
327    pub version: i64,
328    profile: Profile,
329    exporter_value: Vec<u8>,
330    challenge: Vec<u8>,
331    connect: Vec<u8>,
332    client_node_id: [u8; 32],
333}
334
335/// Checks a challenge and, when every check passes, returns the CONNECT to
336/// send. It checks, in macula's order: the frame, the profile, the station's
337/// carried key, that each key in view serves one purpose, the station's
338/// node_id against the one dialed, the TLS binding against the leaf received,
339/// and the status statement. It signs nothing before all of them pass.
340pub fn answer_challenge(
341    challenge: &[u8],
342    s: &ClientSession<'_>,
343) -> Result<(Vec<u8>, Station), HandshakeError> {
344    let f = decode(challenge, "challenge", &[CHALLENGE_KEYS])?;
345    let station_key = f.bytes("identity_key");
346    let connect_key = s.connect_key.public_key();
347    if f.text("profile") != s.profile.name() {
348        return Err(HandshakeError::ProfileMismatch);
349    }
350    if !carried_key_well_formed(station_key, s.profile) {
351        return Err(HandshakeError::Malformed);
352    }
353    if shares_a_half(&s.identity_key, &connect_key)
354        || in_leaf(station_key, s.leaf)
355        || in_leaf(&connect_key, s.leaf)
356    {
357        return Err(HandshakeError::KeyPurposeReuse);
358    }
359    let station_node_id = node_id_of(station_key, s.profile);
360    if station_node_id != s.expected_node_id {
361        return Err(HandshakeError::PeerIdentityMismatch {
362            expected: s.expected_node_id,
363            derived: station_node_id,
364        });
365    }
366    let tls_binding = f.signed("tls_binding");
367    let binding = verify_tls_binding(&tls_binding, station_key, s.profile, s.leaf, s.now_ms)?;
368    let expires_at = verify_status(
369        &f.signed("tls_status"),
370        &tls_binding,
371        station_key,
372        s.profile,
373        s.now_ms,
374    )?;
375    let client_node_id = node_id_of(&s.identity_key, s.profile);
376    let e = session_exported(s.version, s.export, &client_node_id, &station_node_id)?;
377    let proof = s
378        .connect_key
379        .sign(&proof_message(
380            s.version,
381            f.bytes("nonce"),
382            &station_node_id,
383            &client_node_id,
384            s.leaf,
385            challenge,
386            &e,
387            s.capabilities,
388        ))
389        .map_err(HandshakeError::Key)?;
390    let connect = encode_frame_version(
391        s.version,
392        "connect",
393        vec![
394            entry("identity_key", Value::Bytes(s.identity_key.clone())),
395            entry("connect_key", Value::Bytes(connect_key)),
396            entry("connect_binding", s.connect_binding.to_value()),
397            entry("connect_status", s.connect_status.to_value()),
398            entry("proof", Value::Bytes(proof)),
399            entry("capabilities", Value::Int(i128::from(s.capabilities))),
400            entry(
401                "member_endorsement",
402                Value::Bytes(s.member_endorsement.clone()),
403            ),
404        ],
405    );
406    Ok((
407        connect.clone(),
408        Station {
409            node_id: station_node_id,
410            identity_key: station_key.to_vec(),
411            tls_binding,
412            status_expires_at: expires_at,
413            binding_not_after: binding.not_after,
414            version: s.version,
415            profile: s.profile,
416            exporter_value: e,
417            challenge: challenge.to_vec(),
418            connect,
419            client_node_id,
420        },
421    ))
422}
423
424/// E for a v5 session, with the client's node_id first in the context (the
425/// initiator's), and nothing for version 4.
426fn session_exported(
427    version: i64,
428    export: Option<&Exporter>,
429    client_node_id: &[u8; 32],
430    station_node_id: &[u8; 32],
431) -> Result<Vec<u8>, HandshakeError> {
432    match (version, export) {
433        (VERSION, _) => Ok(Vec::new()),
434        (VERSION_5, Some(export)) => {
435            let context = [client_node_id.as_slice(), station_node_id].concat();
436            match export(EXPORTER_LABEL, &context, EXPORTER_SIZE) {
437                Some(e) if e.len() == EXPORTER_SIZE => Ok(e),
438                _ => Err(HandshakeError::ExporterUnavailable),
439            }
440        }
441        (VERSION_5, None) => Err(HandshakeError::ExporterUnavailable),
442        _ => Err(HandshakeError::UnsupportedVersion),
443    }
444}
445
446/// What a station brings to a CONNECT check: its profile, the challenge bytes
447/// it sent, the leaf DER this connection presented, its puzzle difficulty and
448/// mode, its capability bits, the time in milliseconds, and what it binds a v5
449/// session with. Without `v5` it answers only version 4.
450#[derive(Debug, Clone)]
451pub struct StationSession {
452    pub profile: Profile,
453    pub challenge: Vec<u8>,
454    pub leaf: Vec<u8>,
455    pub puzzle_difficulty: u32,
456    pub puzzle_mode: PuzzleMode,
457    pub capabilities: u64,
458    pub now_ms: i64,
459    pub v5: Option<StationV5>,
460}
461
462/// Signs a v5 session proof with the station's identity key for the client
463/// named, within the station's budget ([`HandshakeError::SessionProofRate`]
464/// past it).
465pub type SessionProofSigner =
466    dyn Fn(&[u8; 32], &[u8]) -> Result<Vec<u8>, HandshakeError> + Send + Sync;
467
468/// A station's means to bind a v5 session: this connection's TLS exporter,
469/// and its session proof signer.
470#[derive(Clone)]
471pub struct StationV5 {
472    pub export: Arc<Exporter>,
473    pub sign_session_proof: Arc<SessionProofSigner>,
474}
475
476impl fmt::Debug for StationV5 {
477    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
478        f.write_str("StationV5")
479    }
480}
481
482/// What a station knows of an accepted client.
483#[derive(Debug, Clone, PartialEq, Eq)]
484pub struct Client {
485    pub node_id: [u8; 32],
486    pub identity_key: Vec<u8>,
487    pub connect_key: Vec<u8>,
488    pub connect_binding: SignedTbs,
489    pub capabilities: u64,
490    pub status_expires_at: i64,
491    pub binding_not_after: i64,
492    pub puzzle: PuzzleResult,
493    /// The endorsement the CONNECT carried, empty when the client holds none.
494    /// Nothing here checks it: that is the station's policy.
495    pub member_endorsement: Vec<u8>,
496    /// The version the CONNECT carried, 4 or 5.
497    pub version: i64,
498}
499
500/// Checks a CONNECT, and returns the verdict with the HELLO to send. It
501/// checks, in macula's order: the frame, the carried keys and the proof's
502/// length, that each key serves one purpose, the puzzle on the derived node_id
503/// before any signature, the CONNECT binding and status statement, and the
504/// proof against the challenge this station sent and the leaf it presented. A
505/// refusal is the local close reason, and the HELLO refuses with one coarse
506/// code.
507///
508/// A station with `v5` answers version 4 and 5, HELLO in the CONNECT's
509/// version; in version 5 it signs the session proof only once every check has
510/// passed. Without it, a v5 CONNECT is refused `unsupported_version` in
511/// version 4, as an old station refuses it.
512pub fn accept_connect(
513    connect: &[u8],
514    s: &StationSession,
515) -> (Result<Client, HandshakeError>, Vec<u8>) {
516    let mut version = VERSION;
517    match check_connect(connect, s, &mut version) {
518        Ok((client, session_proof)) => {
519            let hello = hello(client.version, None, s.capabilities, session_proof);
520            (Ok(client), hello)
521        }
522        Err(e) => {
523            let code = match e {
524                HandshakeError::UnsupportedVersion => RefusalCode::UnsupportedVersion,
525                HandshakeError::PuzzleInvalid => RefusalCode::PuzzleInvalid,
526                HandshakeError::SessionProofRate => RefusalCode::SessionProofRate,
527                _ => RefusalCode::NotAccepted,
528            };
529            (Err(e), hello(version, Some(code), s.capabilities, None))
530        }
531    }
532}
533
534/// The client and, in version 5, the session proof. `version` is set to the
535/// CONNECT's once it decodes, so a refusal answers in it.
536fn check_connect(
537    connect: &[u8],
538    s: &StationSession,
539    version: &mut i64,
540) -> Result<(Client, Option<Vec<u8>>), HandshakeError> {
541    if s.puzzle_difficulty > 256 {
542        return Err(HandshakeError::InvalidStationSession);
543    }
544    let versions: &[i64] = match s.v5 {
545        Some(_) => &[VERSION, VERSION_5],
546        None => &[VERSION],
547    };
548    let f = decode_versioned(connect, "connect", versions, &[CONNECT_KEYS])?;
549    *version = f.version();
550    let (identity_key, connect_key, proof) = (
551        f.bytes("identity_key"),
552        f.bytes("connect_key"),
553        f.bytes("proof"),
554    );
555    if !carried_key_well_formed(identity_key, s.profile)
556        || !carried_key_well_formed(connect_key, s.profile)
557        || proof.len() != signature_size(s.profile)
558    {
559        return Err(HandshakeError::Malformed);
560    }
561    if shares_a_half(identity_key, connect_key) || in_leaf(connect_key, &s.leaf) {
562        return Err(HandshakeError::KeyPurposeReuse);
563    }
564    let node_id = node_id_of(identity_key, s.profile);
565    let puzzle = match s.puzzle_mode {
566        PuzzleMode::Off => PuzzleResult::NotChecked,
567        _ if puzzle_solved(&node_id, s.puzzle_difficulty) => PuzzleResult::Solved,
568        _ => PuzzleResult::Unsolved,
569    };
570    if puzzle == PuzzleResult::Unsolved && s.puzzle_mode == PuzzleMode::Enforce {
571        return Err(HandshakeError::PuzzleInvalid);
572    }
573    let connect_binding = f.signed("connect_binding");
574    let binding = verify_connect_binding(
575        &connect_binding,
576        identity_key,
577        s.profile,
578        connect_key,
579        s.now_ms,
580    )?;
581    let expires_at = verify_status(
582        &f.signed("connect_status"),
583        &connect_binding,
584        identity_key,
585        s.profile,
586        s.now_ms,
587    )?;
588    // The station's own challenge decodes: it built it.
589    let challenge = decode(&s.challenge, "challenge", &[CHALLENGE_KEYS])
590        .map_err(|_| HandshakeError::ProofInvalid)?;
591    let station_node_id = node_id_of(challenge.bytes("identity_key"), s.profile);
592    let export = s.v5.as_ref().map(|v5| v5.export.as_ref());
593    let e = session_exported(*version, export, &node_id, &station_node_id)?;
594    let capabilities = f.uint("capabilities");
595    let message = proof_message(
596        *version,
597        challenge.bytes("nonce"),
598        &station_node_id,
599        &node_id,
600        &s.leaf,
601        &s.challenge,
602        &e,
603        capabilities,
604    );
605    if !verify(&message, proof, connect_key, s.profile) {
606        return Err(HandshakeError::ProofInvalid);
607    }
608    let session_proof = match (&s.v5, *version) {
609        (Some(v5), VERSION_5) => Some((v5.sign_session_proof)(
610            &node_id,
611            &session_proof_message(
612                &e,
613                &s.challenge,
614                connect,
615                &station_node_id,
616                &node_id,
617                s.capabilities,
618            ),
619        )?),
620        _ => None,
621    };
622    Ok((
623        Client {
624            node_id,
625            identity_key: identity_key.to_vec(),
626            connect_key: connect_key.to_vec(),
627            connect_binding,
628            capabilities,
629            status_expires_at: expires_at,
630            binding_not_after: binding.not_after,
631            puzzle,
632            member_endorsement: f.bytes("member_endorsement").to_vec(),
633            version: *version,
634        },
635        session_proof,
636    ))
637}
638
639/// What the CONNECT proof signs: the label, a zero byte, the nonce, the
640/// station's and the client's node_ids, the SHA-384 of the leaf DER and the
641/// SHA-384 of the challenge bytes as received. Version 5 (V2) appends E and
642/// the client's capabilities, 8 bytes big-endian: every field has a fixed
643/// width, so no two field sequences encode to the same bytes.
644#[allow(clippy::too_many_arguments)]
645fn proof_message(
646    version: i64,
647    nonce: &[u8],
648    station_node_id: &[u8; 32],
649    client_node_id: &[u8; 32],
650    leaf: &[u8],
651    challenge: &[u8],
652    e: &[u8],
653    capabilities: u64,
654) -> Vec<u8> {
655    let label = match version {
656        VERSION_5 => CONNECT_PROOF_LABEL_V2,
657        _ => CONNECT_PROOF_LABEL,
658    };
659    let mut out = Vec::with_capacity(label.len() + 1 + nonce.len() + 64 + 96 + EXPORTER_SIZE + 8);
660    out.extend_from_slice(label);
661    out.push(0);
662    out.extend_from_slice(nonce);
663    out.extend_from_slice(station_node_id);
664    out.extend_from_slice(client_node_id);
665    out.extend_from_slice(&Sha384::digest(leaf));
666    out.extend_from_slice(&Sha384::digest(challenge));
667    if version == VERSION_5 {
668        out.extend_from_slice(e);
669        out.extend_from_slice(&capabilities.to_be_bytes());
670    }
671    out
672}
673
674/// What the station's session proof signs: the label, a zero byte, E, the
675/// SHA-384 of the challenge and of CONNECT, the station's and the client's
676/// node_ids, and the station's capabilities, 8 bytes big-endian. The SHA-384
677/// of CONNECT covers the client's capabilities.
678fn session_proof_message(
679    e: &[u8],
680    challenge: &[u8],
681    connect: &[u8],
682    station_node_id: &[u8; 32],
683    client_node_id: &[u8; 32],
684    capabilities: u64,
685) -> Vec<u8> {
686    let mut out = Vec::with_capacity(SESSION_PROOF_LABEL.len() + 1 + EXPORTER_SIZE + 96 + 64 + 8);
687    out.extend_from_slice(SESSION_PROOF_LABEL);
688    out.push(0);
689    out.extend_from_slice(e);
690    out.extend_from_slice(&Sha384::digest(challenge));
691    out.extend_from_slice(&Sha384::digest(connect));
692    out.extend_from_slice(station_node_id);
693    out.extend_from_slice(client_node_id);
694    out.extend_from_slice(&capabilities.to_be_bytes());
695    out
696}
697
698fn hello(
699    version: i64,
700    refusal: Option<RefusalCode>,
701    capabilities: u64,
702    session_proof: Option<Vec<u8>>,
703) -> Vec<u8> {
704    let mut entries = vec![
705        entry("accepted", Value::Int(i128::from(refusal.is_none()))),
706        entry("capabilities", Value::Int(i128::from(capabilities))),
707    ];
708    if let Some(code) = refusal {
709        entries.push(entry("refusal_code", Value::text(code.name())));
710    }
711    if let Some(proof) = session_proof {
712        entries.push(entry("session_proof", Value::Bytes(proof)));
713    }
714    encode_frame_version(version, "hello", entries)
715}
716
717/// The client's reading of HELLO against the version its CONNECT carried
718/// (`station`, as [`answer_challenge`] returned it): the station's capability
719/// bits, or why not. After a v5 CONNECT an accepting HELLO must be version 5
720/// with a session proof that verifies under the station's identity key over
721/// this session. A v4 refusal is how an old station answers
722/// ([`HandshakeError::Refused`]); a v4 acceptance is never taken as a v4
723/// connection.
724pub fn read_hello(frame: &[u8], station: &Station) -> Result<u64, HandshakeError> {
725    let (versions, layouts): (&[i64], &[&[&str]]) = match station.version {
726        VERSION_5 => (
727            &[VERSION, VERSION_5],
728            &[HELLO_PROVED_KEYS, HELLO_ACCEPTED_KEYS, HELLO_REFUSED_KEYS],
729        ),
730        _ => (&[VERSION], &[HELLO_ACCEPTED_KEYS, HELLO_REFUSED_KEYS]),
731    };
732    let f = decode_versioned(frame, "hello", versions, layouts)?;
733    let refusal = f.0.get("refusal_code");
734    let proof = f.0.get("session_proof");
735    match (f.int("accepted"), refusal) {
736        (0, Some(Value::Text(code))) => {
737            return Err(HandshakeError::Refused(
738                RefusalCode::parse(code).ok_or(HandshakeError::Malformed)?,
739            ))
740        }
741        (1, None) => {}
742        _ => return Err(HandshakeError::Malformed),
743    }
744    let capabilities = f.uint("capabilities");
745    match (f.version(), station.version, proof) {
746        (VERSION, VERSION_5, _) => Err(HandshakeError::V4HelloToV5Connect),
747        (VERSION, _, None) => Ok(capabilities),
748        (VERSION_5, VERSION_5, None) => Err(HandshakeError::SessionProofMissing),
749        (VERSION_5, VERSION_5, Some(Value::Bytes(proof))) => {
750            if proof.len() != signature_size(station.profile) {
751                return Err(HandshakeError::Malformed);
752            }
753            let message = session_proof_message(
754                &station.exporter_value,
755                &station.challenge,
756                &station.connect,
757                &station.node_id,
758                &station.client_node_id,
759                capabilities,
760            );
761            if !verify(&message, proof, &station.identity_key, station.profile) {
762                return Err(HandshakeError::SessionProofInvalid);
763            }
764            Ok(capabilities)
765        }
766        _ => Err(HandshakeError::Malformed),
767    }
768}
769
770/// A status frame carrying a fresh status statement, sent at every reissue.
771pub fn status_frame(statement: &SignedTbs) -> Vec<u8> {
772    encode_frame("status", vec![entry("statement", statement.to_value())])
773}
774
775/// What a connection checks a peer's status frames against: the profile, the
776/// identity key and binding the handshake verified, and the time in
777/// milliseconds.
778#[derive(Debug, Clone)]
779pub struct Peer {
780    pub profile: Profile,
781    pub identity_key: Vec<u8>,
782    pub binding: SignedTbs,
783    pub now_ms: i64,
784}
785
786/// Checks a peer's status frame, and returns when its statement expires.
787pub fn read_status(frame: &[u8], p: &Peer) -> Result<i64, HandshakeError> {
788    let f = decode(frame, "status", &[STATUS_KEYS])?;
789    Ok(verify_status(
790        &f.signed("statement"),
791        &p.binding,
792        &p.identity_key,
793        p.profile,
794        p.now_ms,
795    )?)
796}
797
798/// A decoded handshake frame's values by their keys.
799struct Fields(std::collections::HashMap<String, Value>);
800
801impl Fields {
802    fn bytes(&self, key: &str) -> &[u8] {
803        match self.0.get(key) {
804            Some(Value::Bytes(b)) => b,
805            _ => &[],
806        }
807    }
808
809    fn text(&self, key: &str) -> &str {
810        match self.0.get(key) {
811            Some(Value::Text(t)) => t,
812            _ => "",
813        }
814    }
815
816    fn int(&self, key: &str) -> i128 {
817        match self.0.get(key) {
818            Some(Value::Int(n)) => *n,
819            _ => -1,
820        }
821    }
822
823    fn uint(&self, key: &str) -> u64 {
824        u64::try_from(self.int(key)).unwrap_or(0)
825    }
826
827    fn version(&self) -> i64 {
828        i64::try_from(self.int("version")).unwrap_or(-1)
829    }
830
831    fn signed(&self, key: &str) -> SignedTbs {
832        self.0
833            .get(key)
834            .and_then(|v| SignedTbs::from_value(v).ok())
835            .unwrap_or(SignedTbs {
836                tbs: Vec::new(),
837                signature: Vec::new(),
838            })
839    }
840}
841
842/// A handshake frame read strictly, in macula's order: the decoding rule, the
843/// version, the frame type, exactly the keys of one of the layouts, then the
844/// type and length of every field.
845fn decode(frame: &[u8], frame_type: &str, layouts: &[&[&str]]) -> Result<Fields, HandshakeError> {
846    decode_versioned(frame, frame_type, &[VERSION], layouts)
847}
848
849/// `decode` in any of `versions`: another version is
850/// [`HandshakeError::UnsupportedVersion`].
851fn decode_versioned(
852    frame: &[u8],
853    frame_type: &str,
854    versions: &[i64],
855    layouts: &[&[&str]],
856) -> Result<Fields, HandshakeError> {
857    let Ok(Value::Map(pairs)) = cbor::decode(frame) else {
858        return Err(HandshakeError::Malformed);
859    };
860    let mut fields = std::collections::HashMap::with_capacity(pairs.len());
861    let mut non_text = 0;
862    for (key, value) in pairs {
863        match key {
864            Value::Text(name) => {
865                fields.insert(name, value);
866            }
867            _ => non_text += 1,
868        }
869    }
870    match fields.get("version") {
871        Some(Value::Int(v)) if versions.iter().any(|known| *v == i128::from(*known)) => {}
872        Some(Value::Int(_)) => return Err(HandshakeError::UnsupportedVersion),
873        _ => return Err(HandshakeError::Malformed),
874    }
875    match fields.get("frame_type") {
876        Some(Value::Text(t)) if t == frame_type => {}
877        Some(Value::Text(_)) => return Err(HandshakeError::UnexpectedFrame),
878        _ => return Err(HandshakeError::Malformed),
879    }
880    let mut keys: Vec<&str> = fields.keys().map(String::as_str).collect();
881    keys.sort_unstable();
882    let has_layout = layouts.contains(&keys.as_slice());
883    if non_text > 0 || !has_layout || !fields.iter().all(|(k, v)| field_typed(k, v)) {
884        return Err(HandshakeError::Malformed);
885    }
886    Ok(Fields(fields))
887}
888
889fn field_typed(key: &str, v: &Value) -> bool {
890    match key {
891        "version" | "frame_type" => true,
892        "profile" => matches!(v, Value::Text(_)),
893        "nonce" => matches!(v, Value::Bytes(b) if b.len() == NONCE_SIZE),
894        "identity_key" | "connect_key" | "proof" | "member_endorsement" | "session_proof" => {
895            matches!(v, Value::Bytes(_))
896        }
897        "tls_binding" | "tls_status" | "connect_binding" | "connect_status" | "statement" => {
898            SignedTbs::from_value(v).is_ok()
899        }
900        "capabilities" => {
901            matches!(v, Value::Int(n) if *n >= 0 && *n < i128::from(MAX_PROTOCOL_INT))
902        }
903        "accepted" => matches!(v, Value::Int(0 | 1)),
904        "refusal_code" => matches!(v, Value::Text(t) if RefusalCode::parse(t).is_some()),
905        _ => false,
906    }
907}
908
909/// Whether `leaf` holds `key`'s ML-DSA-87 half.
910fn in_leaf(key: &[u8], leaf: &[u8]) -> bool {
911    key.len() >= MLDSA_KEY_SIZE
912        && leaf
913            .windows(MLDSA_KEY_SIZE)
914            .any(|w| w == &key[..MLDSA_KEY_SIZE])
915}
916
917/// Whether two carried keys share their ML-DSA-87 half, or a classical half.
918fn shares_a_half(a: &[u8], b: &[u8]) -> bool {
919    if a.len() < MLDSA_KEY_SIZE || b.len() < MLDSA_KEY_SIZE {
920        return a == b;
921    }
922    let (classical_a, classical_b) = (&a[MLDSA_KEY_SIZE..], &b[MLDSA_KEY_SIZE..]);
923    a[..MLDSA_KEY_SIZE] == b[..MLDSA_KEY_SIZE]
924        || (!classical_a.is_empty() && classical_a == classical_b)
925}
926
927fn encode_frame(frame_type: &str, entries: Vec<(Value, Value)>) -> Vec<u8> {
928    encode_frame_version(VERSION, frame_type, entries)
929}
930
931/// `encode_frame` in a given version: CONNECT and HELLO carry the version the
932/// client chose; opener, challenge and status are always 4.
933fn encode_frame_version(version: i64, frame_type: &str, entries: Vec<(Value, Value)>) -> Vec<u8> {
934    let mut all = vec![
935        entry("version", Value::Int(i128::from(version))),
936        entry("frame_type", Value::text(frame_type)),
937    ];
938    all.extend(entries);
939    cbor::encode(&Value::Map(all)).expect("a handshake frame's integers are all below 2^53")
940}
941
942fn entry(key: &str, value: Value) -> (Value, Value) {
943    (Value::text(key), value)
944}
945
946fn hex_of(bytes: &[u8]) -> String {
947    bytes.iter().map(|b| format!("{b:02x}")).collect()
948}