macula_handshake (macula v15.1.0)
View SourceThe post-quantum connection handshake of wire 1.0 (docs/design/WIRE_1_0.md section 4, on docs/design/DESIGN_PQ_HANDSHAKE_FRAMES.md and docs/design/DESIGN_NEIGHBOUR_CHANNEL_BINDING.md): the opener, challenge, CONNECT, HELLO and status frames, built as deterministic CBOR bytes and checked as received.
Handshake version 5 is the only version, and every frame carries it. The client opens the control stream with an opener naming its ranges, the versions and profiles it speaks. The station chooses the highest common version and the first of the client's profiles it supports, or answers at once with a HELLO refusal naming its own ranges. Its challenge names the choice, repeats the station's ranges, and carries its identity key, TLS binding and status statement and a fresh nonce. The client checks that the choice is the one its own ranges and the station's give, so nobody on the path can downgrade it, then the station's identity, binding and status, and answers with CONNECT: its keys, binding and status, its ranges again, the chosen profile, and a proof V3 by its CONNECT key over the nonce, both node_ids, the leaf hash, the challenge hash, E (the session's TLS exporter value) and the hash of the CONNECT tbs, the frame with the proof empty. The station checks CONNECT, the puzzle before any signature, and answers HELLO with a session proof V2 by its identity key over E, both frames, both node_ids and the HELLO tbs. A node never completes below the version floor it holds for the peer (section 4.6). Status frames renew a peer's statement on the open connection.
Frames here are CBOR bytes without the length prefix: macula_frame:encode_bytes/1 frames them for the stream, and macula_frame:parse_stream_bytes/1 reads them back. A frame must be deterministic CBOR and hold exactly the keys of its type, each with its type and length. Close reasons are local. On the wire, a refusing station sends only HELLO with one coarse refusal code.
Summary
Types
The version floor held for a peer: the highest version completed with it, or none.
floor gives the floor the station holds for a client's node_id.
What the client needs to read HELLO: what the session proof covers.
Ranges as a peer sent them: profile names stay text, since a peer may name a profile this node does not know.
The ranges a node speaks: [Min, Max] of handshake versions, and its profiles in its order of preference.
versions and profiles are the client's ranges, as its opener sent them; floor is the floor it holds for the station it dialled.
The session's TLS exporter (RFC 8446 section 7.5): label, context and length to bytes.
The station's choice from an opener, kept for the CONNECT check.
Functions
The station's check of CONNECT, and the HELLO bytes to send. The station checks the frame, that the client's ranges are the ones its opener sent and the profile the one chosen, the carried keys and the proof length, that each key serves one purpose, the puzzle on the derived node_id, the CONNECT binding and status statement, the proof V3 against the challenge bytes it sent, the leaf this connection presented and the CONNECT tbs, and then its floor for the client, so whether it holds one is told only to the node that proved the identity. The puzzle comes before any signature: under enforce an unsolved puzzle is refused with puzzle_invalid, under log_only it is accepted and reported, and off skips it. A refusal carries a HELLO with accepted 0 and one coarse refusal code.
The client's check of a challenge and, when every check passes, its CONNECT. The client checks the frame, that the choice is the one its own ranges and the station's give, its floor for the station, the profile, the station's carried key, that each key in view serves one purpose, the station's node_id against the one dialled, the TLS binding against the leaf received in this TLS handshake, and the status statement, before it signs the proof. A station that cannot serve the opener answers HELLO instead, read as {refused, Code, StationRanges}. On any refusal the client closes without CONNECT.
A station's challenge for its choice: the chosen profile, its own ranges, its precomputed identity key, TLS binding and status statement, and a fresh nonce. The station keeps the bytes it sends, for the proof check.
What a decoded frame on an open connection is, by its frame_type: a status frame, a handshake frame, which has no place after HELLO, or any other frame, which macula_frame reads.
The client's first frame on the control stream: its ranges, and nothing that relates to identity.
The ranges a node of Profile speaks: a node supports N and N-1 of the versions it knows, and in wire 1.0 that is 5 alone (section 4.1).
The client's reading of HELLO: the station's capabilities, once the session proof verifies under the station's identity key over the HELLO tbs, or why not. An unknown refusal code reads as not_accepted.
The station's reading of the first frame, against its own ranges: its choice, or a HELLO refusal naming its ranges to send before closing (unsupported_version or profile_unsupported), or a reason to close without one.
A HELLO refusal as received, early or after CONNECT: its code, and whether that code is one this node knows (known) or an unknown one read as not_accepted (fallback), which the caller counts as wire_enum_fallback.
A peer's status frame, checked against the binding and identity key the handshake verified, and when the new statement expires. STATUS travels after HELLO, so it is an envelope frame: it may carry frame_id, sent_at_ms and ext (section 3.1). An unknown critical extension refuses it with extension_unsupported, and the caller drops it.
read_status/2 on a frame already decoded from CBOR, for a connection that decodes each frame once to route it.
A status frame carrying a fresh status statement, sent at every reissue.
Types
-type choice() :: #{version := version(), profile := profile(), client_ranges := wire_ranges()}.
The version floor held for a peer: the highest version completed with it, or none.
-type client() :: #{node_id := <<_:256>>, version := version(), identity_key := binary(), connect_key := binary(), connect_binding := envelope(), capabilities := non_neg_integer(), member_endorsement := binary(), status_expires_at := non_neg_integer(), binding_not_after := non_neg_integer(), puzzle := solved | unsolved | not_checked}.
-type client_session() :: #{profile := profile(), expected_node_id := <<_:256>>, leaf := binary(), identity_key := binary(), connect_key := macula_node_keys:node_key(), connect_binding := envelope(), connect_status := envelope(), capabilities := non_neg_integer(), now := integer(), export := exporter(), versions := [non_neg_integer()], profiles := [profile()], floor => floor(), member_endorsement => binary()}.
floor gives the floor the station holds for a client's node_id.
-type close_reason() :: unexpected_frame | unsupported_version | malformed_frame | profile_mismatch | profile_unsupported | downgrade_detected | negotiation_mismatch | version_below_floor | key_purpose_reuse | {peer_identity_mismatch, #{expected := <<_:256>>, derived := <<_:256>>}} | puzzle_invalid | proof_invalid | macula_key_bindings:refusal() | session_proof_invalid | session_proof_missing | session_proof_rate | exporter_unavailable | {refused, refusal_code()} | {refused, refusal_code(), wire_ranges()}.
-type envelope() :: macula_key_bindings:envelope().
-type exporter() :: fun((binary(), binary(), pos_integer()) -> {ok, binary()} | {error, term()}).
What the client needs to read HELLO: what the session proof covers.
-type floor() :: version() | none.
-type profile() :: macula_crypto_profile:profile().
-type puzzle_mode() :: off | log_only | enforce.
-type ranges() :: #{versions := [non_neg_integer()], profiles := [profile()]}.
Ranges as a peer sent them: profile names stay text, since a peer may name a profile this node does not know.
-type refusal_code() ::
unsupported_version | profile_unsupported | puzzle_invalid | session_proof_rate | not_accepted.
The ranges a node speaks: [Min, Max] of handshake versions, and its profiles in its order of preference.
-type station() :: #{node_id := <<_:256>>, identity_key := binary(), tls_binding := envelope(), status_expires_at := non_neg_integer(), binding_not_after := non_neg_integer(), version := version()}.
-type station_material() :: #{profile := profile(), identity_key := binary(), tls_binding := envelope(), tls_status := envelope()}.
versions and profiles are the client's ranges, as its opener sent them; floor is the floor it holds for the station it dialled.
-type station_session() :: #{profile := profile(), choice := choice(), challenge := binary(), leaf := binary(), puzzle := #{difficulty := 0..256, mode := puzzle_mode()}, capabilities := non_neg_integer(), now := integer(), export := exporter(), sign_session_proof := fun((<<_:256>>, iodata()) -> {ok, binary()} | {error, session_proof_rate}), floor => fun((<<_:256>>) -> floor())}.
-type version() :: 5.
The session's TLS exporter (RFC 8446 section 7.5): label, context and length to bytes.
-type wire_ranges() :: #{versions := [non_neg_integer()], profiles := [binary()]}.
The station's choice from an opener, kept for the CONNECT check.
Functions
-spec accept_connect(binary(), station_session()) -> {accepted, client(), binary()} | {refused, close_reason(), binary()}.
The station's check of CONNECT, and the HELLO bytes to send. The station checks the frame, that the client's ranges are the ones its opener sent and the profile the one chosen, the carried keys and the proof length, that each key serves one purpose, the puzzle on the derived node_id, the CONNECT binding and status statement, the proof V3 against the challenge bytes it sent, the leaf this connection presented and the CONNECT tbs, and then its floor for the client, so whether it holds one is told only to the node that proved the identity. The puzzle comes before any signature: under enforce an unsolved puzzle is refused with puzzle_invalid, under log_only it is accepted and reported, and off skips it. A refusal carries a HELLO with accepted 0 and one coarse refusal code.
-spec answer_challenge(binary(), client_session()) -> {ok, binary(), station(), expect_hello()} | {error, close_reason()}.
The client's check of a challenge and, when every check passes, its CONNECT. The client checks the frame, that the choice is the one its own ranges and the station's give, its floor for the station, the profile, the station's carried key, that each key in view serves one purpose, the station's node_id against the one dialled, the TLS binding against the leaf received in this TLS handshake, and the status statement, before it signs the proof. A station that cannot serve the opener answers HELLO instead, read as {refused, Code, StationRanges}. On any refusal the client closes without CONNECT.
-spec challenge(station_material(), choice(), ranges()) -> binary().
A station's challenge for its choice: the chosen profile, its own ranges, its precomputed identity key, TLS binding and status statement, and a fresh nonce. The station keeps the bytes it sends, for the proof check.
-spec open_frame_kind(term()) -> status | handshake | other.
What a decoded frame on an open connection is, by its frame_type: a status frame, a handshake frame, which has no place after HELLO, or any other frame, which macula_frame reads.
The client's first frame on the control stream: its ranges, and nothing that relates to identity.
The ranges a node of Profile speaks: a node supports N and N-1 of the versions it knows, and in wire 1.0 that is 5 alone (section 4.1).
-spec read_hello(binary(), expect_hello()) -> {ok, #{capabilities := non_neg_integer(), version := version()}} | {error, close_reason()}.
The client's reading of HELLO: the station's capabilities, once the session proof verifies under the station's identity key over the HELLO tbs, or why not. An unknown refusal code reads as not_accepted.
-spec read_opener(binary(), ranges()) -> {ok, choice()} | {refused, unsupported_version | profile_unsupported, binary()} | {error, close_reason()}.
The station's reading of the first frame, against its own ranges: its choice, or a HELLO refusal naming its ranges to send before closing (unsupported_version or profile_unsupported), or a reason to close without one.
-spec read_refusal(binary()) -> {refused, refusal_code(), known | fallback} | {error, close_reason()}.
A HELLO refusal as received, early or after CONNECT: its code, and whether that code is one this node knows (known) or an unknown one read as not_accepted (fallback), which the caller counts as wire_enum_fallback.
-spec read_status(binary(), peer()) -> {ok, non_neg_integer()} | {error, close_reason() | extension_unsupported}.
A peer's status frame, checked against the binding and identity key the handshake verified, and when the new statement expires. STATUS travels after HELLO, so it is an envelope frame: it may carry frame_id, sent_at_ms and ext (section 3.1). An unknown critical extension refuses it with extension_unsupported, and the caller drops it.
-spec read_status_wire(term(), peer()) -> {ok, non_neg_integer()} | {error, close_reason() | extension_unsupported}.
read_status/2 on a frame already decoded from CBOR, for a connection that decodes each frame once to route it.
A status frame carrying a fresh status statement, sent at every reissue.