Finding
derive_olm_initial_ratchet_key concatenates three 32-byte values as HKDF input key material with an empty salt and a public label. All three are values the homeserver already has, so the homeserver can recompute the session's initial ratchet key. The function's own doc states that there are "no DH outputs to combine" and that this is a "departure from full X3DH" — but the consequence is stronger than a departure. Without a Diffie-Hellman step there is no secret in the derivation at all, so the construction is not a weakened key agreement; it is the absence of one.
Evidence
Read at main 87fdfad.
The derivation, crates/thumos/src/matrix_crypto.rs:1464-1479:
fn derive_olm_initial_ratchet_key(
sender_identity_key: &[u8; KEY_SIZE],
base_key: &[u8; KEY_SIZE],
one_time_key: &[u8; KEY_SIZE],
) -> Result<[u8; KEY_SIZE], CryptoError> {
const LABEL: &[u8] = b"olm-prekey-session";
let mut ikm = [0u8; KEY_SIZE * 3];
ikm[..KEY_SIZE].copy_from_slice(sender_identity_key);
ikm[KEY_SIZE..KEY_SIZE * 2].copy_from_slice(base_key);
ikm[KEY_SIZE * 2..].copy_from_slice(one_time_key);
// HKDF over ikm, empty salt, constant label
Where each input comes from, and who else has it:
| Input |
Origin |
Homeserver has it because |
one_time_key |
this device's own pool |
build_one_time_keys_json (:1506-1518) hex-encodes every pool entry verbatim into the /keys/upload body — w.string_value(&hex_encode(key)) at :1514, reached from build_key_upload_request at :719 |
sender_identity_key |
the peer's curve25519_key |
published the same way by every device: build_device_keys_json at :1497 hex-encodes it into /keys/upload; this device reads peers' copies out of a /keys/query response at :817 |
base_key |
body[..KEY_SIZE] of the inbound pre-key message (:678-680) |
it arrives as a to_device event, which the homeserver relays |
So every byte of ikm transits or originates at the homeserver, and LABEL is a compile-time constant. Given the traffic it already stores, the homeserver can run the same HKDF.
Why there is no secret to add: generate_device_keys (:974-983) fills both key fields straight from kernel_random_bytes and keeps nothing else. There is no private scalar anywhere — the same 32 bytes are the published "public key" and the only key material the device holds for this peer. The struct doc at :327-332 states this plainly ("CSPRNG-derived 256-bit keys used with HKDF for key agreement rather than actual elliptic curve operations"); what it does not say is that the values are also published.
Why this matters
End-to-end encryption exists to exclude the homeserver specifically. A session key the homeserver can derive gives it plaintext for every message that session would ever protect, which is the whole property, not a degradation of it.
Currently latent, and that is the reason to fix it now rather than the reason not to. matrix_crypto is compiled-only in docs/capability-inventory.toml:135 — MatrixClient is never constructed from kernel_main — and #844 records that there is no per-message Olm encrypt/decrypt path, so nothing is encrypted under this key today. The defect is therefore inherited rather than exploited: whoever builds the message path (#437) gets a ratchet root that looks derived and is not confidential, and the ratchet's forward secrecy would then be measured from a public starting point.
This is the concrete instance of what #889 asks to be settled at the protocol level. Filed separately because it is a verifiable defect with citations rather than a design decision, and it should not be closed by a decision that leaves the code as it is.
Desired correction
Perform a real key agreement, or state in the type system that none is performed.
The first option means a genuine X25519 Diffie-Hellman between this device's private identity/one-time scalars and the peer's public keys, with the DH outputs — not the public keys — as HKDF input. That requires the device to hold private scalars it currently does not have, which is the same gap as the device-identity half noted on #831.
The second option is honest and cheap while #437 is unbuilt: rename the type and the function so nothing reads as a session key (OlmPrekeyBinding over derive_olm_prekey_binding, say), and make the doc state that the value is a session identifier derived from public inputs and must never be used to encrypt. That closes the path where a later implementer treats it as a root key.
Choosing the second is a deliberate deferral and should say so; choosing neither leaves a function named derive_..._ratchet_key returning something that must not be used as one.
Done when: either the Olm session key is derived from at least one value the homeserver cannot observe, with a test asserting two sessions built from identical public inputs but different private scalars do not agree; or no type or function in matrix_crypto names a value derived from public inputs as a ratchet or session key, and its doc states that it carries no confidentiality.
Finding
derive_olm_initial_ratchet_keyconcatenates three 32-byte values as HKDF input key material with an empty salt and a public label. All three are values the homeserver already has, so the homeserver can recompute the session's initial ratchet key. The function's own doc states that there are "no DH outputs to combine" and that this is a "departure from full X3DH" — but the consequence is stronger than a departure. Without a Diffie-Hellman step there is no secret in the derivation at all, so the construction is not a weakened key agreement; it is the absence of one.Evidence
Read at
main87fdfad.The derivation,
crates/thumos/src/matrix_crypto.rs:1464-1479:Where each input comes from, and who else has it:
one_time_keybuild_one_time_keys_json(:1506-1518) hex-encodes every pool entry verbatim into the/keys/uploadbody —w.string_value(&hex_encode(key))at:1514, reached frombuild_key_upload_requestat:719sender_identity_keycurve25519_keybuild_device_keys_jsonat:1497hex-encodes it into/keys/upload; this device reads peers' copies out of a/keys/queryresponse at:817base_keybody[..KEY_SIZE]of the inbound pre-key message (:678-680)to_deviceevent, which the homeserver relaysSo every byte of
ikmtransits or originates at the homeserver, andLABELis a compile-time constant. Given the traffic it already stores, the homeserver can run the same HKDF.Why there is no secret to add:
generate_device_keys(:974-983) fills both key fields straight fromkernel_random_bytesand keeps nothing else. There is no private scalar anywhere — the same 32 bytes are the published "public key" and the only key material the device holds for this peer. The struct doc at:327-332states this plainly ("CSPRNG-derived 256-bit keys used with HKDF for key agreement rather than actual elliptic curve operations"); what it does not say is that the values are also published.Why this matters
End-to-end encryption exists to exclude the homeserver specifically. A session key the homeserver can derive gives it plaintext for every message that session would ever protect, which is the whole property, not a degradation of it.
Currently latent, and that is the reason to fix it now rather than the reason not to.
matrix_cryptoiscompiled-onlyindocs/capability-inventory.toml:135—MatrixClientis never constructed fromkernel_main— and #844 records that there is no per-message Olm encrypt/decrypt path, so nothing is encrypted under this key today. The defect is therefore inherited rather than exploited: whoever builds the message path (#437) gets a ratchet root that looks derived and is not confidential, and the ratchet's forward secrecy would then be measured from a public starting point.This is the concrete instance of what #889 asks to be settled at the protocol level. Filed separately because it is a verifiable defect with citations rather than a design decision, and it should not be closed by a decision that leaves the code as it is.
Desired correction
Perform a real key agreement, or state in the type system that none is performed.
The first option means a genuine X25519 Diffie-Hellman between this device's private identity/one-time scalars and the peer's public keys, with the DH outputs — not the public keys — as HKDF input. That requires the device to hold private scalars it currently does not have, which is the same gap as the device-identity half noted on #831.
The second option is honest and cheap while #437 is unbuilt: rename the type and the function so nothing reads as a session key (
OlmPrekeyBindingoverderive_olm_prekey_binding, say), and make the doc state that the value is a session identifier derived from public inputs and must never be used to encrypt. That closes the path where a later implementer treats it as a root key.Choosing the second is a deliberate deferral and should say so; choosing neither leaves a function named
derive_..._ratchet_keyreturning something that must not be used as one.Done when: either the Olm session key is derived from at least one value the homeserver cannot observe, with a test asserting two sessions built from identical public inputs but different private scalars do not agree; or no type or function in
matrix_cryptonames a value derived from public inputs as a ratchet or session key, and its doc states that it carries no confidentiality.