Skip to content

Latest commit

 

History

History
245 lines (183 loc) · 8.86 KB

File metadata and controls

245 lines (183 loc) · 8.86 KB

Integration

CFR owns no sockets. It consumes byte slices and returns messages with a destination; delivering them is the application's job.

1. Admission

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 band

The 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.

2. The message loop

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.

3. Media

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.

WebRTC

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.

4. Selective forwarding units

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.

5. Key confirmation

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.

6. Recovery

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.

7. Durable process restart

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.

8. Post-compromise

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.

9. Choosing a policy

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.

10. Post-quantum

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.