macula_handshake (macula v11.4.0)

View Source

The post-quantum connection handshake, as plans/DESIGN_PQ_HANDSHAKE_FRAMES.md lays it out: the opener, challenge, CONNECT, HELLO and status frames, built as deterministic CBOR bytes and checked as received.

The client opens the control stream with an opener. The station answers with a challenge: its carried identity key, its TLS binding and status statement, all precomputed, and a fresh nonce. The client checks the challenge against the node_id it dialed and the leaf it received, before it signs anything, and answers with CONNECT: its identity and CONNECT keys, the CONNECT binding and status statement, and a proof by the CONNECT key over the nonce, both node_ids, the leaf hash and the challenge hash. The station checks CONNECT, the puzzle before any signature, and answers with HELLO. 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 is decoded with macula_record_cbor:decode_strict/1 and must 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

Functions

The station's check of CONNECT, and the HELLO bytes to send. The station checks the frame, 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, and the proof against the challenge bytes it sent and the leaf this connection presented. 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, the profile, the station's carried key, that each key in view serves one purpose, the station's node_id against the one dialed, the TLS binding against the leaf received in this TLS handshake, and the status statement, before it signs the proof. On a refusal it closes without CONNECT.

A station's challenge: 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. It carries nothing that relates to identity.

The client's reading of HELLO: the station's capabilities, or its refusal code.

The station's check of the first frame.

A peer's status frame, checked against the binding and identity key the handshake verified, and when the new statement expires.

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

client/0

-type client() ::
          #{node_id := <<_:256>>,
            identity_key := binary(),
            connect_key := binary(),
            connect_binding := envelope(),
            capabilities := non_neg_integer(),
            status_expires_at := non_neg_integer(),
            binding_not_after := non_neg_integer(),
            puzzle := solved | unsolved | not_checked}.

client_session/0

-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()}.

close_reason/0

-type close_reason() ::
          unexpected_frame | unsupported_version | malformed_frame | profile_mismatch |
          key_purpose_reuse |
          {peer_identity_mismatch, #{expected := <<_:256>>, derived := <<_:256>>}} |
          puzzle_invalid | proof_invalid |
          macula_key_bindings:refusal() |
          {refused, refusal_code()}.

envelope/0

-type envelope() :: macula_key_bindings:envelope().

peer/0

-type peer() ::
          #{profile := profile(), identity_key := binary(), binding := envelope(), now := integer()}.

profile/0

-type profile() :: macula_crypto_profile:profile().

puzzle_mode/0

-type puzzle_mode() :: off | log_only | enforce.

refusal_code/0

-type refusal_code() :: unsupported_version | puzzle_invalid | not_accepted.

station/0

-type station() ::
          #{node_id := <<_:256>>,
            identity_key := binary(),
            tls_binding := envelope(),
            status_expires_at := non_neg_integer(),
            binding_not_after := non_neg_integer()}.

station_material/0

-type station_material() ::
          #{profile := profile(),
            identity_key := binary(),
            tls_binding := envelope(),
            tls_status := envelope()}.

station_session/0

-type station_session() ::
          #{profile := profile(),
            challenge := binary(),
            leaf := binary(),
            puzzle := #{difficulty := 0..256, mode := puzzle_mode()},
            capabilities := non_neg_integer(),
            now := integer()}.

Functions

accept_connect(Bytes, Session)

-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, 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, and the proof against the challenge bytes it sent and the leaf this connection presented. 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.

answer_challenge(Bytes, Session)

-spec answer_challenge(binary(), client_session()) ->
                          {ok, binary(), station()} | {error, close_reason()}.

The client's check of a challenge and, when every check passes, its CONNECT. The client checks the frame, the profile, the station's carried key, that each key in view serves one purpose, the station's node_id against the one dialed, the TLS binding against the leaf received in this TLS handshake, and the status statement, before it signs the proof. On a refusal it closes without CONNECT.

challenge(_)

-spec challenge(station_material()) -> binary().

A station's challenge: its precomputed identity key, TLS binding and status statement, and a fresh nonce. The station keeps the bytes it sends, for the proof check.

open_frame_kind(Other)

-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.

opener()

-spec opener() -> binary().

The client's first frame on the control stream. It carries nothing that relates to identity.

read_hello(Bytes)

-spec read_hello(binary()) -> {ok, #{capabilities := non_neg_integer()}} | {error, close_reason()}.

The client's reading of HELLO: the station's capabilities, or its refusal code.

read_opener(Bytes)

-spec read_opener(binary()) -> ok | {error, close_reason()}.

The station's check of the first frame.

read_status(Bytes, _)

-spec read_status(binary(), peer()) -> {ok, non_neg_integer()} | {error, close_reason()}.

A peer's status frame, checked against the binding and identity key the handshake verified, and when the new statement expires.

read_status_wire(Wire, _)

-spec read_status_wire(term(), peer()) -> {ok, non_neg_integer()} | {error, close_reason()}.

read_status/2 on a frame already decoded from CBOR, for a connection that decodes each frame once to route it.

status(Statement)

-spec status(envelope()) -> binary().

A status frame carrying a fresh status statement, sent at every reissue.