CFR owns no sockets. It consumes byte slices and returns messages with a destination; delivering them is the application's job.
A newcomer generates its own material and hands over a self-signed package:
let joining = cfr_protocol::Joining::new(Policy::leaderless(2))?;
let package = joining.key_package(); // send to the inviter out of bandThe package is self-signed, so an inviter cannot substitute a prekey it controls and read the welcome. The library cannot authenticate the identity — deliver the package over a channel that does, or verify fingerprints separately.
The inviter admits:
let out = conference.invite(&package)?;One of the returned messages is addressed to the newcomer; the rest are for everyone. Deliver them.
let (mut newcomer, out) = joining.accept(&welcome_payload)?;A newcomer does not contribute immediately. Existing members rotate their
prekeys when they see the admission, and until those rotations land the
newcomer's view of them is one message stale. Call rekey() on the next cycle.
for msg in outbound {
match msg.to {
Recipient::Everyone => transport.broadcast(&msg.payload),
Recipient::Peer(id) => transport.send_to(id, &msg.payload),
}
}
// inbound
let (events, more) = conference.handle(&payload)?;Handle the events:
| event | action |
|---|---|
KeyChanged |
nothing required; media keys refresh themselves |
Joined / Left |
update the roster in the user interface |
Equivocation |
surface it; the participant is being evicted |
RepairNeeded |
call resync() and deliver the result |
Call tick() about once per rotation interval so prekey deadlines advance.
let sealed = conference.protect(Codec::H264, &frame, is_keyframe)?;
// … send over SRTP as usual …
let (from, plain) = conference.open(&packet)?;Protect encoded frames, before packetisation, and open after reassembly. The transport's own encryption stays in place: CFR protects against the server, SRTP against the network.
Frame overhead is 45 bytes for video and 29 for audio. If you are packetising close to the MTU, reduce the payload budget by that much.
In libwebrtc terms this is a frame encryptor and decryptor pair. Register
protect as the encryptor and open as the decryptor. The keyframe flag comes
from the encoded image; passing it wrongly is safe — for VP8 the bitstream is
consulted and overrides a false claim.
A forwarding unit needs no key:
let t = Conference::inspect(&packet)?;
t.sender; t.counter; t.codec; t.keyframe;and the codec structure is still in the frame: NAL headers, OBU headers and the VP8 uncompressed chunk are byte-identical to the original and sit at the same offsets. Parse and route as before.
What a forwarder cannot do is change any of it. The trailer and every readable range are authenticated; a single altered bit makes the frame fail to open at every receiver.
Attach conference.beacon() — twelve bytes — to outgoing media, and check what
arrives:
match conference.check_beacon(&peer, &beacon) {
Beacon::Agreed => {}
Beacon::Diverged => alert("someone is being fed a different history"),
Beacon::Unknown => { /* usually lag; resync before concluding */ }
}Diverged means same version, different key. That is not a network problem.
if conference.needs_repair() {
for m in conference.resync() { transport.send(m); }
}resync is safe to call at any time and is the single recovery path: it covers
missed operations and missing node keys.
An inviter that cannot currently derive the key will refuse to admit — it cannot hand over material it does not have. Resync, then retry.
With the default std feature, cfr_protocol::persistence::PersistentConference owns a
Conference, a bounded inbound idempotency window and a durable control-message
outbox. It never exposes &mut Conference; every state-changing protocol and
media operation crosses the same filesystem transaction boundary.
use cfr_protocol::persistence::{InboundId, PersistentConference};
let mut conference = PersistentConference::create("call-state", policy)?;
// The transport supplies a stable ID. It is not generated by CFR.
let result = conference.handle_inbound(InboundId::from_bytes(transport_id), &payload)?;
for delivery in conference.pending_deliveries() {
transport.send(
delivery.delivery_key.as_bytes(),
delivery.recipient,
&delivery.payload,
)?;
// Acknowledge only after the transport accepts responsibility for delivery.
conference.acknowledge(delivery.id)?;
}
drop(conference); // process shutdown
let conference = PersistentConference::open("call-state")?;create refuses an existing path and open returns NotFound for an absent
state directory. There is deliberately no open_or_create: a missing state can
never silently replace the identity or session. A newcomer uses
PersistentConference::join with its Joining value and welcome payload.
An inbound ID has three outcomes:
| condition | result |
|---|---|
| new ID | protocol mutation, inbound digest and resulting outbox rows commit atomically |
| same ID and bytes | duplicate = true; no events, outbox rows or transaction |
| same ID, different bytes | IdempotencyConflict; no state change |
Outbox IDs are monotonic and delivery keys are deterministic over the session,
local identity, ID, recipient and exact payload. Unacknowledged rows survive
restart in ID order. A repeated acknowledgement returns false without writing
a transaction. This is an at-least-once queue: the transport must use the
delivery key to suppress duplicate sends around its own crash boundary.
For every mutation, CFR first imports an isolated candidate, applies the
operation there, encodes and validates the complete candidate, appends a
full-state WAL record and waits for sync_data. Only then does it replace the
live state or return media plaintext/ciphertext, events or outbound IDs. This
includes protect and open_media, so a restart cannot roll back a sender
counter or an authenticated replay window.
The logical state schema and snapshot/WAL envelope have independent internal
version tags, both currently 1. Unknown tags fail with UnsupportedVersion;
there are no guessed legacy formats or synthetic migrations. Recovery verifies
every complete WAL record. It truncates only an incomplete final record, fails
closed on a complete bad checksum/marker/state, and can use a newer full-state
WAL record when the snapshot is corrupt. Snapshot replacement and WAL reset use
synced temporary files, atomic rename and directory sync.
Default persisted limits are:
| bound | default |
|---|---|
| inbound idempotency IDs | 4,096 |
| unacknowledged outbox rows | 1,024 |
| logical state / WAL-record payload | 4 MiB |
| WAL file | 64 MiB |
| checkpoint threshold | 32 MiB |
PersistenceOptions can lower these values for a deployment or test. The
options themselves are persisted and validated on open. Before an append would
cross the threshold or WAL limit, the current committed state is checkpointed
and the WAL is reset; if one candidate still cannot fit, the operation fails
before changing live state. checkpoint() also exposes explicit compaction.
Only one writable handle can own a state directory. CFR holds an OS advisory
lock on an open file descriptor for the handle lifetime; a diagnostic PID file
is not used for ownership. On Unix, new directories are 0700 and state files
are 0600.
The store contains secret key material in plaintext. Permissions are not
encryption, and checksums are corruption detection rather than authentication.
See the persisted-state boundary in security.md before deciding
where the directory and its backups may live.
After any suspicion that a device was compromised:
for m in conference.heal()? { transport.send(m); }This rotates the prekey and contributes. Both are needed. Rotation alone leaves the attacker holding the current key; a contribution alone leaves it able to read the channels.
Policy::leaderless(quorum) is the configuration the analysis assumes. A quorum
of two means two distinct participants must agree to evict, which stops a single
malicious member from ejecting people.
Naming administrators is supported and weakens the leaderless property: a named identity can evict unilaterally. Use it only when the deployment already has an authority worth trusting with that.
Enable the pq feature to use X25519 + ML-KEM-768 hybrid key encapsulation.
Every reduction in the analysis goes through unchanged; the assumption becomes
"Gap-CDH or ML-KEM-768 IND-CCA" rather than Gap-CDH alone. Key packages grow
by about 1.2 kB.