Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Working with AppData

Important

Currently this functionality is behind the extensions-draft-08 feature.

So far, applications could store group state that all members should agree on in custom extensions. The MLS Extensions draft specifies a new mechanism to encode application data in the group state via the AppDataDictionary extension. When using custom extensions for this purpose, every update message contains the full new state, for example in a GroupContextExtensionProposal. The AppDataUpdate proposal allows sending only a diff, which the application interprets to produce the new state in the AppDataDictionary.

This is very flexible and allows implementing a wide range of diff-style approaches. However, it puts more burden on the application, since it needs to validate and process the updates itself to produce the new state.

Note

The extensions draft specifies ComponentIDs to be 32 bit, but after publishing this was reduced to 16 bit. We are using 16 bit ComponentIDs. More context in issue mls-extensions#69

To demonstrate the API, we need a custom component that we keep in the group.

Setting up a custom Component

Each application component needs:

  • A unique ComponentId (we’ll use 0xf042, which is in the private range 0x8000..0xffff)
  • A data format for the stored state
  • A data format for updates (the “diff”)
  • Application logic to process updates and compute new state

For this example, we’ll build a simple counter where:

  • The stored state is the counter value as a big-endian u32
  • Updates are a single byte: 0x01 = increment, 0x02 = decrement
  • Incrementing a counter that hasn’t been set yet initializes it to 1
  • Decrementing below zero is invalid and will cause the commit to be rejected
/// Our counter component ID (in the private range 0x8000..0xffff)
const COUNTER_COMPONENT_ID: u16 = 0xf042;

/// The operations that can be performed on the counter
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CounterOperation {
    Increment = 0x01,
    Decrement = 0x02,
}

impl CounterOperation {
    fn from_byte(byte: u8) -> Option<Self> {
        match byte {
            0x01 => Some(CounterOperation::Increment),
            0x02 => Some(CounterOperation::Decrement),
            _ => None,
        }
    }

    fn to_bytes(self) -> Vec<u8> {
        vec![self as u8]
    }
}

/// Error type for counter operations
#[derive(Debug, Clone, PartialEq, Eq)]
enum CounterError {
    /// Attempted to decrement below zero
    Underflow,
    /// Invalid operation byte
    InvalidOperation,
}

/// Process a list of counter updates, returning the new counter value.
///
/// - `current_value`: The current counter value (None if not yet set)
/// - `updates`: Iterator of update payloads (each is a single byte)
///
/// Returns the new counter value, or an error if the updates are invalid.
fn process_counter_updates<'a>(
    current_value: Option<&[u8]>,
    updates: impl Iterator<Item = &'a [u8]>,
) -> Result<Vec<u8>, CounterError> {
    // Parse current value as big-endian u32, defaulting to 0
    let mut counter: u32 = current_value
        .map(|bytes| {
            let arr: [u8; 4] = bytes.try_into().unwrap_or([0; 4]);
            u32::from_be_bytes(arr)
        })
        .unwrap_or(0);

    // Apply each update
    for update in updates {
        let op_byte = update.first().ok_or(CounterError::InvalidOperation)?;
        let op = CounterOperation::from_byte(*op_byte).ok_or(CounterError::InvalidOperation)?;

        match op {
            CounterOperation::Increment => {
                counter = counter.saturating_add(1);
            }
            CounterOperation::Decrement => {
                counter = counter.checked_sub(1).ok_or(CounterError::Underflow)?;
            }
        }
    }

    Ok(counter.to_be_bytes().to_vec())
}

Next, we crate the group.

Group Setup

Both the group and its members must advertise support for AppDataUpdate proposals and the AppDataDictionary extension. This is done through capabilities and required capabilities.

/// Set up a group with AppDataUpdate support.
///
/// This creates Alice and Bob with the required capabilities and creates
/// a group where AppDataUpdate proposals are supported.
fn setup_group_with_app_data_support<'a, Provider: OpenMlsProvider>(
    alice_party: &'a CorePartyState<Provider>,
    bob_party: &'a CorePartyState<Provider>,
    ciphersuite: Ciphersuite,
) -> GroupState<'a, Provider> {
    // Define capabilities that include AppDataDictionary extension
    // and AppDataUpdate proposal support
    let capabilities = Capabilities::new(
        None, // protocol versions (default)
        None, // ciphersuites (default)
        Some(&[ExtensionType::AppDataDictionary]),
        Some(&[ProposalType::AppDataUpdate]),
        None, // credentials (default)
    );

    // The group context must require these capabilities so that
    // all members are guaranteed to support them
    let required_capabilities_extension =
        Extension::RequiredCapabilities(RequiredCapabilitiesExtension::new(
            &[ExtensionType::AppDataDictionary], // required extensions
            &[ProposalType::AppDataUpdate],      // required proposals
            &[],                                 // required credentials
        ));

    // Create pre-group states with the capabilities
    let alice_pre_group = alice_party
        .pre_group_builder(ciphersuite)
        .with_leaf_node_capabilities(capabilities.clone())
        .build();

    let bob_pre_group = bob_party
        .pre_group_builder(ciphersuite)
        .with_leaf_node_capabilities(capabilities.clone())
        .build();

    // Configure the group with required capabilities
    let create_config = MlsGroupCreateConfig::builder()
        .ciphersuite(ciphersuite)
        .capabilities(capabilities)
        .use_ratchet_tree_extension(true)
        .with_group_context_extensions(
            Extensions::single(required_capabilities_extension).expect("valid extensions"),
        )
        .build();

    let join_config = create_config.join_config().clone();

    // Alice creates the group
    let mut group_state = GroupState::new_from_party(
        GroupId::from_slice(b"CounterGroup"),
        alice_pre_group,
        create_config,
    )
    .expect("failed to create group");

    // Alice adds Bob
    group_state
        .add_member(AddMemberConfig {
            adder: "alice",
            addees: vec![bob_pre_group],
            join_config,
            tree: None,
        })
        .expect("failed to add Bob");

    group_state
}

Sending and receiving proposals

This part doesn’t really change.

Alice sends a proposal to increment the counter:

./tests/book_code_app_data.rs:send_proposal}}

Bob receives and stores the proposal:

    // Bob receives and stores the proposal
    let processed_proposal = bob
        .group
        .process_message(
            &bob_party.provider,
            proposal_message
                .into_protocol_message()
                .expect("failed to convert Proposal MlsMessageOut to ProtocolMessage"),
        )
        .expect("failed to process proposal");

    // Verify it's a proposal and store it
    match processed_proposal.into_content() {
        ProcessedMessageContent::ProposalMessage(proposal) => {
            bob.group
                .store_pending_proposal(bob_party.provider.storage(), *proposal)
                .expect("failed to store proposal");
        }
        _ => panic!("expected a proposal message"),
    }

Sending Commits

Now, Alice creates a commit that includes:

  • The previously sent proposal (by reference, from her proposal store)
  • One additional increment proposal (inline)

An important change is that Alice must compute the resulting state herself before building the commit:

    // Alice creates a commit that includes:
    // - The previously sent proposal (by reference, from her proposal store)
    // - Two additional increment proposals (inline)
    let mut commit_stage = alice
        .group
        .commit_builder()
        .add_proposals(vec![
            // Two more increments as inline proposals
            Proposal::AppDataUpdate(Box::new(AppDataUpdateProposal::update(
                COUNTER_COMPONENT_ID,
                CounterOperation::Increment.to_bytes(),
            ))),
        ])
        .load_psks(alice_party.provider.storage())
        .expect("failed to load PSKs");

    // Alice must compute the resulting state before building the commit.
    // She iterates over all AppDataUpdate proposals (both from the proposal
    // store and inline proposals).
    let mut alice_updater = commit_stage.app_data_dictionary_updater();

    process_app_data_proposals(&mut alice_updater, commit_stage.app_data_update_proposals())
        .expect("failed to process proposals");

    // Provide the computed changes to the commit builder
    commit_stage.with_app_data_dictionary_updates(alice_updater.changes());

    // Build and stage the commit
    let commit_bundle = commit_stage
        .build(
            alice_party.provider.rand(),
            alice_party.provider.crypto(),
            &alice.party.signer,
            |_proposal| true, // accept all proposals
        )
        .expect("failed to build commit")
        .stage_commit(&alice_party.provider)
        .expect("failed to stage commit");

    let (commit_message, _welcome, _group_info) = commit_bundle.into_contents();

Receiving Commits

Bob receives the commit and must independently compute the same new state. He iterates over the proposals in the commit, resolving references from his proposal store:

    // Bob receives the commit and must independently compute the same new state.

    // First, unprotect (decrypt) the message
    let commit_in: MlsMessageIn = commit_message.into();
    let unverified_message = bob
        .group
        .unprotect_message(
            &bob_party.provider,
            commit_in
                .into_protocol_message()
                .expect("not a protocol message"),
        )
        .expect("failed to unprotect message");

    // Create an updater for Bob
    let mut bob_updater = bob.group.app_data_dictionary_updater();

    // Get the proposals from the commit
    let committed_proposals = unverified_message
        .committed_proposals()
        .expect("not a commit");

    // Process each proposal, resolving references from the proposal store
    let mut app_data_updates: Vec<AppDataUpdateProposal> = Vec::new();

    for proposal_or_ref in committed_proposals.iter() {
        // Validate and potentially resolve the reference
        let validated = proposal_or_ref
            .clone()
            .validate(
                bob_party.provider.crypto(),
                ciphersuite,
                ProtocolVersion::Mls10,
            )
            .expect("invalid proposal");

        // Resolve to the actual proposal
        let proposal: Box<Proposal> = match validated {
            ProposalOrRef::Proposal(proposal) => proposal,
            ProposalOrRef::Reference(reference) => {
                // Look up the proposal in the proposal store
                bob.group
                    .proposal_store()
                    .proposals()
                    .find(|p| p.proposal_reference_ref() == &*reference)
                    .map(|p| Box::new(p.proposal().clone()))
                    .expect("proposal not found in store")
            }
        };

        // Collect AppDataUpdate proposals for processing
        if let Proposal::AppDataUpdate(app_data_proposal) = *proposal {
            app_data_updates.push(*app_data_proposal);
        }
    }

    // Process the collected proposals
    process_app_data_proposals(&mut bob_updater, app_data_updates.iter())
        .expect("failed to process proposals");

    // Now process the message with the computed updates
    let processed_message = bob
        .group
        .process_unverified_message_with_app_data_updates(
            &bob_party.provider,
            unverified_message,
            bob_updater.changes(),
        )
        .expect("failed to process commit");

    // Extract and merge the staged commit
    let staged_commit = match processed_message.into_content() {
        ProcessedMessageContent::StagedCommitMessage(commit) => commit,
        _ => panic!("expected a staged commit"),
    };

    bob.group
        .merge_staged_commit(&bob_party.provider, *staged_commit)
        .expect("failed to merge commit");

After both parties merge, they should have identical state:

    // Both parties should now have identical state
    assert_eq!(
        alice.group.extensions().app_data_dictionary(),
        bob.group.extensions().app_data_dictionary(),
        "dictionaries should match"
    );

    // Verify the counter value is 3 (three increments)
    let alice_dict = alice
        .group
        .extensions()
        .app_data_dictionary()
        .expect("dictionary should exist");

    let counter_bytes = alice_dict
        .dictionary()
        .get(&COUNTER_COMPONENT_ID)
        .expect("counter should exist");

    let counter_value = u32::from_be_bytes(counter_bytes.try_into().expect("invalid length"));
    assert_eq!(counter_value, 2, "counter should be 2 after two increments");

Error Handling: Invalid Updates

If an update would result in invalid state (e.g., decrementing below zero), the application should reject the commit. Here’s what happens when Alice tries to decrement an unset counter:

    // Alice tries to decrement an unset counter, which should fail.
    let commit_stage = alice
        .group
        .commit_builder()
        .add_proposals(vec![Proposal::AppDataUpdate(Box::new(
            AppDataUpdateProposal::update(
                COUNTER_COMPONENT_ID,
                CounterOperation::Decrement.to_bytes(),
            ),
        ))])
        .load_psks(alice_party.provider.storage())
        .expect("failed to load PSKs");

    let mut alice_updater = commit_stage.app_data_dictionary_updater();

    let proposals: Vec<_> = commit_stage.app_data_update_proposals().collect();

    // This should fail because we can't decrement below zero
    let result = process_app_data_proposals(&mut alice_updater, proposals.into_iter());

    assert_eq!(
        result,
        Err(CounterError::Underflow),
        "decrementing unset counter should fail"
    );

    // Alice should not proceed with the commit since the state is invalid.
    // In a real application, you would handle this error appropriately,
    // perhaps by notifying the user or choosing different proposals.

The application detects the invalid state during proposal processing and can choose not to proceed with the commit (on the sender side) or reject the message (on the receiver side).


Verifying Consistency