Files
rfc-index/nomos/raw/nomos-message-encapsulation.md
Cofson 93cfaa0d06 Address PR #218 review feedback
- Add RFC 2119 link in Semantics section
- Remove MUST keyword from Document Structure (descriptive text)
- Replace vague 'Formatting section' references with proper links
- Remove duplicated Overview section (content already in Abstract)
- Add reference-style links for external specs (Key Types, PoQ, PoS, SDP, PoL)
- Fix first-person language throughout
2025-12-25 21:05:45 +01:00

40 KiB

title, name, status, category, tags, editor, contributors
title name status category tags editor contributors
NOMOS-MESSAGE-ENCAPSULATION Nomos Message Encapsulation Mechanism raw Standards Track nomos, blend, message-encapsulation, privacy, cryptography Marcin Pawlowski
Youngjoon Lee <youngjoon@status.im>
Alexander Mozeika <alexander.mozeika@status.im>
Mehmet Gonen <mehmet@status.im>
Álvaro Castro-Castilla <alvaro@status.im>
Daniel Kashepava <danielkashepava@status.im>
Daniel Sanchez Quiros <danielsq@status.im>
Filip Dimitrijevic <filip@status.im>

Abstract

The message encapsulation mechanism is a core component of the Blend Protocol that ensures privacy and security during node-to-node message transmission. By implementing multiple encryption layers and cryptographic operations, this mechanism keeps messages confidential while concealing their origins. The encapsulation process includes building a multi-layered structure with public headers, private headers, and encrypted payloads, using cryptographic keys and proofs for layer security and authentication, applying verifiable random node selection for message routing, and using shared key derivation for secure inter-node communication.

This document outlines the cryptographic notation, data structures, and algorithms essential to the encapsulation process.

Semantics

The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.

Document Structure

This specification is organized into two distinct parts to serve different audiences and use cases:

Protocol Specification contains the normative requirements necessary for implementing an interoperable Blend Protocol node. This section defines the cryptographic primitives, message formats, network protocols, and behavioral requirements that all implementations must follow to ensure compatibility and maintain the protocol's privacy guarantees. Protocol designers, auditors, and those seeking to understand the core mechanisms should focus on this part.

Implementation Considerations provides non-normative guidance for implementers. This section offers practical recommendations, optimization strategies, and detailed examples that help developers build efficient and robust implementations. While these details are not required for interoperability, they represent best practices learned from reference implementations and can significantly improve performance and reliability.

Protocol Specification

Introduction

The message encapsulation mechanism is part of the Blend Protocol and it describes the cryptographic operations necessary for building and processing messages by a Blend node. See the Blend Protocol Formatting specification for additional context on message structure and formatting conventions.

Notation

Key Collections:

\mathbf K^{n}h = \{(K^{n}{0}, k^{n}{0}, \pi{Q}^{K_{0}^{n}}),...,(K^{n}{h-1}, k^{n}{h-1}, \pi_{Q}^{K_{h-1}^{n}}) \} is a collection of h key pairs for a node n with proofs of quota, where K_{i}^{n} is the $i$-th public key and k_{i}^{n} is its corresponding private key, and \pi_{Q}^{K_{i}^{n}} is its proof of quota.

Ed25519PublicKey = bytes
Ed25519PrivateKey = bytes
KEY_SIZE = 32
ProofOfQuota = bytes
PROOF_OF_QUOTA_SIZE = 160

KeyCollection = List[KeyPair]

class KeyPair:
    signing_public_key: Ed25519PublicKey
    signing_private_key: Ed25519PrivateKey
    proof_of_quota: ProofOfQuota

class ProofOfQuota:
    key_nullifier: zkhash  # 32 bytes
    proof: bytes  # 128 bytes

For more information about key generation mechanism please refer to the Key Types and Generation Specification.

For more information about proof of quota please refer to the Proof of Quota Specification.

Service Declaration Protocol:

P^n is a public key of the node n, which is globally accessible using the Service Declaration Protocol (SDP). This notation is used to distinguish the origin of the key, hence the following simplified notation.

For more information about Service Declaration Protocol please refer to the Service Declaration Protocol.

\mathcal{N} = \text{SDP}(s) is the set of nodes globally accessible using the SDP.

Nodes = set[Ed25519PublicKey]  # set of signing public keys

N =|\mathcal{N}| is the number of nodes globally accessible using the SDP.

Shared Keys:

\kappa^{n,m}{i} = k^{n}{i} \cdot P^{m} = p^{m} \cdot K^{n}_{i}, is a shared key calculated between node n and node m using the $i$-th key of the node n, P^{m} is the public key of the node m retrieved from the SDP protocol and p^m is its corresponding private key.

SharedKey = bytes  # KEY_SIZE

Proof of Selection:

\pi^{K^{n}{l},m}{S} is the proof of selection of the public key K^{n}_l to the node index m from a set of all nodes \mathcal N.

ProofOfSelection = bytes
PROOF_OF_SELECTION_SIZE = 32

For more information about the proof of selection, please refer to the Proof of Selection Specification.

Hash Functions:

H_{\mathbf N}() is a domain-separated hash function dedicated to the node index selection (the implementation of the hash function is blake2b).

def hashds(domain=b"BlendNode", data: bytes) -> bytes:
    return Blake2B.hash512(domain + data)

H_\mathbf{I}() is a domain-separated hash function dedicated to the initialization of the blend header (the implementation of the hash function is blake2b).

def hashds(domain=b"BlendInitialization", data: bytes) -> bytes:
    return Blake2b.hash512(domain + data)

H_\mathbf{b}() is a domain-separated hash function dedicated to the blend header encryption operations (the implementation of the hash function is blake2b).

def hashds(domain=b"BlendHeader", data: bytes) -> bytes:
    return Blake2b.hash512(domain + data)

H_\mathbf{P}() is a domain-separated hash function dedicated to the payload encryption operations (the implementation of the hash function is blake2b).

def hashds(domain=b"BlendPayload", data: bytes) -> bytes:
    return Blake2b.hash512(domain + data)

Encapsulation Parameters:

\beta_{max} is the maximal number of blending headers in the private header.

ENCAPSULATION_COUNT: int

Pseudo-Random Generation:

\text {CSPRBG}() is a generalized cryptographically secure pseudo-random bytes generator, it is implemented as BLAKE2b-Based PRNG Construction.

\text {CSPRBG}()_{x} is a cryptographically secure pseudo-random bytes generator whose output is restricted to x bytes, it is implemented as BLAKE2b-Based PRNG Construction.

def pseudo_random(domain: bytes, key: bytes, size: int) -> bytes:
    rand = BlakeRng.from_seed(hashds(domain, key)).generate(size)
    assert len(rand) == size
    return rand

Basic Operations:

|t| returns the length of the t expressed in bytes.

\oplus is a XOR operation.

def xor(a: bytes, b: bytes) -> bytes:
    assert len(a) == len(b)
    return bytes(x ^ y for x, y in zip(a, b))

Encryption and Decryption:

E_k(x)=\text{CSPRBG}(k) \oplus x is an encryption that uses a cryptographically secure pseudo-random bytes generator with a secret k and payload x.

def encrypt(data: bytes, key: bytes) -> bytes:
    return xor(data, pseudo_random(b"BlendEncapsulation", key, len(data)))

D_k(x)=\text{CSPRBG}(k) \oplus x is a decryption that uses cryptographically secure pseudo-random bytes generator with a secret k and payload x.

def decrypt(data: bytes, key: bytes) -> bytes:
    return xor(data, pseudo_random(b"BlendEncapsulation", key, len(data)))

Construction

Message Structure

The following defines the message structure that provides the protocol with the envisioned capabilities.

A node n constructs a message \mathbf M = (\mathbf H, \mathbf h, \mathbf P) according to the format presented below.

class Message:
    public_header: PublicHeader
    private_header: PrivateHeader
    payload: EncryptedPayload

Public Header:

\mathbf H is a public header:

  1. V, version of the header, it is set to 1.
  2. K^{n}_i, a public key from the set \mathbf K^n_h.
  3. \pi^{K^{n}i}{Q}, a corresponding proof of quota for the key K^{n}_i from the set \mathbf K^n_h and contains its proof nullifier.
  4. \sigma_{K^{n}_{i}}(\mathbf {h|P}i), a signature of the concatenation of the $i$-th encapsulation of the payload \mathbf P and the private header \mathbf h, that can be verified by the public key K^{n}{i}.
Signature = bytes
SIGNATURE_SIZE = 64

class PublicHeader:
    version: int = 1  # u8
    signing_public_key: Ed25519PublicKey
    proof_of_quota: ProofOfQuota
    signature: Signature

Private Header:

\mathbf h = (\mathbf b_1,...,\mathbf b_{\beta_{max}}) is an encrypted private header:

\mathbf b_l is a blending header:

  1. K^{n}_{l}, a public key from the set \mathbf K^n_h.
  2. \pi^{K^{n}{l}}{Q}, a corresponding proof of quota for the key K^{n}_l from the \mathbf K^n_h and contains its proof nullifier.
  3. \sigma_{K^{n}_{l}}(\mathbf {h|P}l), a signature of the concatenation of the $l$-th encapsulation of the payload \mathbf P and the private header \mathbf h, that can be verified by public key K^{n}{l}.
  4. \pi^{K^{n}{l+1},m{l+1}}{S}, a proof of selection of the node index m{l+1} assuming public key K^{n}_{l+1}.
  5. \Omega, a flag that indicates that this is the last blending header.
PrivateHeader = List[EncryptedBlendingHeader]  # length: ENCAPSULATION_COUNT
EncryptedBlendingHeader = bytes

class BlendingHeader:
    signing_public_key: Ed25519PublicKey
    proof_of_quota: ProofOfQuota
    signature: Signature
    proof_of_selection: ProofOfSelection
    is_last: bool  # 1 byte

Payload:

\mathbf P is a payload.

EncryptedPayload = bytes

PAYLOAD_BODY_SIZE = 34 * 1024

class Payload:
    header: PayloadHeader
    body: bytes  # PAYLOAD_BODY_SIZE

class PayloadHeader:
    payload_type: PayloadType  # 1 byte
    body_len: int  # u16

class PayloadType(Enum):
    COVER = 0x00
    DATA = 0x01

Keys and Proof Generation

For simplicity of the presentation, this specification does not distinguish between signing and encryption keys. However, in practice, such a distinction is necessary, that is:

  • The \mathbf K^n_h contains Ephemeral Signing Keys (ESK) that are part of the PoQ generation and are used for message signing; these are included in the public and private headers.
  • Shared secret keys used for encryption of messages are generated from an Ephemeral Encryption Key (sender), which is derived from the ESK, and from a Non-ephemeral Encryption Key (NEK) (receiver), which is derived from a Non-ephemeral Signing Key (NSK) retrieved from the SDP protocol.

For more information, look at Key Types and Generation Specification.

The first step is to generate a set of keys alongside all necessary proofs that will be used in the next steps of the algorithm.

Step 1: Generate Key Collection

Generate the collection \mathbf K^n_h, where h defines the number of encapsulation layers such that h \le \beta_{max}.

def generate_key_collection(num_layers: int) -> List[KeyPair]:
    assert num_layers <= ENCAPSULATION_COUNT
    # Generate `num_layers` random KeyPairs non-deterministically.
    return [KeyPair.random() for _ in range(num_layers)]

The key collection generation requires generation of Proof of Quota (Proof of Quota Specification) for each key, as defined in the following steps.

The ProofOfQuotaPublic (Public values) structure must be filled with public information:

  1. session, core_quota, leader_quota, core_root, pol_epoch_nonce, pol_t0, pol_t1, pol_ledger_aged are retrieved from the blockchain.
  2. K_part_one and K_part_two are first and second part of the signature key (KeyPair) generated by the above generate_key_collection.
class ProofOfQuotaPublic:
    session: int  # Session number (uint64)
    core_quota: int  # Allowed messages per session for core nodes
    leader_quota: int  # Allowed messages per session for potential leaders
    core_root: zkhash  # Merkle root of zk_id of the core nodes
    K_part_one: int  # First part of the signature public key (16 bytes)
    K_part_two: int  # Second part of the signature public key (16 bytes)
    pol_epoch_nonce: int  # PoL Epoch nonce
    pol_t0: int  # PoL constant t0
    pol_t1: int  # PoL constant t1
    pol_ledger_aged: zkhash  # Merkle root of the PoL eligible notes
    # Outputs:
    key_nullifier: zkhash  # derived from session, private index and private sk

The ProofOfQuotaWitness (Witness) structure must be filled as follows:

  1. If the message contains cover traffic then:
    1. The core quota is used and the selector=0 value must be specified.
    2. The index counts the number of cover messages and must be below core_quota.
    3. The core_sk, core_path, core_path_selector are filled by the node to prove that the node is the core node.
    4. The rest of the ProofOfQuotaWitness, is filled with arbitrary data.
  2. If the message contains data then:
    1. The leader quota is used and the selector=1 value must be specified.
    2. The index counts the number of data messages and must be below leader_quota.
    3. The core_sk, core_path, core_path_selector are filled with arbitrary data.
    4. The rest is filled with Proof of Leadership (PoL) related data.
class ProofOfQuotaWitness:
    index: int  # This is the index of the generated key. Limiting this index
                # limits the maximum number of key generated. (20 bits)
    selector: int  # Indicates if it's a leader (=1) or a core node (=0)
    # This part is filled randomly by potential leaders
    core_sk: zkhash  # sk corresponding to the zk_id of the core node
    core_path: list[zkhash]  # Merkle path proving zk_id membership (len = 20)
    core_path_selectors: list[bool]  # Indicates how to read the core_path (if
                                      # Merkle nodes are left or right in the path)
    # This part is filled randomly by core nodes
    pol_sl: int  # PoL slot
    pol_sk_starting_slot: int  # PoL starting slot of the slot secrets
    pol_note_value: int  # PoL note value
    pol_note_tx_hash: zkhash  # PoL note transaction
    pol_note_output_number: int  # PoL note transaction output number
    pol_noteid_path: list[zkhash]  # PoL Merkle path proving noteID membership
                                    # in ledger aged (len = 32)
    pol_noteid_path_selectors: list[bool]  # Indicates how to read the note_path
                                            # (if Merkle nodes are left or right in the path)
    pol_slot_secret: int  # PoL slot secret corresponding to sl
    pol_slot_secret_path: list[zkhash]  # PoL slot secret Merkle path to
                                         # sk_secrets_root (len = 25)

The ProofOfQuotaPublic and ProofOfQuotaWitness are passed to the zero-knowledge circuits that generate the proof \pi^{K^{n}{l}}{Q} which derives the key_nullifier (\nu_s) from session, private index, private secret key during proof generation.

Step 2: Select Nodes

Select h nodes from the set of nodes \mathcal{N} in a random and verifiable manner. For i \in \{1,…,h\}, select l_i = \text{CSPRBG}(H_{\mathbf N}(\rho))_{8} \mod N, where \rho is a selection randomness (using little-endian encoding), a shared secret derived during Proof of Quota generation, the output of the \text{CSPRBG}()_8 is returns 8 bytes (little-endian).

def select_nodes(key_collection: List[KeyPair], nodes: List[Node]) -> List[Node]:
    selected_nodes = []
    for keypair in key_collection:
        rand = pseudo_random(
            b"BlendNode",
            selection_randomness,
            8
        )
        index = modular_bytes(rand, NUM_NODES)
        selected_nodes.append(nodes[index])
    return selected_nodes

def modular_bytes(data: bytes, modulus: int) -> int:
    # Convert data into an unsigned big integer using little-endian.
    return int.from_bytes(data, byteorder='little') % modulus

Step 3: Generate Proofs of Selection

Generate proofs of selection \pi^{K^{n}i,l_i}{S} for i \in \{1,…,h\}, which proves that the public key K^{n}_i correctly maps to the index l_i from the set of nodes \mathcal{N}.

Step 4: Retrieve Public Keys

For i \in \{1,…,h\}, retrieve public keys \mathcal P = \{ {P^{l_1},..., P^{l_h}} \} for all h selected nodes using the SDP protocol (defined as provider_id in Identifiers).

def blend_node_signing_public_keys(selected_nodes: List[Node]) -> List[Ed25519PublicKey]:
    return [node.signing_public_key for node in selected_nodes]

Step 5: Calculate Shared Keys

For i \in \{1,…,h\}, calculate shared keys from a set of public keys of selected nodes \kappa^{n,i}{i} = k^{n}{i} \cdot P^{l_i}.

def derive_shared_keys(key_collection: List[KeyPair],
                       blend_node_signing_public_keys: List[Ed25519PublicKey]) -> List[SharedKey]:
    assert len(key_collection) == len(blend_node_signing_public_keys)
    assert len(key_collection) <= ENCAPSULATION_COUNT

    shared_keys = []
    for (keypair, blend_node_signing_public_key) in zip(key_collection,
                                                          blend_node_signing_public_keys):
        encryption_private_key = signing_private_key.derive_x25519()
        blend_node_encryption_public_key = blend_node_signing_public_key
        shared_key = diffie_hellman(encryption_private_key,
                                     blend_node_encryption_public_key)
        shared_keys.append(shared_key)
    return shared_keys

Node Selection Mechanism:

In step 2 of the algorithm above, the sender constructs a blending path from nodes sampled at random but in a verifiable manner. The nodes are selected deterministically (and randomly) by the key value. The key to node mapping is proven in step 3.

The node selection proof \pi^{K^n_i,l_i}_{S} is constructed in such a way that it proves only the fact that the key K^n_i used for the encryption maps correctly to the node index l_i from the stable set of nodes \mathcal{N}. This proof should be considered a private proof intended only for the recipient blend node.

This mechanism intends to limit the possibility of "double spending" the emission token. This restricts the sender's ability to use the same emission token twice, first for constructing and emitting a message and then for claiming a reward for it.

For more information about proof of selection please refer to the Proof of Selection Specification.

Message Initialization

The second step is to create an empty message \mathbf M and fill the private header with random values.

Step 1: Create Empty Message

Create an empty message \mathbf M (filled with zeros).

Step 2: Randomize Private Header

Randomize the private header: For \mathbf b_i \in \mathbf h = (\mathbf b_{1},...,\mathbf b_{\beta_{max}}), set \mathbf b_{i} = \text {CSPRBG}( \rho_{i})_{|\mathbf b|}, where \rho_i is some random value.

def randomize_private_header() -> PrivateHeader:
    blending_headers = []
    for _ in range(ENCAPSULATION_COUNT):
        blending_header = pseudo_random(b"BlendRandom", entropy(),
                                         BlendingHeader.SIZE)
        blending_headers.append(blending_header)
    return blending_headers

Step 3: Fill Last Blend Headers

Fill the last h blend headers with reconstructable payloads: For i = \{ 1+\beta_{max}-h,...,\beta_{max}), do the following:

  1. t=\beta_{max} - i + 1
  2. r_{t,1} = \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,t}t|1)){|K|}
  3. r_{t,2} = \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,t}t|2)){|\pi^{K}_{Q}|}
  4. r_{t,3}= \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,t}t|3)){|\sigma_{K}(\mathbf P)|}
  5. r_{t,4}= \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,t}t|4)){|\pi^{K,k}_{S}|}
  6. \mathbf{b}i = \{ r{t,1}, r_{t,2}, r_{t,3}, r_{t,4} \}.
def fill_last_blending_headers(private_header: PrivateHeader,
                                shared_keys: List[SharedKeys]) -> PrivateHeader:
    assert len(private_header) == ENCAPSULATION_COUNT
    assert len(shared_keys) <= ENCAPSULATION_COUNT

    pseudo_random_blending_headers = []
    for shared_key in shared_keys:
        r1 = pseudo_random(b"BlendInitialization", shared_key + b"\\x01", KEY_SIZE)
        r2 = pseudo_random(b"BlendInitialization", shared_key + b"\\x02",
                           PROOF_OF_QUOTA_SIZE)
        r3 = pseudo_random(b"BlendInitialization", shared_key + b"\\x03",
                           SIGNATURE_SIZE)
        r4 = pseudo_random(b"BlendInitialization", shared_key + b"\\x04",
                           PROOF_OF_SELECTION_SIZE)
        pseudo_random_blending_headers.append(r1 + r2 + r3 + r4)

    # Replace the last `len(shared_keys)` blending headers.
    private_header[-num_layers:] = pseudo_random_blending_headers
    return private_header

Step 4: Encrypt Last Blend Headers

Encrypt the last h blend headers in a reconstructable manner: For i=\{ 1,...,h \}, for j=\{1, ..., i \}, encrypt blend header:

\mathbf{b}{\beta{max}-i+1}=E_{H_{\mathbf b}(\kappa^{n,l_j}{j})}(\mathbf b{\beta_{max}-i+1})
def encrypt_last_blending_headers(private_header: PrivateHeader,
                                   shared_keys: List[SharedKeys]) -> PrivateHeader:
    assert len(private_header) == ENCAPSULATION_COUNT
    assert len(shared_keys) <= ENCAPSULATION_COUNT

    for i, _ in enumerate(shared_keys):
        index = len(private_header) - i - 1
        for shared_key in shared_keys[:i + 1]:
            private_header[index] = encrypt(private_header[index], shared_key)

    return private_header

This prevents leakage of the encryption sequence when a message is encapsulated less than \beta_{max} times, and enables us to encode the header in a way that it can be reconstructed during the decapsulation.

Message Encapsulation

The final part of the algorithm is the true encapsulation of the payload. That is, given the payload \mathbf P_0 and number of encapsulations h \le \beta_{max} we do the following.

For i \in \{ 1,…,h \} do the following:

  1. If i=1 then generate a new ephemeral key pair: (K^n_0, k^n_0) \notin \mathbf K^n_h.
  2. Calculate the signature of the concatenation of the current header and payload: \sigma_{K^{n}{i-1}}(\mathbf h{i-1}| \mathbf P_{i-1}).
  3. Using the shared key \kappa^{n,l_i}i, encrypt the payload: \mathbf{P}i = E{H\mathbf{P}( \kappa^{n,l_i}i)}(\mathbf P{i-1})=\mathbf{P}{i-1} \oplus \text {CSPRBG}(H\mathbf{P}(\kappa^{n,l_i}_i)).
  4. Shift blending headers by one downward: \mathbf b_z \rightarrow \mathbf b_{z+1} for z \in \{ 1,…,\beta_{max} \}. The first blending header is now empty, and the last blending header is truncated.
  5. Fill the blending header \mathbf b_1, where 1 refers to the top position:
    1. If i=1 then:
      1. Fill the proof of quota with random data: \pi^{K^{n}0}{Q}= \text {CSPRBG}(H_\mathbf{I}(k^{n}0)){|\pi^{K}_{Q}|}
      2. Set the last flag to 1: \Omega=1
    2. Else set the last flag to 0: \Omega = 0
    3. \mathbf{b}1 = \{ K^n{i-1}, \pi^{K^{n}{i-1}}{Q}, \sigma_{K^{n}{i-1}}(\mathbf h{i-1}|\mathbf P_{i-1}), \pi^{K^{n}i,l_i}{S}, \Omega \}.
  6. Using shared key \kappa^{n,l_i}i, encrypt the private header \mathbf{h}{E_{i}} = E_{H_{\mathbf b}(\kappa^{n,l_i}_i)}(\mathbf{h}_i):

For each \mathbf b_j \in \mathbf h_i = (\mathbf b_1,...,\mathbf b_{m_{max}}) using a shared key \kappa^{n,l_i}i, encrypt the blending header: \mathbf{b}j = E{H\mathbf{b}(\kappa^{n,l_i}_i)}(\mathbf{b}_j)=\mathbf{b}j \oplus \text {CSPRBG}(H\mathbf{b}(\kappa^{n,l_i}_i)).

Fill in the public header: \mathbf H=\{ K^{n}h, \pi^{K^{n}h}{Q}, \sigma{K^{n}_h}(\mathbf P_h) \}.

The message is encapsulated.

def encapsulate(
    private_header: PrivateHeader,
    payload: Payload,
    shared_keys: List[SharedKeys],
    key_collection: List[KeyPair],
    list_of_pos: List[ProofOfSelection]
) -> bytes:
    # Step 1 ~ 6: Encapsulate private header and payload
    prev_keypair = KeyPair.random()
    is_first_selected = True
    for shared_key, keypair, proof_of_selection) in zip(shared_keys, key_collection,
                                                          list_of_pos):
        private_header, payload = encapsulate_private_part(
            private_header,
            payload.bytes(),
            shared_key,
            prev_keypair.signing_private_key,
            prev_keypair.proof_of_quota,
            proof_of_selection,
            # The first encapsulation is for the last decapsulation.
            is_last=is_first_selected,
        )
        prev_keypair = keypair
        is_first = False

    # Fill in the public header
    public_header = PublicHeader(
        prev_keypair.signing_public_key,
        prev_keypair.proof_of_quota,
        signature=sign(private_part, prev_keypair.signing_private_key),
        version=1,
    )

    return public_header.bytes() + b"".join(private_headers) + payload

def encapsulate_private_part(
    private_header: PrivateHeader,
    payload: EncryptedPayload,
    shared_key: SharedKey,
    signing_private_key: Ed25519PrivateKey,
    proof_of_quota: ProofOfQuota,
    proof_of_selection: ProofOfSelection,
    is_last: bool
) -> bytes:
    # Step 2: Calculate a signature on `private_header + payload`.
    signature = sign(
        signing_body(private_header, payload),
        signing_private_key
    )

    # Step 3: Encrypt the payload
    payload = encrypt(payload, shared_key)

    # Step 4: Shift blending headers by one rightward.
    private_header.pop()  # Remove the last blending header

    # Step 5: Add the new blending header to the front.
    blending_header = BlendingHeader(
        signing_private_key.public(),
        proof_of_quota,
        signature,
        proof_of_selection,
        is_last
    )
    private_header.insert(0, blending_header.bytes())

    # Step 6: Encrypt the private header
    for i, _ in enumerate(private_header):
        private_header[i] = encrypt(private_header[i], shared_key)

    return private_header, payload

def signing_body(private_header: PrivateHeader, payload: EncryptedPayload) -> bytes:
    return b"".join(private_headers) + payload

Message Decapsulation

If a message \mathbf M is received by the node and its public header is correct - that is, it was verified according to the relay logic defined here: Relaying - then the node l executes the following logic:

  1. Calculate the shared secret. Using the key K^{n}_l \in \mathbf H from the public header of the message \mathbf M and the private key p^l of the node l calculate: \kappa^{n,l}_l = K^{n}_l \cdot p^l.
  2. Decrypt the private header using the shared key \kappa^{n,l}_l. For each \mathbf b_j \in \mathbf h = (\mathbf b_1,...,\mathbf b_{\beta_{max}}) using a shared key \kappa^{n,l}l decrypt the blending header: \mathbf{b}j = D{H\mathbf{b}(\kappa^{n,l}_l)}(\mathbf{b}_j)=\mathbf{b}j \oplus \text {CSPRBG}(H\mathbf{b}(\kappa^{n,l}_l)).
  3. Verify the header:
    1. If the proof \pi^{K^{n}l,l}{S}\in \mathbf b_1 is not correct, discard the message. That is, if the node index l does not correspond to the K^{n}_l\in \mathbf H, then the message must be rejected.
    2. If the key K^{n}_l \in \mathbf b_1 was already seen, discard the message.
    3. If the proof \pi^{K^{n}l,l}{Q} \in \mathbf b_1 is incorrect, discard the message.
  4. Using the blending header \mathbf b_1, set the public header: \mathbf H_l = \{K^{n}l \in \mathbf b_1,\pi^{K^{n}l,l}{Q} \in \mathbf b_1 ,\sigma{K^{n}_l}(\mathbf {h|P}) \in \mathbf b_1\}.
  5. Decrypt the payload, using the shared key \kappa^{n,l}l: \mathbf{P}l =D{H\mathbf{P}(\kappa^{n,l}l)}=\mathbf{P} \oplus \text {CSPRBG}(H{\mathbf P}(\kappa^{n,l}_l)).
  6. Reconstruct the blend header:
    1. r_{l,1} = \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,l}l|1)){|K|}
    2. r_{l,2} = \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,l}l|2)){|\pi^{K}_{Q}|}
    3. r_{l,3}= \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,l}l|3)){|\sigma_{K}(\mathbf P)|}
    4. r_{l,4}= \text {CSPRBG}(H_\mathbf{I}(\kappa^{n,l}l|4)){|\pi^{K,k}_{S}|}
    5. b = \{ r_{l,1}, r_{l,2}, r_{l,3}, r_{l,4} \}.
  7. Encrypt the blending header: \hat b = E_{H_\mathbf{b}(\kappa^{n,{l}}_{l})}(b).
  8. Shift blending headers by one upward: \mathbf b_z \rightarrow \mathbf b_{z-1} for z \in \{ 1,…,\beta_{max} \}. The first blending header is truncated, and the last blending header is empty.
  9. Reconstruct the private header: \mathbf h_{E_{l}} = \{ \mathbf{b}{\beta{max}} = \hat b, \}.
  10. If the signature from the public header does not match the signature of the reconstructed header and the decrypted payload, discard the message: \text{verify\sig}(\sigma{K^n_l}(\mathbf{h}_{E_l}| \mathbf{P}_l), \mathbf{h}| \mathbf{P},{K^n_l}).
  11. The message is decapsulated.
  12. Follow the message processing logic: Processing.
def decapsulate(
    message: bytes,
    signing_private_key: Ed25519PrivateKey
) -> bytes:
    # Step 1: Derive the shared key.
    encryption_private_key = signing_private_key.derive_x25519()
    public_header = PublicHeader.from_bytes(
        message[Header.SIZE : Header.SIZE + PublicHeader.SIZE]
    )
    shared_key = diffie_hellman(
        encryption_private_key,
        public_header.signing_public_key.derive_x25519()
    )

    # Step 2: Decrypt the private header
    private_header = message[
        Header.SIZE + PublicHeader.SIZE:
        Header.SIZE + PublicHeader.SIZE + (BlendingHeader.SIZE * ENCAPSULATION_COUNT)
    ]
    for i, _ in enumerate(private_header):
        private_header[i] = decrypt(private_header[i], shared_key)

    # Step 3: Verify the first blending header
    first_blending_header = BlendingHeader.from_bytes(private_header[0])
    first_blending_header.validate()

    # Step 4: Construct the new public header
    public_header = PublicHeader(
        first_blending_header.signing_public_key,
        first_blending_header.proof_of_quota,
        first_blending_header.signature,
        version= 1,
    )

    # Step 5: Decrypt the payload
    payload_offset = (
        Header.SIZE + PublicHeader.SIZE + (BlendingHeader.SIZE * ENCAPSULATION_COUNT)
    )
    payload = message[payload_offset:]
    payload = decrypt(payload, shared_key)

    # Step 6: Reconstruct the new blending header
    r1 = pseudo_random(b"BlendInitialization", shared_key + b"\\x01", KEY_SIZE)
    r2 = pseudo_random(b"BlendInitialization", shared_key + b"\\x02",
                       PROOF_OF_QUOTA_SIZE)
    r3 = pseudo_random(b"BlendInitialization", shared_key + b"\\x03", SIGNATURE_SIZE)
    r4 = pseudo_random(b"BlendInitialization", shared_key + b"\\x04",
                       PROOF_OF_SELECTION_SIZE)

    # Step 7: Encrypt the new blending header
    encrypted_new_blending_header = encrypt(r1 + r2 + r3 + r4, shared_key)

    # Step 8: Shift blending headers by one leftward.
    private_header.pop(0)  # Remove the first blending header.

    # Step 9: Add the new blending header to the end.
    private_header.append(encrypted_new_blending_header)

    # Step 10: Verify the signature
    verify_signature(
        first_blending_header.signature,
        signing_body(private_header, payload)
        first_blending_header.signing_public_key,
    )

    header = message[0:Header.SIZE]
    return header + public_header.bytes() + b"".join(private_header) + payload

Implementation Considerations

Security Considerations

Message Privacy:

  • The multi-layered encryption ensures that intermediate nodes cannot determine the message origin or final destination
  • Each encapsulation layer uses unique ephemeral keys to prevent correlation attacks
  • The reconstructable header mechanism prevents leakage of the encryption sequence

Proof Verification:

  • All Proof of Quota (PoQ) proofs must be verified to ensure message authenticity
  • Proof of Selection (PoS) proofs prevent double-spending of emission tokens
  • Key nullifiers must be checked to prevent key reuse attacks

Key Management:

  • Ephemeral keys should be generated using cryptographically secure random sources
  • Private keys must never be logged or persisted beyond their required lifetime
  • Shared keys derived via Diffie-Hellman must use secure elliptic curve operations

Performance Optimization

Cryptographic Operations:

  • BLAKE2b hash operations are efficient but should still be batched when possible
  • XOR-based encryption/decryption is computationally inexpensive
  • Signature generation and verification are the most expensive operations and should be minimized

Memory Management:

  • Private headers with \beta_{max} blending headers can consume significant memory
  • Implementations should reuse buffers for encryption/decryption operations
  • Payload sizes (34 KiB) should be considered when allocating message buffers

Implementation Notes

Byte Order:

  • All multi-byte integers use little-endian encoding unless otherwise specified
  • The modular_bytes function converts bytes to integers using little-endian format
  • Implementations must maintain consistent endianness throughout

Error Handling:

  • Invalid proofs should result in immediate message rejection
  • Signature verification failures must discard the message
  • Implementations should not leak timing information about verification failures

Integration Points:

  • Service Declaration Protocol (SDP) integration is required for node public key retrieval
  • Proof of Leadership (PoL) integration is needed for leader quota verification
  • The Formatting specification provides additional context for message structure

Appendix

Example: Complete Encapsulation and Decapsulation

The following example demonstrates the above mechanism with \beta_{max}=4,h=3. The protocol version in the header is omitted for simplicity.

Initialization

Create Empty Message (Example)

Create an empty message: \mathbf{M} = (\mathbf{H}=0,\mathbf{h}=0,\mathbf{P}=0)

Randomize Private Header (Example)

Randomize the private header: \mathbf h_0 = \{

\mathbf b_1 = \text {CSPRBG}( \rho_{1})_{|\mathbf b|},

\mathbf b_2 = \text {CSPRBG}( \rho_{2})_{|\mathbf b|},

\mathbf b_3 = \text {CSPRBG}( \rho_{3})_{|\mathbf b|},

\mathbf b_4 = \text {CSPRBG}( \rho_{4})_{|\mathbf b|},

\}.

Fill Last h Blend Headers (Example)

Fill the last h blend headers with reconstructable payloads: \mathbf h_0 = \{

\mathbf b_1 = \text {CSPRBG}( \rho_{1})_{|\mathbf b|},

\mathbf b_2 = \{ r_{l_3,1}, r_{l_3,2} ,r_{l_3,3}, r_{l_3,4} \},

\mathbf b_3 = \{ r_{l_2,1}, r_{l_2,2} ,r_{l_2,3}, r_{l_2,4} \},

\mathbf b_4 = \{ r_{l_1,1}, r_{l_1,2} ,r_{l_1,3}, r_{l_1,4} \},

\}.

Encrypt Last h Blend Headers (Example)

Encrypt the last h blend headers in a reconstructable manner: \mathbf h_{E_0} = \{

\mathbf b_1 = \text {CSPRBG}( \rho_{1})_{|\mathbf b|},

\mathbf b_2 = E_{H_\mathbf{b}(\kappa^{n,{l_3}}{3})}E{H_\mathbf{b}(\kappa^{n,{l_2}}{2})}E{H_\mathbf{b}(\kappa^{n,{l_1}}{1})}(\{ r{l_3,1}, r_{l_3,2} ,r_{l_3,3}, r_{l_3,4} \}),

\mathbf b_3 = E_{H_\mathbf{b}(\kappa^{n,{l_2}}{2})}E{H_\mathbf{b}(\kappa^{n,{l_1}}{1})}(\{ r{l_2,1}, r_{l_2,2} ,r_{l_2,3}, r_{l_2,4} \}),

\mathbf b_4 = E_{H_\mathbf{b}(\kappa^{n,{l_1}}{1})}(\{ r{l_1,1}, r_{l_1,2} ,r_{l_1,3}, r_{l_1,4} \}),

\}.

Encapsulation

Iteration i=1:

  1. Generate a new ephemeral key pair: (K^n_0, k^n_0) \notin \mathbf K^n.
  2. Calculate the signature of the header and the payload: \sigma_{K^{n}0}(\mathbf{h}{E_0}| \mathbf{P}_0).
  3. Using shared key \kappa^{n,l_1}{1} encrypt the payload: \mathbf P_1 = E{H_\mathbf{P}(\kappa^{n,l_1}_{1})}(\mathbf P_0).
  4. Shift blending headers by one down: \mathbf h_1 = \{ \mathbf b_1 = \empty, ... \}.
  5. Fill the first blending header with signature, proof, and flag.
  6. Using shared key \kappa^{n,l_1}{1} encrypt the private header: \mathbf{h}{E_{1}} = E_{H_{\mathbf b}(\kappa^{n,l_1}_1)}(\mathbf{h}_1).

Iteration i=2:

Continue with similar steps using \kappa^{n,l_2}_{2}.

Iteration i=3:

Continue with similar steps using \kappa^{n,l_3}_{3}.

The above calculations give us the final message \mathbf {M = (H,h,P)} where:

\mathbf H = (K^{n}_3,~ \pi^{K^{n}_3}Q,~ \sigma{K^{n}3}(\mathbf{h}{E_3}|\mathbf{P}_3)),

\mathbf{h} = \mathbf{h}_{E_3} with fully encrypted blending headers,

\mathbf{P} = \mathbf P_3= E_{H_{\mathbf P_0}(\kappa^{n,l_3}3)}E{H_{\mathbf P}(\kappa^{n,l_2}2)}E{H_{\mathbf P}(\kappa^{n,l_1}_1)}(\mathbf{P}_0).

Decapsulation

This section demonstrates decapsulation of the above message. The node doing the processing is the rightful recipient of the message and the public header is verified to be correct.

Node l=l_3:

  1. Calculate shared secret: \kappa^{n,l_3}{3}=K^n{3} \cdot p^{l_3}
  2. Decrypt the header: \mathbf h_{l_3} = D_{H_\mathbf{b}(\kappa^{n,{l_3}}_{3})}(\mathbf{h})
  3. Verify the header (proof of selection, key novelty, proof of quota)
  4. Reconstruct the public header
  5. Decrypt the payload: \mathbf{P}{l_3} = D{H_\mathbf{b}(\kappa^{n,{l_3}}_{3})}(\mathbf P)
  6. Reconstruct the blend header with pseudo-random values
  7. Encrypt the reconstructed blend header
  8. Shift blending headers by one upward
  9. Reconstruct the private header
  10. Verify the signature
  11. Message is decapsulated
  12. Follow the processing logic

Node l=l_2 and l=l_1:

Similar decapsulation steps are performed by subsequent nodes in the blending path.

References

Normative

Informative

Copyright and related rights waived via CC0.