Virtual Clients (draft)
OpenMLS has experimental support for virtual clients, following draft-ietf-mls-virtual-clients. A virtual client lets several real clients act jointly as a single member of an MLS group. The group sees one leaf. Behind that leaf, any of the cooperating clients can speak for it.
This feature is a moving draft. Everything described here lives behind the
virtual-clients-draftcargo feature and is not part of the stable API. Wire formats, storage layout, and method names can change between releases with no migration path. Do not assume interoperability with other implementations.
The idea
The clients that cooperate to act as one member are called emulator clients. The member they present to the outside world is the virtual client. The emulator clients coordinate through a separate MLS group of their own, the emulation group, while the virtual client appears as a single leaf in one or more higher-level groups.
The point of the construction is that the emulator clients never share raw private keys with each other. Instead they all derive the same key material from the emulation group’s epoch secrets, so any of them can produce a commit, an application message, or a KeyPackage on behalf of the virtual client, and any other can reproduce the matching private state. To the higher-level group, the result is indistinguishable from an ordinary single member.
Two mechanisms make this work without secret sharing:
- A Virtual Client Operation Secret Tree, derived from the emulation group’s
epoch through the Safe Exporter. It has the same shape as an MLS secret tree.
Each emulator client’s leaf carries one ratchet per operation type
(
KeyPackage,LeafNode,Application). Advancing a ratchet yields an operation secret, and from that secret each client derives the leaf encryption key, init key, signature key seed, and path secrets for a single operation. - A
DerivationInfocomponent embedded in every leaf node the virtual client produces. It carries, encrypted, the derivation epoch’sleaf_indexand thegenerationthat was used. A sibling emulator client reads it, derives the same operation secret from the operation tree at that position, and so reconstructs the private keys for the leaf without ever receiving them.
Each derivation epoch also produces a generation_id_secret (so the Delivery
Service can detect when two emulator clients pick the same ratchet generation)
and a reuse_guard_secret (so two emulator clients never reuse a key and nonce
pair while still looking random to outside observers).
Enabling the feature
Add the feature to your dependency on openmls:
[dependencies]
openmls = { version = "...", features = ["virtual-clients-draft"] }
Storage backends carry their own virtual-clients-draft feature, which the
openmls feature turns on for them. For tests that exercise the storage
provider trait methods directly, enable
virtual-clients-draft-test-dependencies on the openmls crate.
Leaf requirements
Every leaf that carries virtual-client material must declare support for the
AppDataDictionary extension and list the VC component id in its
AppComponents entry. Build the capabilities and leaf-node extensions
accordingly when you create or join an emulation group or a higher-level group:
use openmls::component::{ComponentId, ComponentType};
use openmls::components::vc_derivation_info::VC_COMPONENT_ID;
use openmls::extensions::{
AppDataDictionary, AppDataDictionaryExtension, Extension, ExtensionType, Extensions,
};
use openmls::prelude::Capabilities;
use tls_codec::Serialize as _;
let capabilities = Capabilities::builder()
.extensions(vec![ExtensionType::AppDataDictionary])
.build();
let supported_components: Vec<ComponentId> = vec![VC_COMPONENT_ID];
let app_components_body = supported_components.tls_serialize_detached().unwrap();
let mut dictionary = AppDataDictionary::new();
dictionary.insert(ComponentType::AppComponents.into(), app_components_body);
let leaf_extensions = Extensions::from_vec(vec![Extension::AppDataDictionary(
AppDataDictionaryExtension::new(dictionary),
)])
.unwrap();
Pass capabilities and leaf_extensions to MlsGroupCreateConfig::builder()
through .capabilities(...) and .with_leaf_node_extensions(...), and to the
KeyPackage::builder() through .leaf_node_capabilities(...) and
.leaf_node_extensions(...).
Derivation epochs
An emulation group is an ordinary MlsGroup. Each emulator client maintains its
own copy. What makes it an emulation group is a flag every emulator client has to
set when it creates or joins the group. The creator sets it on the create config:
let create_config = MlsGroupCreateConfig::builder()
.emulation_group(true)
// ... the leaf requirements above, ciphersuite, wire format policy
.build();
A member joining by Welcome sets it on the StagedWelcome:
let group = StagedWelcome::new_from_welcome(provider, &join_config, welcome, ratchet_tree)?
.emulation_group(true)
.into_group(provider)?;
A member resyncing by external commit sets it on the ExternalCommitBuilder,
via .emulation_group(true) before build_group.
The flag is local state. Nothing about it travels on the wire, and OpenMLS does not verify that the other members set it. Loading a group recovers the flag from storage, so it only has to be set once.
Virtual-client secrets are not derived from every epoch of the emulation group,
only from its derivation epochs. The initial epoch is one, and so is the output
epoch of any commit that changes membership or that carries a
new_derivation_epoch action. Other commits leave the newest derivation epoch in
place.
OpenMLS registers those epochs itself: at group creation, at a Welcome join, and
when such a commit is merged. Registration sources the root secret from the
epoch’s Safe Exporter under VC_COMPONENT_ID, builds the operation secret tree,
and persists the per-epoch state under a derived EpochId. Because the secret
comes from the Safe Exporter, all emulator clients derive the same EpochId
and the same operation tree for a given derivation epoch.
Every new virtual-client operation uses the newest derivation epoch of the
emulation group, as the draft requires. The sender-side entry points take the
emulation group and resolve that epoch themselves, so an application cannot keep
operating from an older, possibly compromised epoch. They fail with
VirtualClientsError::NoDerivationEpoch if the group has none registered. To
inspect the epoch, for example for logging, ask the emulation group:
let epoch_id = emulator_group
.newest_vc_derivation_epoch(provider.storage())?
.expect("an emulation group has a derivation epoch");
That epoch may be older than the emulation group’s current epoch. The EpochId
is the key under which all per-epoch state is stored, and it is the value
embedded in the leaves the virtual client produces.
Registration writes happen alongside the writes of the operation that triggered
them. Calls that create or advance an emulation group perform several storage
writes that must land together: apply group creation, the Welcome join
(StagedWelcome::into_group), and merge_staged_commit and
merge_pending_commit calls atomically, for example by wrapping each call in a
storage transaction. A call applied partially leaves storage inconsistent with
the group.
To start a fresh derivation epoch without changing membership, for instance to bound the damage of a compromise, mark a commit on the emulation group:
let bundle = emulator_group
.commit_builder()
.derivation_epoch(true)
.force_self_update(true)
.load_psks(provider.storage())?
.build(provider.rand(), provider.crypto(), &emulator_signer, |_| true)?
.stage_commit(provider)?;
The marker travels in the commit’s Safe AAD, so the emulation group’s GroupContext has to require Safe AAD framing. Like all actions, the marker applies relative to the commit’s input state: operations that reference a derivation epoch, including ones carried by this very commit, keep using the input state’s newest derivation epoch.
Retaining derivation epochs
An emulation group can be configured to keep a log of the derivation epochs it registered. This is such that emulator clients can still process delayed messages sent by sibling emulator clients.
How far back the log reaches can be configured on group join, defaulting to five epochs:
let create_config = MlsGroupCreateConfig::builder()
.emulation_group(true)
.set_vc_derivation_epoch_retention_policy(VcDerivationEpochRetentionPolicy::MaxEpochs(10))
// ... the leaf requirements above, ciphersuite, wire format policy
.build();
The log is stored as one entry per registered epoch, and every stored entry
keeps its epoch’s key material alive, as does every higher-level group binding
and every retained KeyPackage. Registering a new derivation epoch drops the log
entries beyond the window and then runs a storage sweep that deletes the key
material of every epoch not referenced anymore. The sweep runs at the end
of every library operation that drops a reference (registration, commit merge,
group deletion), so an epoch is released as soon as its last holder lets go,
and state that a crash orphaned is collected on the next sweep. A reference
dropped outside the library, such as a retained KeyPackage deleted through the
storage provider, is only noticed by the next sweep. See the limitations at the
end of this chapter.
MlsGroup::set_vc_derivation_epoch_retention_policy changes the window later
and applies it right away.
VcDerivationEpochRetentionPolicy::KeepAll turns the automatic pruning off. The
application then decides when epochs go, by how long ago they were superseded.
An epoch is superseded when the next one is registered, so the duration is a
grace period after the epoch stopped being current, however late in its life
that happened:
let outcome = emulator_group.delete_vc_derivation_epochs(
provider,
VcDerivationEpochDeletion::older_than_duration(Duration::from_secs(24 * 3600)),
)?;
The function returns the epochs whose state was deleted and the ones that were kept because something still referenced them. Either way they leave the log, so a later call does not reconsider them.
Committing in a higher-level group
To commit on behalf of the virtual client, set vc_emulation on the commit
builder, passing the emulation group’s id. The builder resolves that group’s newest
derivation epoch, allocates the next LeafNode operation generation, derives the
new leaf’s encryption key and the first path secret from it, and embeds the
encrypted DerivationInfo in the leaf:
let bundle = main_group
.commit_builder()
.vc_emulation(provider.crypto(), provider.storage(), emulator_group.group_id())?
.load_psks(provider.storage())?
.build(provider.rand(), provider.crypto(), &vc_signer, |_| true)?
.stage_commit(provider)?;
main_group.merge_pending_commit(provider)?;
let commit = bundle.into_commit();
Allocation advances the operation ratchet and persists it immediately, which is
a deliberate exception to the usual rule that nothing is written before a commit
is staged. The spec requires that a generation is never used for more than one
operation, so the generation is consumed at allocation time. If you discard the
builder, or the Delivery Service rejects the commit, the generation stays
burned. clear_pending_commit does not roll the ratchet back. A burned
generation is harmless, because sibling ratchets skip over it and retain the
skipped secret.
Processing a sibling’s commit
process_message detects the DerivationInfo component in a committer’s leaf.
If the committer’s leaf index is the receiver’s own leaf, the commit came from a
sibling emulator client. The receiver loads the derivation epoch state, decrypts
the derivation info, derives the same operation secret positionally from the
operation tree (advancing or skipping the sibling’s ratchet as needed),
reconstructs the path secrets, and processes the commit as if it had created it:
let processed = receiver_group
.process_message(provider, commit.into_protocol_message().unwrap())?;
A receiver that does not hold the referenced derivation epoch state, for example a real member of the higher-level group that is not an emulator client, processes the commit as an ordinary commit. The permissive handling is framed around the receiver, who may not be a sibling. The sender is always a sibling.
When a commit that carries a DerivationInfo is merged, the client stores a
binding from (GroupId, GroupEpoch) to the EpochId from that leaf. Joining a
higher-level group through a virtual client’s KeyPackage, by Welcome or by
external commit, binds the joined epoch to the KeyPackage’s derivation epoch the
same way. The binding is keyed by epoch, not just group id, because a delayed
application message from an earlier higher-level epoch must be processed with
the derivation epoch that was active then. Bindings follow the same retention
window as the message secrets store.
Confirming handshake messages
When the group frames handshake messages as PrivateMessage (a ciphertext outgoing wire format policy), proposals and commits draw their generations from the per-leaf handshake ratchet, the same way application messages draw from the application ratchet. Like an application send, a private handshake send retains its key and nonce until the Delivery Service accepts it, so two emulator clients that race for the same handshake generation can both recover.
A commit framed as PrivateMessage exposes its confirmation data on the bundle.
Take it out with take_confirmation before consuming the bundle, since the
consuming accessors (into_commit, into_contents, into_messages) drop the
confirmation data:
let mut bundle = main_group
.commit_builder()
.vc_emulation(provider.crypto(), provider.storage(), emulator_group.group_id())?
.load_psks(provider.storage())?
.build(provider.rand(), provider.crypto(), &vc_signer, |_| true)?
.stage_commit(provider)?;
if let Some(confirmation) = bundle.take_confirmation() {
// Attach confirmation.generation_id when fanning out the commit, so the
// Delivery Service can detect a generation collision with a sibling.
send_to_delivery_service(bundle.into_commit(), confirmation.generation_id.clone());
}
For standalone proposals, propose_unconfirmed mirrors propose but also
returns the confirmation data alongside the framed proposal:
let (proposal, proposal_ref, confirmation) = main_group.propose_unconfirmed(
provider,
&vc_signer,
Propose::Add(key_package),
ProposalOrRefType::Reference,
)?;
The confirmation from either path is Some when the message was framed as
PrivateMessage and None when it was framed as a plaintext PublicMessage. As
with application messages, the generation_id is Some only on a group bound
to a derivation epoch.
Once the Delivery Service accepts the message, drop the retained key with
confirm_handshake_message, passing the confirmation’s epoch and
generation:
main_group.confirm_handshake_message(
provider.storage(),
confirmation.epoch,
confirmation.generation,
)?;
The epoch carried by a commit’s confirmation is the epoch the commit was encrypted in, which is the epoch before the commit is merged. Confirming is epoch-scoped, so it stays correct even when called after the merge has advanced the group to a later epoch. Proposals and commits share the handshake ratchet, so this single endpoint covers both.
Classic proposal functions
The classic proposal functions (propose, propose_add_member,
propose_self_update, propose_self_update_with_new_signer,
propose_group_context_extensions, leave_group, and the rest) are not
available with the feature, the same way the committing convenience functions
are not. They return only the framed MlsMessageOut and discard the handshake
confirmation data.
Use propose_unconfirmed instead. It retains the handshake secret and returns
the confirmation data alongside the framed proposal, which you confirm with
confirm_handshake_message once the Delivery Service accepts the send. For a
signature-key rotation use propose_self_update_with_new_signer_unconfirmed,
which returns the same confirmation data.
propose_unconfirmed dispatches on the Propose variant, so it also covers
Propose::GroupContextExtensions and the AppDataDictionary update variants.
The committing convenience functions (add_members, add_members_without_update,
swap_members, remove_members, self_update, self_update_with_new_signer,
commit_to_pending_proposals, and update_group_context_extensions) are not
available with the feature. They return only the commit and discard its
confirmation data. Build commits through commit_builder instead, and read the
confirmation from the bundle’s confirmation() as shown above.
Application messages
With the feature enabled, the single-shot create_message is replaced by a two
step send flow, because two emulator clients can race for the same ratchet
generation.
create_unconfirmed_message encrypts the payload, retains the key and nonce,
and returns the message together with the ratchet generation and a
generation_id:
let unconfirmed = main_group
.create_unconfirmed_message(provider, &vc_signer, b"hello")?;
// Attach unconfirmed.generation_id when fanning out, so the Delivery Service
// can detect a generation collision with a sibling.
send_to_delivery_service(unconfirmed.message, unconfirmed.generation_id);
The generation_id is Some on a group bound to a derivation epoch and None
otherwise. It is derived from generation_id_secret over the spec’s
PrivateMessageContext. The reuse guard is computed rather than sampled: the
client resolves the derivation epoch through the (GroupId, GroupEpoch) binding,
picks a value congruent to its emulation leaf index modulo the emulation group
size, and encrypts it with a small-space PRP keyed from reuse_guard_secret.
Once the Delivery Service accepts the message, drop the retained key:
main_group.confirm_application_message(provider.storage(), unconfirmed.epoch, unconfirmed.generation)?;
If the Delivery Service reports a collision, the sibling won that generation.
Process the winning message through process_message, which has a carve-out for
messages arriving from the receiver’s own leaf. Decrypting the winner consumes
the retained key for that generation, so no explicit cleanup is needed. Then
call create_unconfirmed_message again to re-encrypt from the ratchet head.
There is no explicit discard call. A retained unconfirmed key is, from the
receiving side, the same as a skipped-generation key. It is cleaned up by
confirm_application_message, by decrypting a sibling’s message at that
generation, or by
aging out under bounded retention.
On the receiving side, process_message inverts the reuse guard PRP and
recovers the sender’s leaf index in the emulation group, so the application can
attribute the message to a specific emulator client:
let processed = receiver_group
.process_message(provider, message.into_protocol_message().unwrap())?;
if let Some(emulation_leaf) = processed.emulator_sender_leaf_index() {
// The message came from this emulator client of the virtual client.
}
emulator_sender_leaf_index() returns None for messages that did not come
from a virtual client, or on a group with no emulation binding.
KeyPackages and Welcomes
A virtual client publishes KeyPackages so that a sibling can later recover the
private keys and join a higher-level group on its behalf. KeyPackages are built
in batches, because one key_package operation generation seeds a whole batch.
Build the batch with build_vc_batch. It allocates one key_package
generation, derives a per-KeyPackage seed for each index, embeds a
DerivationInfo in every leaf, writes each bundle to local storage, and returns
the generation plus one (KeyPackageBundle, KeyPackageInfo) per KeyPackage:
let batch = KeyPackage::builder()
.leaf_node_capabilities(capabilities)
.leaf_node_extensions(leaf_extensions)?
.build_vc_batch(
ciphersuite,
provider,
&vc_signer,
vc_credential,
emulator_group.group_id(),
count, // number of KeyPackages, must be > 0
)?;
The operation tree is advanced in memory and persisted only after every
KeyPackage is built, so a build failure consumes no generation. A count of 0
returns EmptyBatch before any state is touched.
Assemble the upload the virtual client hands to its sibling from the batch’s
epoch and generation and its KeyPackageInfos. Take the epoch from the batch
rather than resolving it again: the emulation group may have moved on to a newer
derivation epoch in the meantime. OpenMLS fills the emulation leaf_index from
the stored epoch state:
use openmls::components::vc_derivation_info::assemble_vc_key_package_upload;
let infos = batch
.key_packages
.iter()
.map(|(_bundle, info)| info.clone())
.collect();
let upload = assemble_vc_key_package_upload(
provider.storage(),
batch.epoch_id.clone(),
batch.generation,
infos,
)?;
How the upload reaches the sibling emulator clients is up to the application. It
must reach them before the KeyPackages are offered to anyone else. On receipt, a
sibling calls process_vc_key_package_upload, which derives the init and leaf
encryption private keys for each listed reference and stores them keyed by
KeyPackageRef:
use openmls::components::vc_derivation_info::process_vc_key_package_upload;
process_vc_key_package_upload(provider, &upload)?;
After that, Welcome processing runs through the ordinary ProcessedWelcome and
StagedWelcome entry points unchanged. The lookup by KeyPackageRef finds
either a locally generated KeyPackageBundle or the derived virtual-client key
material. Eager derivation costs no forward secrecy: the derived private keys
take the place of any retained operation secret and have to be kept until the
KeyPackage is no longer live anyway.
What is not implemented yet
The implementation tracks the draft but does not yet cover everything in it:
- Onboarding a new emulator client by state transfer (the draft’s
NewEmulatorClientState, Variant A) is not implemented. Onboarding through an external commit (Variant B) works, because it is an application-orchestrated sequence of operations the code already supports. - VC Update proposals are not implemented. Only commits and external commits emit virtual-client leaves.
- The
key_package_uploadaction is carried and parsed, but nothing acts on it automatically. The application decides whether to attach it to an emulation-group commit, reads it back withProcessedMessage::vc_commit_data(), and callsprocess_vc_key_package_uploaditself. Only thenew_derivation_epochaction is acted on by the library. - A retained KeyPackage that expires unused keeps its derivation epoch alive
until the application deletes the KeyPackage. Deletion through the storage
provider’s
delete_key_packageremoves the retained material too, and a follow-up call todelete_unreferenced_vc_derivation_epoch_statesreleases the epoch’s key material if nothing else references it.
Refer to the virtual clients draft for the authoritative protocol description.