macula_seal (macula v13.2.0)
View SourceEnd-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
-type parties() :: {binary(), <<_:256>>, <<_:256>>}.
-type private_key() :: #{mlkem_dk := <<_:25344>>, p384_priv => <<_:384>>}.
A request's request_id, caller and target.
-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.
-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.
-type request() :: #{frame_type := binary(), realm := <<_:256>>, procedure := binary(), caller := <<_:256>>, target := <<_:256>>, request_id := binary(), deadline := non_neg_integer()}.
Functions
The request and reply keys of one call or STREAM_OPEN.
-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.
-spec event_aad(<<_:256>>, binary(), <<_:256>>, non_neg_integer(), non_neg_integer()) -> binary().
What an event's sealed payload is bound to.
-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.
-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.
-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.
-spec key_hash(binary()) -> <<_:384>>.
The SHA-384 of a KEM key as carried, which the combiner binds.
-spec key_id(binary()) -> <<_:64>>.
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.
-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.
-spec random_nonce() -> <<_:96>>.
A fresh random nonce, for a reply, a provider stream frame or an event.
-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.
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.
-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.
-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.
The caller-to-provider and provider-to-caller keys of one stream.
-spec stream_nonce(non_neg_integer()) -> <<_:96>>.
A caller stream frame's nonce: its seq, as a 96-bit big-endian integer.