macula_seal (macula v13.2.1)

View Source

End-to-end payload sealing, scheme 1: what a node needs to seal a payload so that the stations relaying it cannot read it (plans/DESIGN_E2E_PAYLOAD_CONFIDENTIALITY.md). The byte-exact construction is test/vectors/E2E_SEAL_V1.md, and every function here reproduces that file's vectors, which an independent Rust implementation generated.

The key agreement is ML-KEM-1024 in pq_pure, and ML-KEM-1024 with an ephemeral P-384 ECDH in pq_hybrid, combined with HKDF-SHA-384 over both secrets, both ciphertexts and the recipient's key. Payloads are sealed with AES-256-GCM. Everything here is a pure function on OTP crypto; the frames that carry a sealed payload are built elsewhere.

Summary

Types

A request's request_id, caller and target.

A recipient's KEM key: the ML-KEM-1024 encapsulation key, plus the uncompressed P-384 point in pq_hybrid.

Its private half as OTP holds it: the expanded ML-KEM decapsulation key, plus the P-384 scalar in pq_hybrid.

Functions

The request and reply keys of one call or STREAM_OPEN.

How many bytes a KEM key as carried has in a profile: the ML-KEM-1024 encapsulation key, plus the uncompressed P-384 point in pq_hybrid.

What an event's sealed payload is bound to.

A publisher's subkey of a group epoch key: every publisher seals under its own, so no two ever share a key.

A fresh KEM keypair in a profile: an ML-KEM-1024 keypair, plus a P-384 keypair in pq_hybrid. The private P-384 scalar is always 48 bytes.

A recipient's KEM key as it is carried and hashed: the ML-KEM key, followed by the P-384 point in pq_hybrid.

The SHA-384 of a KEM key as carried, which the combiner binds.

The 8-byte id a sealed payload names its recipient key by.

The plaintext of a sealed payload, or sealed_refused when the key, the nonce, the AAD or a single bit of it differ.

The public key a KEM key as carried holds, and the profile its size names: 1568 bytes in pq_pure, 1665 in pq_hybrid, whose tail is an uncompressed P-384 point. Anything else carries no key.

A fresh random nonce, for a reply, a provider stream frame or an event.

The shared secret a kem_ct carries, recovered with the recipient's private key: the recipient's side. Carried is the recipient's own key as carried, which the combiner binds. A kem_ct of the wrong length, a P-384 point not on the curve, or an ECDH output of zero is refused.

What a reply's sealed payload is bound to: its request's routing fields, the reply's frame type, the request hash and the provider.

What a request's sealed payload is bound to: its routing fields.

AES-256-GCM: the ciphertext with its 16-byte tag appended.

A fresh shared secret to Recipient, and the kem_ct that carries it: the sender's side, with fresh randomness each time.

What a stream frame's sealed body is bound to. Direction is 0 from caller to provider and 1 back.

The caller-to-provider and provider-to-caller keys of one stream.

A caller stream frame's nonce: its seq, as a 96-bit big-endian integer.

Types

parties/0

-type parties() :: {binary(), <<_:256>>, <<_:256>>}.

private_key/0

-type private_key() :: #{mlkem_dk := <<_:25344>>, p384_priv => <<_:384>>}.

A request's request_id, caller and target.

profile/0

-type profile() :: pq_pure | pq_hybrid.

A recipient's KEM key: the ML-KEM-1024 encapsulation key, plus the uncompressed P-384 point in pq_hybrid.

public_key/0

-type public_key() :: #{mlkem_ek := <<_:12544>>, p384_pub => <<_:776>>}.

Its private half as OTP holds it: the expanded ML-KEM decapsulation key, plus the P-384 scalar in pq_hybrid.

request/0

-type request() ::
          #{frame_type := binary(),
            realm := <<_:256>>,
            procedure := binary(),
            caller := <<_:256>>,
            target := <<_:256>>,
            request_id := binary(),
            deadline := non_neg_integer()}.

Functions

call_keys(Secret, FrameType, _)

-spec call_keys(binary(), binary(), parties()) -> {<<_:256>>, <<_:256>>}.

The request and reply keys of one call or STREAM_OPEN.

carried_key_size(_)

-spec carried_key_size(profile()) -> pos_integer().

How many bytes a KEM key as carried has in a profile: the ML-KEM-1024 encapsulation key, plus the uncompressed P-384 point in pq_hybrid.

event_aad(Realm, Topic, Publisher, Seq, PublishedAt)

-spec event_aad(<<_:256>>, binary(), <<_:256>>, non_neg_integer(), non_neg_integer()) -> binary().

What an event's sealed payload is bound to.

event_key(GroupKey, Publisher)

-spec event_key(<<_:256>>, <<_:256>>) -> <<_:256>>.

A publisher's subkey of a group epoch key: every publisher seals under its own, so no two ever share a key.

generate_key(_)

-spec generate_key(profile()) -> {public_key(), private_key()}.

A fresh KEM keypair in a profile: an ML-KEM-1024 keypair, plus a P-384 keypair in pq_hybrid. The private P-384 scalar is always 48 bytes.

key_as_carried(_)

-spec key_as_carried(public_key()) -> binary().

A recipient's KEM key as it is carried and hashed: the ML-KEM key, followed by the P-384 point in pq_hybrid.

key_hash(Carried)

-spec key_hash(binary()) -> <<_:384>>.

The SHA-384 of a KEM key as carried, which the combiner binds.

key_id(Carried)

-spec key_id(binary()) -> <<_:64>>.

The 8-byte id a sealed payload names its recipient key by.

open(Key, Nonce, Aad, Sealed)

-spec open(<<_:256>>, <<_:96>>, binary(), binary()) -> {ok, binary()} | {error, sealed_refused}.

The plaintext of a sealed payload, or sealed_refused when the key, the nonce, the AAD or a single bit of it differ.

public_key(Carried)

-spec public_key(term()) -> {ok, profile(), public_key()} | error.

The public key a KEM key as carried holds, and the profile its size names: 1568 bytes in pq_pure, 1665 in pq_hybrid, whose tail is an uncompressed P-384 point. Anything else carries no key.

random_nonce()

-spec random_nonce() -> <<_:96>>.

A fresh random nonce, for a reply, a provider stream frame or an event.

recipient_secret(Profile, Private, Carried, KemCt)

-spec recipient_secret(profile(), private_key(), binary(), binary()) ->
                          {ok, binary()} | {error, sealed_refused}.

The shared secret a kem_ct carries, recovered with the recipient's private key: the recipient's side. Carried is the recipient's own key as carried, which the combiner binds. A kem_ct of the wrong length, a P-384 point not on the curve, or an ECDH output of zero is refused.

reply_aad(Request, ReplyFrameType, RequestHash, RespondedBy)

-spec reply_aad(request(), binary(), <<_:384>>, <<_:256>>) -> binary().

What a reply's sealed payload is bound to: its request's routing fields, the reply's frame type, the request hash and the provider.

request_aad(Request)

-spec request_aad(request()) -> binary().

What a request's sealed payload is bound to: its routing fields.

seal(Key, Nonce, Aad, Plain)

-spec seal(<<_:256>>, <<_:96>>, binary(), binary()) -> binary().

AES-256-GCM: the ciphertext with its 16-byte tag appended.

sender_secret(_, Recipient)

-spec sender_secret(profile(), public_key()) -> {binary(), binary()}.

A fresh shared secret to Recipient, and the kem_ct that carries it: the sender's side, with fresh randomness each time.

stream_aad(FrameType, RequestId, Seq, Direction)

-spec stream_aad(binary(), binary(), non_neg_integer(), 0 | 1) -> binary().

What a stream frame's sealed body is bound to. Direction is 0 from caller to provider and 1 back.

stream_keys(Secret, _)

-spec stream_keys(binary(), parties()) -> {<<_:256>>, <<_:256>>}.

The caller-to-provider and provider-to-caller keys of one stream.

stream_nonce(Seq)

-spec stream_nonce(non_neg_integer()) -> <<_:96>>.

A caller stream frame's nonce: its seq, as a 96-bit big-endian integer.