moho now joins the LiveKit calls Element holds (#132), but only unencrypted ones carry sound both ways.
Element Call encrypts a call's media per sender: each participant generates a key, shares it with the others over Matrix (to-device, io.element.call.encryption_keys / the newer RTCEncryptionManager + ToDeviceKeyTransport in matrix-js-sdk), and the SFU forwards ciphertext it cannot read. A client that joins and sends plaintext is a client nobody in that call can hear — and whose own tiles stay silent.
moho detects the case and says so when joining a call in an encrypted room, which is the honest stopgap, not the fix.
What it needs
Generate a sender key per session and rotate it when somebody leaves — a person who has left must not be able to keep decoding.
Share and receive keys over to-device, encrypted with Olm the way every other key here already is; the crypto stack for that is already in the daemon.
Encrypt and decrypt the media with those keys in the renderer — WebRTC insertable streams / RTCRtpScriptTransform, which is what LiveKit's own E2EE support uses (Room takes an e2eeOptions with an external key provider, so this may be mostly key management rather than cipher work).
Match Element's key identifiers and rotation exactly, or the two clients will hold different keys and hear nothing while believing they are connected.
moho now joins the LiveKit calls Element holds (#132), but only unencrypted ones carry sound both ways.
Element Call encrypts a call's media per sender: each participant generates a key, shares it with the others over Matrix (to-device, `io.element.call.encryption_keys` / the newer `RTCEncryptionManager` + `ToDeviceKeyTransport` in matrix-js-sdk), and the SFU forwards ciphertext it cannot read. A client that joins and sends plaintext is a client nobody in that call can hear — and whose own tiles stay silent.
moho detects the case and says so when joining a call in an encrypted room, which is the honest stopgap, not the fix.
**What it needs**
- Generate a sender key per session and rotate it when somebody leaves — a person who has left must not be able to keep decoding.
- Share and receive keys over to-device, encrypted with Olm the way every other key here already is; the crypto stack for that is already in the daemon.
- Encrypt and decrypt the media with those keys in the renderer — WebRTC insertable streams / `RTCRtpScriptTransform`, which is what LiveKit's own E2EE support uses (`Room` takes an `e2eeOptions` with an external key provider, so this may be mostly key management rather than cipher work).
- Match Element's key identifiers and rotation exactly, or the two clients will hold different keys and hear nothing while believing they are connected.
A call in an encrypted room is now encrypted end to end: the media server forwards frames it has no key for, and the keys travel over Matrix.
Element's own transport, so a call with both clients in it works: one io.element.call.encryption_keys per device, Olm-encrypted, carrying a 16-byte key and an index that cycles 0–255 — the same key material Element generates. Sending needed matrix-sdk-crypto's experimental-send-custom-to-device feature (encrypt_content_for_devices), the one API that encrypts an arbitrary event to named devices rather than to a room.
Receiving came from somewhere the daemon was already looking and discarding.receive_sync_changes hands back the decrypted to-device events and every one of them was being thrown away; the call keys are lifted out now and the rest still goes to the crypto machine.
Handed to LiveKit as HKDF material, not as a key — that is what it derives its cipher key from and what Element gives it, with the same ratchet window (10) and keyring size (256). A provider that ratchets differently decodes nothing.
Rolled when somebody leaves, so what is said after they go is not readable by them, and re-sent when somebody arrives — the two events are answered differently, which is Element's behaviour too.
Sent to every device in the call, verified or not. A call's key is not a secret kept from an unverified phone: the people in the room are the people in the call, and withholding it means somebody sitting in a call hearing silence.
Verified with a negative control, because "tracks exist" does not mean "frames decrypt". Two accounts on two homeservers, in an encrypted room, on the local LiveKit + lk-jwt-service rig:
Breaking the key exchange changed the observable, which is what makes the positive result mean something.
One thing the control exposed: LiveKit drops undecryptable frames silently — no EncryptionError fired. So a key mismatch looks exactly like a quiet room. moho now names anybody whose key has not arrived a few seconds after they joined, rather than leaving somebody to wonder why nobody is talking.
Still untested against Element itself — there is no Element on this machine, so this is verified against Element's server components and its published key format, not its client. Worth one live check when an Element user is to hand.
Finished — https://git.salastil.com/Salastil/nobilis/commit/96e096a and https://git.salastil.com/Salastil/moho/commit/c33bdc8
A call in an encrypted room is now encrypted end to end: the media server forwards frames it has no key for, and the keys travel over Matrix.
**Element's own transport**, so a call with both clients in it works: one `io.element.call.encryption_keys` per device, Olm-encrypted, carrying a 16-byte key and an index that cycles 0–255 — the same key material Element generates. Sending needed matrix-sdk-crypto's `experimental-send-custom-to-device` feature (`encrypt_content_for_devices`), the one API that encrypts an arbitrary event to named devices rather than to a room.
**Receiving came from somewhere the daemon was already looking and discarding.** `receive_sync_changes` hands back the decrypted to-device events and every one of them was being thrown away; the call keys are lifted out now and the rest still goes to the crypto machine.
**Handed to LiveKit as HKDF material**, not as a key — that is what it derives its cipher key from and what Element gives it, with the same ratchet window (10) and keyring size (256). A provider that ratchets differently decodes nothing.
**Rolled when somebody leaves**, so what is said after they go is not readable by them, and re-sent when somebody arrives — the two events are answered differently, which is Element's behaviour too.
**Sent to every device in the call, verified or not.** A call's key is not a secret kept from an unverified phone: the people in the room are the people in the call, and withholding it means somebody sitting in a call hearing silence.
**Verified with a negative control**, because "tracks exist" does not mean "frames decrypt". Two accounts on two homeservers, in an encrypted room, on the local LiveKit + lk-jwt-service rig:
```
keys exchanged → remote tile carries audio samples (analyser peak 1, non-zero)
keys withheld → remote tile is dead silence (peak 0)
```
Breaking the key exchange changed the observable, which is what makes the positive result mean something.
**One thing the control exposed:** LiveKit drops undecryptable frames silently — no `EncryptionError` fired. So a key mismatch looks exactly like a quiet room. moho now names anybody whose key has not arrived a few seconds after they joined, rather than leaving somebody to wonder why nobody is talking.
**Still untested against Element itself** — there is no Element on this machine, so this is verified against Element's server components and its published key format, not its client. Worth one live check when an Element user is to hand.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
moho now joins the LiveKit calls Element holds (#132), but only unencrypted ones carry sound both ways.
Element Call encrypts a call's media per sender: each participant generates a key, shares it with the others over Matrix (to-device,
io.element.call.encryption_keys/ the newerRTCEncryptionManager+ToDeviceKeyTransportin matrix-js-sdk), and the SFU forwards ciphertext it cannot read. A client that joins and sends plaintext is a client nobody in that call can hear — and whose own tiles stay silent.moho detects the case and says so when joining a call in an encrypted room, which is the honest stopgap, not the fix.
What it needs
RTCRtpScriptTransform, which is what LiveKit's own E2EE support uses (Roomtakes ane2eeOptionswith an external key provider, so this may be mostly key management rather than cipher work).Finished — https://git.salastil.com/Salastil/nobilis/commit/96e096a and https://git.salastil.com/Salastil/moho/commit/c33bdc8
A call in an encrypted room is now encrypted end to end: the media server forwards frames it has no key for, and the keys travel over Matrix.
Element's own transport, so a call with both clients in it works: one
io.element.call.encryption_keysper device, Olm-encrypted, carrying a 16-byte key and an index that cycles 0–255 — the same key material Element generates. Sending needed matrix-sdk-crypto'sexperimental-send-custom-to-devicefeature (encrypt_content_for_devices), the one API that encrypts an arbitrary event to named devices rather than to a room.Receiving came from somewhere the daemon was already looking and discarding.
receive_sync_changeshands back the decrypted to-device events and every one of them was being thrown away; the call keys are lifted out now and the rest still goes to the crypto machine.Handed to LiveKit as HKDF material, not as a key — that is what it derives its cipher key from and what Element gives it, with the same ratchet window (10) and keyring size (256). A provider that ratchets differently decodes nothing.
Rolled when somebody leaves, so what is said after they go is not readable by them, and re-sent when somebody arrives — the two events are answered differently, which is Element's behaviour too.
Sent to every device in the call, verified or not. A call's key is not a secret kept from an unverified phone: the people in the room are the people in the call, and withholding it means somebody sitting in a call hearing silence.
Verified with a negative control, because "tracks exist" does not mean "frames decrypt". Two accounts on two homeservers, in an encrypted room, on the local LiveKit + lk-jwt-service rig:
Breaking the key exchange changed the observable, which is what makes the positive result mean something.
One thing the control exposed: LiveKit drops undecryptable frames silently — no
EncryptionErrorfired. So a key mismatch looks exactly like a quiet room. moho now names anybody whose key has not arrived a few seconds after they joined, rather than leaving somebody to wonder why nobody is talking.Still untested against Element itself — there is no Element on this machine, so this is verified against Element's server components and its published key format, not its client. Worth one live check when an Element user is to hand.