%%%=================================================================== %%% matrix_e2e.erl — E2E crypto state manager %%% %%% Added: %%% encrypt_room_event/2 — encrypt a room event with Megolm %%% megolm_outbound state — outbound sessions per room %%% share_megolm_key — Olm key sharing with room members %%%=================================================================== -module(matrix_e2e). -behaviour(gen_server). -export([start_link/2, decrypt_room_event/1, handle_to_device/1, device_keys/0, request_room_key/3, upload_device_self_signature/7, b64u/1, reupload_device_sig/0, verify_with/2, fetch_backup_keys/1, %% NEW encrypt_room_event/2]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). -define(STATE_FILE, "matrix_e2e_state.bin"). -define(OTK_TARGET, 50). -record(verif, { txn_id :: binary(), their_user_id :: binary(), their_device_id :: binary(), our_ephem_pub = undefined, our_ephem_priv = undefined, their_ephem_pub = undefined, start_content = undefined, shared_secret = undefined }). -record(state, { account, device_id :: binary(), user_id :: binary() | undefined, token :: binary(), homeserver :: string(), megolm_sessions = #{} :: #{}, olm_sessions = #{} :: #{}, cross_signing_master = undefined, cross_signing_self = undefined, cross_signing_user = undefined, cross_signing_uploaded = false :: boolean(), verifications = #{} :: #{}, %% NEW: outbound Megolm sessions, one per room %% #{RoomId => {SessionId::binary(), Pickle::binary()}} megolm_outbound = #{} :: #{} }). %%%=================================================================== %%% Public API %%%=================================================================== -spec start_link(binary(), string()) -> {ok, pid()} | {error, term()}. start_link(Token, HomeserverUrl) -> gen_server:start_link({local, ?MODULE}, ?MODULE, [Token, HomeserverUrl], []). -spec decrypt_room_event(map()) -> {ok, map()} | {error, term()}. decrypt_room_event(Event) -> gen_server:call(?MODULE, {decrypt_room_event, Event}, 15000). -spec handle_to_device([map()]) -> ok. handle_to_device(Events) -> gen_server:cast(?MODULE, {to_device_events, Events}). -spec device_keys() -> {ok, map()} | {error, term()}. device_keys() -> gen_server:call(?MODULE, device_keys). -spec request_room_key(binary(), binary(), binary()) -> ok. request_room_key(RoomId, SessionId, SenderKey) -> gen_server:cast(?MODULE, {request_room_key, RoomId, SessionId, SenderKey}). -spec reupload_device_sig() -> ok. reupload_device_sig() -> gen_server:cast(?MODULE, reupload_device_sig). -spec verify_with(binary(), binary()) -> ok. verify_with(UserId, DeviceId) -> gen_server:cast(?MODULE, {verify_with, UserId, DeviceId}). %% NEW: encrypt a room event with the outbound Megolm session for RoomId. %% EventContent is a map like #{<<"type">> => ..., <<"content">> => ..., <<"room_id">> => ...} %% Returns {ok, EncryptedContent} where EncryptedContent is the m.room.encrypted payload. -spec encrypt_room_event(binary(), map()) -> {ok, map()} | {error, term()}. encrypt_room_event(RoomId, EventContent) -> gen_server:call(?MODULE, {encrypt_room_event, RoomId, EventContent}, 30000). %%%=================================================================== %%% gen_server %%%=================================================================== init([Token, HomeserverUrl]) -> case keylara:start() of ok -> ok; {error, _Reason} -> ok end, Hostname = extract_hostname(HomeserverUrl), State = load_state(Token, Hostname), {UserId, Did} = case matrix_http:get(Hostname, "/_matrix/client/v3/account/whoami", Token) of {ok, Body} -> Data = json:decode(Body), {maps:get(<<"user_id">>, Data, undefined), maps:get(<<"device_id">>, Data, State#state.device_id)}; _ -> {undefined, State#state.device_id} end, io:format("E2E: authenticated as ~s device ~s~n", [UserId, Did]), case os:getenv("MATRIX_BACKUP_KEY") of false -> ok; RKey -> io:format("E2E: auto-fetching backup keys from env~n"), self() ! {fetch_backup_keys, list_to_binary(RKey)} end, self() ! upload_keys, {ok, State#state{user_id = UserId, device_id = Did}}. handle_call({decrypt_room_event, Event}, _From, State) -> {Result, NewState} = do_decrypt_room_event(Event, State), {reply, Result, NewState}; handle_call({fetch_backup_keys, RecoveryKey}, _From, State) -> {Result, NewState} = do_fetch_backup_keys(RecoveryKey, State), save_state(NewState), {reply, Result, NewState}; handle_call(device_keys, _From, State) -> Keys = matrix_olm_session:account_identity_keys(State#state.account), {reply, {ok, Keys#{<<"device_id">> => State#state.device_id}}, State}; %% NEW handle_call({encrypt_room_event, RoomId, EventContent}, _From, State) -> {Result, NewState} = do_encrypt_room_event(RoomId, EventContent, State), {reply, Result, NewState}; handle_call(_R, _F, S) -> {reply, {error, unknown_request}, S}. handle_cast({to_device_events, Events}, State) -> NewState = lists:foldl(fun process_to_device/2, State, Events), save_state(NewState), {noreply, NewState}; handle_cast({verify_with, UserId, DeviceId}, State) -> TxnId = gen_request_id(), Msg = #{<<"from_device">> => State#state.device_id, <<"methods">> => [<<"m.sas.v1">>], <<"timestamp">> => erlang:system_time(millisecond), <<"transaction_id">> => TxnId}, send_to_device(State, <<"m.key.verification.request">>, UserId, DeviceId, Msg), io:format("E2E: SAS request sent to ~s/~s txn=~s~n", [UserId, DeviceId, TxnId]), {noreply, State}; handle_cast(reupload_device_sig, State = #state{ homeserver = HS, token = Token, device_id = Did, user_id = UserId, account = Acc, cross_signing_self = {SelfPub, SelfPriv}}) -> SelfKeyId = <<"ed25519:", (b64u(SelfPub))/binary>>, upload_device_self_signature(HS, Token, Did, UserId, Acc, SelfPriv, SelfKeyId), {noreply, State}; handle_cast({request_room_key, RoomId, SessionId, SenderKey}, State) -> do_request_room_key(RoomId, SessionId, SenderKey, State), {noreply, State}; handle_cast(_M, S) -> {noreply, S}. handle_info(upload_keys, State) -> State2 = maybe_upload_keys(State), {noreply, State2}; handle_info({fetch_backup_keys, RecoveryKey}, State) -> case do_fetch_backup_keys(RecoveryKey, State) of {ok, NewState} -> save_state(NewState), {noreply, NewState}; {{error, E}, _} -> io:format("E2E: auto backup fetch failed: ~p~n", [E]), {noreply, State} end; handle_info(_I, S) -> {noreply, S}. terminate(_R, State) -> save_state(State), ok. code_change(_OldVsn, S, _E) -> {ok, S}. %%%=================================================================== %%% NEW: Outbound Megolm encryption %%%=================================================================== do_encrypt_room_event(RoomId, EventContent, State) -> {Session, SessionId, State2} = ensure_outbound_session(RoomId, State), Plaintext = iolist_to_binary(json:encode(EventContent)), case matrix_megolm:encrypt_outbound(Session, Plaintext) of {ok, Ciphertext, Session2} -> Pickle = matrix_megolm:pickle_outbound(Session2), Outbound = maps:put(RoomId, {SessionId, Pickle}, State2#state.megolm_outbound), State3 = State2#state{megolm_outbound = Outbound}, IdKeys = matrix_olm_session:account_identity_keys(State#state.account), SenderKey = maps:get(<<"curve25519">>, IdKeys), EncContent = #{ <<"algorithm">> => <<"m.megolm.v1.aes-sha2">>, <<"ciphertext">> => base64:encode(Ciphertext), <<"device_id">> => State#state.device_id, <<"sender_key">> => SenderKey, <<"session_id">> => SessionId }, {{ok, EncContent}, State3}; {error, Reason} -> {{error, Reason}, State} end. %% Returns {Session, SessionId, State}. %% Creates a new outbound session (and shares the key) if none exists. ensure_outbound_session(RoomId, State) -> case maps:get(RoomId, State#state.megolm_outbound, undefined) of undefined -> {ok, Session} = matrix_megolm:create_outbound(), SessionId = matrix_megolm:outbound_session_id(Session), io:format("[e2e-out] new Megolm session ~s for ~s~n", [SessionId, RoomId]), %% Share the key with room members asynchronously spawn(fun() -> try share_megolm_key(RoomId, Session, State) catch C:R:St -> io:format("[e2e-out] share_megolm_key CRASH ~p:~p~n~p~n", [C, R, St]) end end), Pickle = matrix_megolm:pickle_outbound(Session), Outbound = maps:put(RoomId, {SessionId, Pickle}, State#state.megolm_outbound), {Session, SessionId, State#state{megolm_outbound = Outbound}}; {SessionId, Pickle} -> {ok, Session} = matrix_megolm:unpickle_outbound(Pickle), {Session, SessionId, State} end. %%%=================================================================== %%% NEW: Megolm key sharing via Olm pre-key messages %%%=================================================================== share_megolm_key(RoomId, OutSession, State) -> #state{token = Token, homeserver = HS, user_id = UserId, device_id = Did, account = Acc} = State, SessionKey = matrix_megolm:outbound_session_key(OutSession), SessionId = matrix_megolm:outbound_session_id(OutSession), IdKeys = matrix_olm_session:account_identity_keys(Acc), OurCurve = maps:get(<<"curve25519">>, IdKeys), OurEd = maps:get(<<"ed25519">>, IdKeys), Members = get_room_joined_members(RoomId, Token, HS), OtherMembers = [M || M <- Members, M =/= UserId], case OtherMembers of [] -> io:format("[e2e-out] no other members in ~s, skipping key share~n", [RoomId]); _ -> io:format("[e2e-out] sharing key with ~p member(s) in ~s~n", [length(OtherMembers), RoomId]), DeviceMap = query_device_keys(OtherMembers, Token, HS), ClaimReq = build_claim_request(DeviceMap), OtkMap = claim_one_time_keys(ClaimReq, Token, HS), RoomKeyContent = #{ <<"algorithm">> => <<"m.megolm.v1.aes-sha2">>, <<"room_id">> => RoomId, <<"session_id">> => SessionId, <<"session_key">> => b64u(SessionKey) }, Messages = maps:fold(fun(TargetUserId, UserDevices, MsgAcc) -> maps:fold(fun(DevId, {TheirCurve, TheirEd}, MsgAcc2) -> case DevId =:= Did andalso TargetUserId =:= UserId of true -> MsgAcc2; %% skip ourselves false -> case get_device_otk(OtkMap, TargetUserId, DevId) of not_found -> io:format("[e2e-out] no OTK for ~s/~s — skip~n", [TargetUserId, DevId]), MsgAcc2; {ok, OtkB64} -> Inner = #{ <<"type">> => <<"m.room_key">>, <<"content">> => RoomKeyContent, <<"sender">> => UserId, <<"recipient">> => TargetUserId, <<"keys">> => #{<<"ed25519">> => OurEd}, <<"recipient_keys">> => #{<<"ed25519">> => TheirEd} }, Plaintext = iolist_to_binary(json:encode(Inner)), case matrix_olm_session:create_olm_prekey_message( Acc, TheirCurve, OtkB64, Plaintext) of {ok, PrekeyMsg} -> EncEvent = #{ <<"algorithm">> => <<"m.olm.v1.curve25519-aes-sha2">>, <<"sender_key">> => OurCurve, <<"ciphertext">> => #{ TheirCurve => #{ <<"type">> => 0, <<"body">> => base64:encode(PrekeyMsg) } } }, UserMsgs = maps:get(TargetUserId, MsgAcc2, #{}), maps:put(TargetUserId, maps:put(DevId, EncEvent, UserMsgs), MsgAcc2); {error, E} -> io:format("[e2e-out] Olm encrypt failed ~s/~s: ~p~n", [TargetUserId, DevId, E]), MsgAcc2 end end end end, MsgAcc, UserDevices) end, #{}, DeviceMap), case map_size(Messages) > 0 of false -> io:format("[e2e-out] no messages to send for key share~n"); true -> TxnId = integer_to_list(erlang:system_time(millisecond)), Path = "/_matrix/client/v3/sendToDevice/m.room.encrypted/" ++ TxnId, io:format("[e2e-out] sending room key to ~p user(s): ~p~n", [map_size(Messages), maps:keys(Messages)]), case matrix_http:put(HS, Path, Token, #{<<"messages">> => Messages}) of {ok, Resp} -> io:format("[e2e-out] room key shared OK resp=~s~n", [Resp]); Err -> io:format("[e2e-out] room key share FAILED: ~p~n", [Err]) end end end. %%%=================================================================== %%% NEW: HTTP helpers for key sharing %%%=================================================================== get_room_joined_members(RoomId, Token, HS) -> Path = "/_matrix/client/v3/rooms/" ++ uri_string:quote(binary_to_list(RoomId)) ++ "/members", case matrix_http:get(HS, Path, Token) of {ok, Body} -> Data = json:decode(Body), Chunk = maps:get(<<"chunk">>, Data, []), [maps:get(<<"state_key">>, E, <<>>) || E <- Chunk, maps:get(<<"type">>, E, <<>>) =:= <<"m.room.member">>, maps:get(<<"membership">>, maps:get(<<"content">>, E, #{}), <<>>) =:= <<"join">>]; Err -> io:format("[e2e-out] get members failed: ~p~n", [Err]), [] end. %% Returns #{UserId => #{DevId => {CurveKeyB64, EdKeyB64}}} query_device_keys(UserIds, Token, HS) -> DevKeys = maps:from_list([{U, []} || U <- UserIds]), case matrix_http:post(HS, "/_matrix/client/v3/keys/query", Token, #{<<"device_keys">> => DevKeys}) of {ok, Body} -> AllDevKeys = maps:get(<<"device_keys">>, json:decode(Body), #{}), maps:fold(fun(UserId, UserDevs, Acc) -> DevMap = maps:fold(fun(DevId, DevData, DA) -> Keys = maps:get(<<"keys">>, DevData, #{}), CurveKey = maps:get(<<"curve25519:", DevId/binary>>, Keys, undefined), EdKey = maps:get(<<"ed25519:", DevId/binary>>, Keys, undefined), case {CurveKey, EdKey} of {C, E} when C =/= undefined, E =/= undefined -> maps:put(DevId, {C, E}, DA); _ -> DA end end, #{}, UserDevs), case map_size(DevMap) > 0 of true -> maps:put(UserId, DevMap, Acc); false -> Acc end end, #{}, AllDevKeys); Err -> io:format("[e2e-out] keys/query failed: ~p~n", [Err]), #{} end. build_claim_request(DeviceMap) -> maps:fold(fun(UserId, UserDevs, Acc) -> DevClaims = maps:fold(fun(DevId, _, DA) -> maps:put(DevId, <<"curve25519">>, DA) end, #{}, UserDevs), maps:put(UserId, DevClaims, Acc) end, #{}, DeviceMap). claim_one_time_keys(ClaimReq, Token, HS) -> case matrix_http:post(HS, "/_matrix/client/v3/keys/claim", Token, #{<<"one_time_keys">> => ClaimReq}) of {ok, Body} -> maps:get(<<"one_time_keys">>, json:decode(Body), #{}); Err -> io:format("[e2e-out] keys/claim failed: ~p~n", [Err]), #{} end. get_device_otk(OtkMap, UserId, DevId) -> case maps:get(UserId, OtkMap, undefined) of undefined -> not_found; UserOtks -> case maps:get(DevId, UserOtks, undefined) of undefined -> not_found; DevOtks -> %% DevOtks = #{"curve25519:KEYID" => Val} %% Val is either a bare base64 binary (unsigned OTK) %% or a map #{"key" => base64, "signatures" => ...} (signed OTK). %% We pass the raw value to create_olm_prekey_message which %% handles both via extract_otk_bytes/1. case maps:values(DevOtks) of [] -> not_found; [V | _] -> io:format("[e2e-out] claimed OTK for ~s/~s type=~s~n", [UserId, DevId, case is_map(V) of true -> <<"signed">>; false -> <<"bare">> end]), {ok, V} end end end. %%%=================================================================== %%% State persistence %%%=================================================================== save_state(State) -> file:write_file(?STATE_FILE, term_to_binary(State#state{token = <<>>})). load_state(Token, Hostname) -> case file:read_file(?STATE_FILE) of {ok, Bin} -> try S = migrate_state(binary_to_term(Bin)), io:format("E2E: loaded state (device_id=~s)~n", [S#state.device_id]), S#state{token = Token, homeserver = Hostname} catch Cls:Err -> io:format("E2E: corrupt state (~p:~p), starting fresh~n", [Cls, Err]), file:rename(?STATE_FILE, ?STATE_FILE ++ ".bak"), fresh_state(Token, Hostname) end; _ -> io:format("E2E: no state file, creating fresh account~n"), fresh_state(Token, Hostname) end. migrate_state(S) when is_record(S, state) -> Fields = record_info(fields, state), Indices = lists:zip(Fields, lists:seq(2, length(Fields) + 1)), Default = #state{}, lists:foldl(fun({_Field, Idx}, Acc) -> case element(Idx, S) of undefined -> setelement(Idx, Acc, element(Idx, Default)); _ -> Acc end end, S, Indices); migrate_state(_) -> throw(unknown_record). fresh_state(Token, Hostname) -> {ok, Acc0} = matrix_olm_session:create_account(), Acc1 = matrix_olm_session:account_generate_otks(Acc0, ?OTK_TARGET), MasterKey = crypto:generate_key(eddsa, ed25519), SelfKey = crypto:generate_key(eddsa, ed25519), UserKey = crypto:generate_key(eddsa, ed25519), #state{ account = Acc1, device_id = generate_device_id(), token = Token, homeserver = Hostname, cross_signing_master = MasterKey, cross_signing_self = SelfKey, cross_signing_user = UserKey, cross_signing_uploaded = false }. %%%=================================================================== %%% Key Upload %%%=================================================================== maybe_upload_keys(State = #state{token = Token, homeserver = HS, account = Acc, device_id = Did, user_id = UserId}) -> OtkCount = fetch_otk_count(HS, Token, Did), io:format("E2E: server has ~p OTK(s)~n", [OtkCount]), Acc2 = case OtkCount < ?OTK_TARGET div 2 of true -> ToGen = ?OTK_TARGET - OtkCount, io:format("E2E: generating ~p new OTKs~n", [ToGen]), matrix_olm_session:account_generate_otks(Acc, ToGen); false -> Acc end, upload_keys(HS, Token, Did, UserId, Acc2), Acc3 = matrix_olm_session:account_mark_otks_published(Acc2), State1 = State#state{account = Acc3}, State2 = maybe_upload_cross_signing(State1), save_state(State2), State2. upload_keys(_HS, _Token, _Did, undefined, _Acc) -> io:format("E2E: skipping key upload, user_id not yet known~n"); upload_keys(HS, Token, Did, UserId, Acc) -> IdKeys = matrix_olm_session:account_identity_keys(Acc), OtkMap = matrix_olm_session:account_one_time_keys(Acc), Ed = maps:get(<<"ed25519">>, IdKeys), Curve = maps:get(<<"curve25519">>, IdKeys), DeviceKeys0 = #{ <<"algorithms">> => [<<"m.olm.v1.curve25519-aes-sha2">>, <<"m.megolm.v1.aes-sha2">>], <<"device_id">> => Did, <<"user_id">> => UserId, <<"keys">> => #{ <<"curve25519:", Did/binary>> => Curve, <<"ed25519:", Did/binary>> => Ed } }, Sig = sign_json(DeviceKeys0, Acc), SigKey = <<"ed25519:", Did/binary>>, DeviceKeys = DeviceKeys0#{ <<"signatures">> => #{UserId => #{SigKey => Sig}} }, Body = #{ <<"device_keys">> => DeviceKeys, <<"one_time_keys">> => OtkMap }, case matrix_http:post(HS, "/_matrix/client/v3/keys/upload", Token, Body) of {ok, _} -> io:format("E2E: keys uploaded (~p OTK)~n", [map_size(OtkMap)]); Err -> io:format("E2E: key upload error ~p~n", [Err]) end. fetch_otk_count(HS, Token, _Did) -> case matrix_http:post(HS, "/_matrix/client/v3/keys/upload", Token, #{}) of {ok, Body} -> Data = json:decode(Body), maps:get(<<"curve25519">>, maps:get(<<"one_time_key_counts">>, Data, #{}), 0); _ -> 0 end. %%%=================================================================== %%% Cross-signing Key Upload %%%=================================================================== maybe_upload_cross_signing(State = #state{cross_signing_uploaded = true}) -> State; maybe_upload_cross_signing(State = #state{ token = Token, homeserver = HS, user_id = UserId}) when UserId =/= undefined -> ServerHasMasterKey = case matrix_http:post(HS, "/_matrix/client/v3/keys/query", Token, #{<<"device_keys">> => #{UserId => []}}) of {ok, Body} -> Data = json:decode(Body), MasterKeys = maps:get(<<"master_keys">>, Data, #{}), UserMaster = maps:get(UserId, MasterKeys, #{}), Keys = maps:get(<<"keys">>, UserMaster, #{}), map_size(Keys) > 0; _ -> false end, case ServerHasMasterKey of true -> io:format("E2E: server already has master key, skipping cross-signing upload~n"), try_sign_device_with_local_key(State); false -> do_upload_cross_signing(State) end; maybe_upload_cross_signing(State) -> State. try_sign_device_with_local_key(State = #state{ token = Token, homeserver = HS, device_id = Did, user_id = UserId, account = Acc, cross_signing_self = {SelfPub, SelfPriv}}) -> SelfKeyId = <<"ed25519:", (b64u(SelfPub))/binary>>, upload_device_self_signature(HS, Token, Did, UserId, Acc, SelfPriv, SelfKeyId), State#state{cross_signing_uploaded = true}; try_sign_device_with_local_key(State) -> State#state{cross_signing_uploaded = true}. do_upload_cross_signing(State = #state{ token = Token, homeserver = HS, device_id = Did, user_id = UserId, account = Acc, cross_signing_master = {MasterPub, MasterPriv}, cross_signing_self = {SelfPub, SelfPriv}, cross_signing_user = {UserPub, _UserPriv}}) -> MasterPubB64 = b64u(MasterPub), SelfPubB64 = b64u(SelfPub), UserPubB64 = b64u(UserPub), MasterKeyId = <<"ed25519:", MasterPubB64/binary>>, SelfKeyId = <<"ed25519:", SelfPubB64/binary>>, UserKeyId = <<"ed25519:", UserPubB64/binary>>, MasterObj0 = #{<<"user_id">> => UserId, <<"usage">> => [<<"master">>], <<"keys">> => #{MasterKeyId => MasterPubB64}}, MasterObj = MasterObj0#{<<"signatures">> => #{UserId => #{ <<"ed25519:", Did/binary>> => sign_json_raw(MasterObj0, Acc)}}}, SelfObj0 = #{<<"user_id">> => UserId, <<"usage">> => [<<"self_signing">>], <<"keys">> => #{SelfKeyId => SelfPubB64}}, SelfObj = SelfObj0#{<<"signatures">> => #{UserId => #{ MasterKeyId => sign_json_ed25519(SelfObj0, MasterPriv)}}}, UserObj0 = #{<<"user_id">> => UserId, <<"usage">> => [<<"user_signing">>], <<"keys">> => #{UserKeyId => UserPubB64}}, UserObj = UserObj0#{<<"signatures">> => #{UserId => #{ MasterKeyId => sign_json_ed25519(UserObj0, MasterPriv)}}}, Payload = #{<<"master_key">> => MasterObj, <<"self_signing_key">> => SelfObj, <<"user_signing_key">> => UserObj}, io:format("E2E: uploading cross-signing keys~n"), CrossSigningPath = "/_matrix/client/v3/keys/device_signing/upload", Result = case matrix_http:post(HS, CrossSigningPath, Token, Payload) of {ok, _} = Ok -> Ok; {error, {status, 401, UiaBody}} -> uia_retry(HS, CrossSigningPath, Token, Payload, UserId, UiaBody); {error, {http_error, 401, UiaBody}} -> uia_retry(HS, CrossSigningPath, Token, Payload, UserId, UiaBody); Other -> Other end, case Result of {ok, _} -> io:format("E2E: cross-signing keys uploaded OK~n"), upload_device_self_signature(HS, Token, Did, UserId, Acc, SelfPriv, SelfKeyId), State#state{cross_signing_uploaded = true}; uia_no_password -> io:format("E2E: cross-signing needs UIA — set MATRIX_BOT_PASSWORD env var~n"), State; Err -> io:format("E2E: cross-signing upload failed: ~p~n", [Err]), State end. uia_retry(HS, Path, Token, Payload, UserId, UiaBody) -> case os:getenv("MATRIX_BOT_PASSWORD") of false -> uia_no_password; Password -> UiaData = json:decode(UiaBody), SessionId = maps:get(<<"session">>, UiaData, <<>>), AuthPayload = Payload#{<<"auth">> => #{ <<"type">> => <<"m.login.password">>, <<"session">> => SessionId, <<"password">> => list_to_binary(Password), <<"identifier">> => #{ <<"type">> => <<"m.id.user">>, <<"user">> => UserId } }}, io:format("E2E: retrying with UIA (session=~s)~n", [SessionId]), matrix_http:post(HS, Path, Token, AuthPayload) end. upload_device_self_signature(HS, Token, Did, UserId, Acc, SelfPriv, SelfKeyId) -> IdKeys = matrix_olm_session:account_identity_keys(Acc), Ed = maps:get(<<"ed25519">>, IdKeys), Curve = maps:get(<<"curve25519">>, IdKeys), DevObj0 = #{<<"algorithms">> => [<<"m.olm.v1.curve25519-aes-sha2">>, <<"m.megolm.v1.aes-sha2">>], <<"device_id">> => Did, <<"user_id">> => UserId, <<"keys">> => #{<<"curve25519:", Did/binary>> => Curve, <<"ed25519:", Did/binary>> => Ed}}, DevObj = DevObj0#{<<"signatures">> => #{UserId => #{ <<"ed25519:", Did/binary>> => sign_json_raw(DevObj0, Acc), SelfKeyId => sign_json_ed25519(DevObj0, SelfPriv)}}}, case matrix_http:post(HS, "/_matrix/client/v3/keys/signatures/upload", Token, #{UserId => #{Did => DevObj}}) of {ok, _} -> io:format("E2E: device self-signature uploaded OK~n"); Err -> io:format("E2E: device self-signature upload failed: ~p~n", [Err]) end. %%%=================================================================== %%% To-device event dispatcher %%%=================================================================== process_to_device(Event, State) -> Type = maps:get(<<"type">>, Event, <<>>), Content = maps:get(<<"content">>, Event, #{}), Sender = maps:get(<<"sender">>, Event, <<>>), io:format("E2E: to-device type=~s from=~s~n", [Type, Sender]), case Type of <<"m.room.encrypted">> -> case maps:get(<<"algorithm">>, Content, <<>>) of <<"m.olm.v1.curve25519-aes-sha2">> -> handle_olm_to_device(Sender, Content, State); Algo -> io:format("E2E: unknown to-device algo ~s~n", [Algo]), State end; <<"m.key.verification.request">> -> handle_verif_request(Sender, Content, State); <<"m.key.verification.start">> -> handle_verif_start(Sender, Content, State); <<"m.key.verification.key">> -> handle_verif_key(Sender, Content, State); <<"m.key.verification.mac">> -> handle_verif_mac(Sender, Content, State); <<"m.key.verification.cancel">> -> TxnId = maps:get(<<"transaction_id">>, Content, <<>>), Reason = maps:get(<<"reason">>, Content, <<"unknown">>), io:format("E2E: verification cancelled txn=~s reason=~s~n", [TxnId, Reason]), State#state{verifications = maps:remove(TxnId, State#state.verifications)}; _ -> State end. %%%=================================================================== %%% SAS Verification %%%=================================================================== handle_verif_request(Sender, Content, State) -> TxnId = maps:get(<<"transaction_id">>, Content, gen_request_id()), FromDevice = maps:get(<<"from_device">>, Content, <<>>), io:format("E2E: SAS request from ~s/~s txn=~s~n", [Sender, FromDevice, TxnId]), V = #verif{txn_id=TxnId, their_user_id=Sender, their_device_id=FromDevice}, ReadyMsg = #{<<"from_device">> => State#state.device_id, <<"methods">> => [<<"m.sas.v1">>], <<"transaction_id">> => TxnId}, send_to_device(State, <<"m.key.verification.ready">>, Sender, FromDevice, ReadyMsg), State#state{verifications = maps:put(TxnId, V, State#state.verifications)}. handle_verif_start(Sender, Content, State) -> TxnId = maps:get(<<"transaction_id">>, Content, <<>>), FromDevice = maps:get(<<"from_device">>, Content, <<>>), io:format("E2E: SAS start from ~s/~s txn=~s~n", [Sender, FromDevice, TxnId]), {EphPub, EphPriv} = crypto:generate_key(ecdh, x25519), BaseVerif = case maps:get(TxnId, State#state.verifications, undefined) of undefined -> #verif{txn_id=TxnId, their_user_id=Sender, their_device_id=FromDevice}; Existing -> Existing end, V = BaseVerif#verif{their_device_id=FromDevice, our_ephem_pub=EphPub, our_ephem_priv=EphPriv, start_content=Content}, Commitment = sas_commitment(EphPub, Content), AcceptMsg = #{ <<"transaction_id">> => TxnId, <<"method">> => <<"m.sas.v1">>, <<"key_agreement_protocol">> => <<"curve25519-hkdf-sha256">>, <<"hash">> => <<"sha256">>, <<"message_authentication_code">> => <<"hkdf-hmac-sha256.v2">>, <<"short_authentication_string">> => [<<"emoji">>], <<"commitment">> => Commitment }, send_to_device(State, <<"m.key.verification.accept">>, Sender, FromDevice, AcceptMsg), send_to_device(State, <<"m.key.verification.key">>, Sender, FromDevice, #{<<"transaction_id">> => TxnId, <<"key">> => b64u(EphPub)}), State#state{verifications = maps:put(TxnId, V, State#state.verifications)}. handle_verif_key(_Sender, Content, State) -> TxnId = maps:get(<<"transaction_id">>, Content, <<>>), TheirKeyB64 = maps:get(<<"key">>, Content, <<>>), io:format("E2E: SAS key received txn=~s~n", [TxnId]), case maps:get(TxnId, State#state.verifications, undefined) of undefined -> io:format("E2E: SAS key for unknown txn ~s~n", [TxnId]), State; V -> TheirKey = base64:decode(pad_b64(TheirKeyB64)), SharedSecret = crypto:compute_key(ecdh, TheirKey, V#verif.our_ephem_priv, x25519), V2 = V#verif{their_ephem_pub=TheirKey, shared_secret=SharedSecret}, log_sas(SharedSecret, V2, State), State2 = State#state{verifications = maps:put(TxnId, V2, State#state.verifications)}, send_verification_mac(TxnId, V2, State2) end. handle_verif_mac(Sender, Content, State) -> TxnId = maps:get(<<"transaction_id">>, Content, <<>>), io:format("E2E: SAS MAC received from ~s txn=~s~n", [Sender, TxnId]), case maps:get(TxnId, State#state.verifications, undefined) of undefined -> io:format("E2E: SAS mac for unknown txn ~s~n", [TxnId]), State; V -> send_to_device(State, <<"m.key.verification.done">>, V#verif.their_user_id, V#verif.their_device_id, #{<<"transaction_id">> => TxnId}), io:format("E2E: SAS verification COMPLETE with ~s/~s~n", [V#verif.their_user_id, V#verif.their_device_id]), State#state{verifications = maps:remove(TxnId, State#state.verifications)} end. send_verification_mac(TxnId, V, State) -> #state{user_id=OurUid, device_id=OurDid, account=Acc} = State, TheirUid = V#verif.their_user_id, TheirDid = V#verif.their_device_id, SharedSecret = V#verif.shared_secret, IdKeys = matrix_olm_session:account_identity_keys(Acc), Ed25519 = maps:get(<<"ed25519">>, IdKeys), KeyId = <<"ed25519:", OurDid/binary>>, KeyMac = sas_mac(SharedSecret, OurUid, OurDid, TheirUid, TheirDid, TxnId, KeyId, Ed25519), KeyIdsMac = sas_mac(SharedSecret, OurUid, OurDid, TheirUid, TheirDid, TxnId, <<"KEY_IDS">>, KeyId), send_to_device(State, <<"m.key.verification.mac">>, TheirUid, TheirDid, #{ <<"transaction_id">> => TxnId, <<"mac">> => #{KeyId => KeyMac}, <<"keys">> => KeyIdsMac }), State. log_sas(SharedSecret, V, #state{user_id=OurUid, device_id=OurDid}) -> TheirPubB64 = b64u(V#verif.their_ephem_pub), OurPubB64 = b64u(V#verif.our_ephem_pub), Info = iolist_to_binary([ <<"MATRIX_KEY_VERIFICATION_SAS|">>, V#verif.their_user_id, <<"|">>, V#verif.their_device_id, <<"|">>, TheirPubB64, <<"|">>, OurUid, <<"|">>, OurDid, <<"|">>, OurPubB64, <<"|">>, V#verif.txn_id ]), <> = hkdf_sha256(SharedSecret, <<>>, Info, 6), io:format("E2E: SAS emoji indices (auto-accepting): ~p ~p ~p ~p~n", [B0 rem 64, B1 rem 64, B2 rem 64, B3 rem 64]). %%%=================================================================== %%% Olm / Megolm inbound %%%=================================================================== handle_olm_to_device(Sender, Content, State) -> IdKeys = matrix_olm_session:account_identity_keys(State#state.account), MyCurve = maps:get(<<"curve25519">>, IdKeys), Ciphertexts = maps:get(<<"ciphertext">>, Content, #{}), SenderKey = maps:get(<<"sender_key">>, Content, <<>>), case maps:get(MyCurve, Ciphertexts, undefined) of undefined -> io:format("E2E: to-device message not addressed to us~n"), State; #{<<"type">> := MsgType, <<"body">> := Body} -> CT = to_bin(Body), OlmSessKey = {SenderKey, Sender}, decrypt_olm_message(MsgType, CT, OlmSessKey, SenderKey, State) end. decrypt_olm_message(0, CT, OlmSessKey, SenderKey, State) -> case matrix_olm_session:create_inbound(State#state.account, SenderKey, CT) of {ok, Plain, Session, Acc2} -> Pkl = matrix_olm_session:pickle_session(Session), Pkls = maps:put(OlmSessKey, Pkl, State#state.olm_sessions), State2 = State#state{account = Acc2, olm_sessions = Pkls}, process_inner_event(json:decode(Plain), SenderKey, State2); Err -> io:format("E2E: inbound session creation failed ~p~n", [Err]), State end; decrypt_olm_message(1, CT, OlmSessKey, SenderKey, State) -> case maps:get(OlmSessKey, State#state.olm_sessions, undefined) of undefined -> io:format("E2E: no Olm session for sender ~p~n", [SenderKey]), State; Pkl -> {ok, Session} = matrix_olm_session:unpickle_session(Pkl), case matrix_olm_session:decrypt(Session, 1, CT) of {ok, Plain, Session2} -> Pkls = maps:put(OlmSessKey, matrix_olm_session:pickle_session(Session2), State#state.olm_sessions), process_inner_event(json:decode(Plain), SenderKey, State#state{olm_sessions = Pkls}); Err -> io:format("E2E: Olm decrypt error ~p~n", [Err]), State end end; decrypt_olm_message(T, _, _, _, State) -> io:format("E2E: unknown Olm message type ~p~n", [T]), State. process_inner_event(Inner, SenderKey, State) -> Type = maps:get(<<"type">>, Inner, <<>>), Content = maps:get(<<"content">>, Inner, #{}), case Type of <<"m.room_key">> -> store_megolm_key(Content, SenderKey, State); <<"m.forwarded_room_key">> -> io:format("E2E: received forwarded room key~n"), store_megolm_key(Content, SenderKey, State); _ -> io:format("E2E: unhandled inner event type: ~s~n", [Type]), State end. store_megolm_key(Content, SenderKey, State) -> case maps:get(<<"algorithm">>, Content, <<>>) of <<"m.megolm.v1.aes-sha2">> -> SessionId = maps:get(<<"session_id">>, Content, <<>>), MegolmKey = maps:get(<<"session_key">>, Content, <<>>), RoomId = maps:get(<<"room_id">>, Content, <<>>), io:format("E2E: received room key ~s / ~s~n", [RoomId, SessionId]), RawKey = b64d(to_bin(MegolmKey)), case matrix_megolm:init_inbound(RawKey) of {ok, MgmSession} -> K = {RoomId, SessionId, SenderKey}, Ms = maps:put(K, matrix_megolm:pickle(MgmSession), State#state.megolm_sessions), State#state{megolm_sessions = Ms}; Err -> io:format("E2E: Megolm session init error ~p~n", [Err]), State end; Algo -> io:format("E2E: unknown room key algorithm: ~s~n", [Algo]), State end. %%%=================================================================== %%% Room event decryption %%%=================================================================== do_decrypt_room_event(Event, State) -> Content = maps:get(<<"content">>, Event, #{}), RoomId = maps:get(<<"room_id">>, Event, <<>>), SenderKey = maps:get(<<"sender_key">>, Content, <<>>), SessionId = maps:get(<<"session_id">>, Content, <<>>), Ciphertext = maps:get(<<"ciphertext">>, Content, <<>>), Algo = maps:get(<<"algorithm">>, Content, <<>>), case Algo of <<"m.megolm.v1.aes-sha2">> -> %% Try exact match first, then fall back to sender_key-agnostic lookup. %% The sender_key in the event may differ from the one stored at backup %% import time (e.g. forwarded key, base64 variant, different device). ExactKey = {RoomId, SessionId, SenderKey}, {FoundKey, FoundPickle} = case maps:get(ExactKey, State#state.megolm_sessions, undefined) of undefined -> find_session_by_room_and_id( RoomId, SessionId, State#state.megolm_sessions); ExactPickle -> {ExactKey, ExactPickle} end, case FoundPickle of undefined -> io:format("E2E: no Megolm session for ~s / ~s (sender_key=~s)~n", [RoomId, SessionId, SenderKey]), case os:getenv("MATRIX_BACKUP_KEY") of false -> ok; RKey -> erlang:send_after(5000, self(), {fetch_backup_keys, list_to_binary(RKey)}) end, {{error, no_session}, State}; FoundPickle -> {ok, MgmSession} = matrix_megolm:unpickle(FoundPickle), CT = b64d(Ciphertext), case matrix_megolm:decrypt(MgmSession, CT) of {ok, {Plain, _Idx, MgmSession2}} -> Ms2 = maps:put(FoundKey, matrix_megolm:pickle(MgmSession2), State#state.megolm_sessions), {{ok, json:decode(Plain)}, State#state{megolm_sessions = Ms2}}; Err -> io:format("E2E: Megolm decrypt error ~p~n", [Err]), {Err, State} end end; _ -> {{error, {unknown_algo, Algo}}, State} end. %% Scan megolm_sessions for any key matching {RoomId, SessionId, _AnySenderKey}. -spec find_session_by_room_and_id(binary(), binary(), map()) -> {tuple(), binary()} | {undefined, undefined}. find_session_by_room_and_id(RoomId, SessionId, Sessions) -> maps:fold(fun (K = {R, S, _SK}, Pkl, {undefined, undefined}) when R =:= RoomId, S =:= SessionId -> io:format("E2E: session found via fallback lookup (stored_key =/= event_key)~n"), {K, Pkl}; (_, _, Acc) -> Acc end, {undefined, undefined}, Sessions). %%%=================================================================== %%% Room key request %%%=================================================================== do_request_room_key(RoomId, SessionId, SenderKey, #state{token=Token, homeserver=HS, device_id=Did}) -> io:format("E2E: requesting room key for ~s / ~s~n", [RoomId, SessionId]), MembersPath = "/_matrix/client/v3/rooms/" ++ uri_string:quote(binary_to_list(RoomId)) ++ "/members", Members = case matrix_http:get(HS, MembersPath, Token) of {ok, Body} -> Data = json:decode(Body), Chunk = maps:get(<<"chunk">>, Data, []), [maps:get(<<"state_key">>, E, <<>>) || E <- Chunk, maps:get(<<"type">>, E, <<>>) =:= <<"m.room.member">>, maps:get(<<"membership">>, maps:get(<<"content">>, E, #{}), <<>>) =:= <<"join">>]; Err -> io:format("E2E: failed to get room members: ~p~n", [Err]), [] end, Messages = lists:foldl(fun(UserId, Acc) -> DevBody = #{<<"device_keys">> => #{UserId => []}}, case matrix_http:post(HS, "/_matrix/client/v3/keys/query", Token, DevBody) of {ok, DevResp} -> DevData = json:decode(DevResp), UserDevMap = maps:get(UserId, maps:get(<<"device_keys">>, DevData, #{}), #{}), DevMsgs = maps:from_list([ {DevId, #{<<"action">> => <<"request">>, <<"body">> => #{ <<"algorithm">> => <<"m.megolm.v1.aes-sha2">>, <<"room_id">> => RoomId, <<"sender_key">> => SenderKey, <<"session_id">> => SessionId}, <<"request_id">> => gen_request_id(), <<"requesting_device_id">> => Did}} || DevId <- maps:keys(UserDevMap), DevId =/= Did ]), maps:put(UserId, DevMsgs, Acc); _ -> Acc end end, #{}, Members), case map_size(Messages) > 0 of false -> io:format("E2E: no devices found to request key from~n"); true -> TxnId = integer_to_list(erlang:system_time(millisecond)), SendPath = "/_matrix/client/v3/sendToDevice/m.room_key_request/" ++ TxnId, case matrix_http:put(HS, SendPath, Token, #{<<"messages">> => Messages}) of {ok, _} -> io:format("E2E: room key request sent to ~p user(s)~n", [map_size(Messages)]); Err1 -> io:format("E2E: room key request failed: ~p~n", [Err1]) end end. %%%=================================================================== %%% SAS crypto %%%=================================================================== sas_commitment(OurEphPub, StartContent) -> PubB64 = b64u(OurEphPub), Hash = crypto:hash(sha256, <>), b64u(Hash). sas_mac(Secret, SenderUid, SenderDid, RecvUid, RecvDid, TxnId, KeyId, Value) -> Info = iolist_to_binary([<<"MATRIX_KEY_VERIFICATION_MAC|">>, SenderUid, <<"|">>, SenderDid, <<"|">>, RecvUid, <<"|">>, RecvDid, <<"|">>, TxnId, <<"|">>, KeyId]), MacKey = hkdf_sha256(Secret, <<>>, Info, 32), b64u(crypto:mac(hmac, sha256, MacKey, Value)). hkdf_sha256(IKM, Salt0, Info, Length) -> Salt = case byte_size(Salt0) of 0 -> binary:copy(<<0>>, 32); _ -> Salt0 end, PRK = crypto:mac(hmac, sha256, Salt, IKM), hkdf_expand(PRK, Info, 1, <<>>, <<>>, Length). hkdf_expand(_PRK, _Info, _I, _Prev, OKM, Length) when byte_size(OKM) >= Length -> binary:part(OKM, 0, Length); hkdf_expand(PRK, Info, I, Prev, OKM, Length) -> T = crypto:mac(hmac, sha256, PRK, <>), hkdf_expand(PRK, Info, I+1, T, <>, Length). %%%=================================================================== %%% Generic to-device sender %%%=================================================================== send_to_device(#state{token=Token, homeserver=HS}, Type, ToUser, ToDevice, Content) -> TxnId = integer_to_list(erlang:system_time(millisecond)), Path = "/_matrix/client/v3/sendToDevice/" ++ binary_to_list(Type) ++ "/" ++ TxnId, Payload = #{<<"messages">> => #{ToUser => #{ToDevice => Content}}}, case matrix_http:put(HS, Path, Token, Payload) of {ok, _} -> ok; Err -> io:format("E2E: send_to_device ~s failed: ~p~n", [Type, Err]) end. %%%=================================================================== %%% Utilities %%%=================================================================== extract_hostname("https://" ++ R) -> hd(string:split(R, "/")); extract_hostname("http://" ++ R) -> hd(string:split(R, "/")); extract_hostname(O) -> O. to_bin(B) when is_binary(B) -> B; to_bin(L) when is_list(L) -> list_to_binary(L). b64d(B) -> base64:decode(pad_b64(B)). generate_device_id() -> {ok, Bytes} = keylara:get_entropy_bytes(10), Chars = <<"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789">>, << <<(binary:at(Chars, B rem 36))>> || <> <= Bytes >>. gen_request_id() -> list_to_binary("req_" ++ integer_to_list(erlang:system_time(microsecond))). sign_json_raw(Map, Account) -> {_EdPub, EdPriv} = matrix_olm_session:account_ed25519_keypair(Account), b64u(crypto:sign(eddsa, none, canonical_json(Map), [EdPriv, ed25519])). sign_json_ed25519(Map, PrivKey) -> b64u(crypto:sign(eddsa, none, canonical_json(Map), [PrivKey, ed25519])). b64u(Bin) -> B = base64:encode(Bin), binary:part(B, 0, byte_size(B) - count_padding(B)). count_padding(B) -> case binary:last(B) of $= -> 1 + count_padding(binary:part(B, 0, byte_size(B)-1)); _ -> 0 end. sign_json(Map, Account) -> sign_json_raw(Map, Account). pkcs7_unpad(Bin) -> PadLen = binary:last(Bin), binary:part(Bin, 0, byte_size(Bin) - PadLen). canonical_json(Map) when is_map(Map) -> Pairs = lists:sort(maps:to_list(Map)), Inner = lists:join(",", [ [<<"\"">>, canonical_key(K), <<"\":">>, canonical_json(V)] || {K, V} <- Pairs ]), iolist_to_binary(["{", Inner, "}"]); canonical_json(List) when is_list(List) -> Inner = lists:join(",", [canonical_json(V) || V <- List]), iolist_to_binary(["[", Inner, "]"]); canonical_json(B) when is_binary(B) -> iolist_to_binary(["\"", B, "\""]); canonical_json(N) when is_integer(N) -> integer_to_binary(N); canonical_json(true) -> <<"true">>; canonical_json(false) -> <<"false">>; canonical_json(null) -> <<"null">>; canonical_json(undefined) -> <<"null">>. canonical_key(K) when is_binary(K) -> K; canonical_key(K) when is_atom(K) -> atom_to_binary(K). %%%=================================================================== %%% Key Backup %%%=================================================================== -spec fetch_backup_keys(binary() | string()) -> ok | {error, term()}. fetch_backup_keys(RecoveryKeyStr) -> gen_server:call(?MODULE, {fetch_backup_keys, RecoveryKeyStr}, 60000). do_fetch_backup_keys(RecoveryKeyStr, State = #state{token=Token, homeserver=HS, user_id=UserId}) -> case decode_recovery_key(RecoveryKeyStr) of {error, E} -> io:format("E2E: bad recovery key: ~p~n", [E]), {{error, bad_recovery_key}, State}; {ok, RecoveryPrivBytes} -> AccDataPath = "/_matrix/client/v3/user/" ++ uri_string:quote(binary_to_list(UserId)) ++ "/account_data/m.secret_storage.default_key", case matrix_http:get(HS, AccDataPath, Token) of {error, E2} -> io:format("E2E: could not get default key id: ~p~n", [E2]), {{error, E2}, State}; {ok, DKBody} -> KeyId = maps:get(<<"key">>, json:decode(DKBody), <<>>), io:format("E2E: secret storage key_id=~s~n", [KeyId]), SecretPath = "/_matrix/client/v3/user/" ++ uri_string:quote(binary_to_list(UserId)) ++ "/account_data/m.megolm_backup.v1", case matrix_http:get(HS, SecretPath, Token) of {error, E3} -> io:format("E2E: could not get backup secret: ~p~n", [E3]), {{error, E3}, State}; {ok, SecBody} -> SecData = json:decode(SecBody), Encrypted = maps:get(<<"encrypted">>, SecData, #{}), KeyEntry = maps:get(KeyId, Encrypted, #{}), case decrypt_secret_storage(KeyEntry, KeyId, RecoveryPrivBytes) of {error, E4} -> io:format("E2E: secret decryption failed: ~p~n", [E4]), {{error, E4}, State}; {ok, BackupKeyB64} -> BackupPrivKey = base64:decode(pad_b64(BackupKeyB64)), io:format("E2E: backup key decrypted OK (~p bytes)~n", [byte_size(BackupPrivKey)]), fetch_and_import_rooms(BackupPrivKey, Token, HS, State) end end end end. decrypt_secret_storage(Entry, _KeyId, RecoveryPrivBytes) -> try IVB64 = maps:get(<<"iv">>, Entry), CtB64 = maps:get(<<"ciphertext">>, Entry), MacB64 = maps:get(<<"mac">>, Entry), IV = base64:decode(pad_b64(IVB64)), Ct = base64:decode(pad_b64(CtB64)), Mac = base64:decode(pad_b64(MacB64)), Keys = hkdf_sha256(RecoveryPrivBytes, binary:copy(<<0>>, 32), <<"m.megolm_backup.v1">>, 64), AesKey = binary:part(Keys, 0, 32), MacKey = binary:part(Keys, 32, 32), ExpMac = crypto:mac(hmac, sha256, MacKey, Ct), case Mac =:= ExpMac of false -> io:format("E2E: secret storage MAC mismatch~n"), {error, mac_mismatch}; true -> Plain = crypto:crypto_one_time(aes_256_ctr, AesKey, IV, Ct, false), {ok, Plain} end catch Cls:Err -> {error, {Cls, Err}} end. fetch_and_import_rooms(BackupPrivKey, Token, HS, State) -> case matrix_http:get(HS, "/_matrix/client/v3/room_keys/version", Token) of {error, E} -> io:format("E2E: backup version fetch failed: ~p~n", [E]), {{error, E}, State}; {ok, VerBody} -> VerData = json:decode(VerBody), Version = maps:get(<<"version">>, VerData, <<"1">>), AuthData = maps:get(<<"auth_data">>, VerData, #{}), BackupPubB64 = maps:get(<<"public_key">>, AuthData, <<>>), io:format("E2E: backup version=~s pubkey=~s~n", [Version, BackupPubB64]), KeysPath = "/_matrix/client/v3/room_keys/keys?version=" ++ binary_to_list(Version), case matrix_http:get(HS, KeysPath, Token) of {error, E2} -> io:format("E2E: backup download failed: ~p~n", [E2]), {{error, E2}, State}; {ok, KeysBody} -> KeysData = json:decode(KeysBody), Rooms = maps:get(<<"rooms">>, KeysData, #{}), io:format("E2E: decrypting backup for ~p room(s)~n", [map_size(Rooms)]), NewState = import_backup_rooms(Rooms, BackupPrivKey, State), io:format("E2E: backup import complete, ~p sessions total~n", [map_size(NewState#state.megolm_sessions)]), {ok, NewState} end end. import_backup_rooms(Rooms, PrivKey, State) -> maps:fold(fun(RoomId, RoomData, Acc) -> Sessions = maps:get(<<"sessions">>, RoomData, #{}), maps:fold(fun(SessionId, SessionData, Acc2) -> SessionDataInner = maps:get(<<"session_data">>, SessionData, #{}), case decrypt_backup_session(SessionDataInner, PrivKey) of {ok, Plain} -> store_backup_session(RoomId, SessionId, Plain, Acc2); {error, E} -> io:format("E2E: failed to decrypt ~s/~s: ~p~n", [RoomId, SessionId, E]), Acc2 end end, Acc, Sessions) end, State, Rooms). decrypt_backup_session(SessionData, PrivKey) -> try EphB64 = maps:get(<<"ephemeral">>, SessionData), CtB64 = maps:get(<<"ciphertext">>, SessionData), _MacB64 = maps:get(<<"mac">>, SessionData), EphPub = base64:decode(pad_b64(EphB64)), Ct = base64:decode(pad_b64(CtB64)), Shared = crypto:compute_key(ecdh, EphPub, PrivKey, x25519), Keys = hkdf_sha256(Shared, binary:copy(<<0>>, 32), <<>>, 80), AesKey = binary:part(Keys, 0, 32), IV = binary:part(Keys, 64, 16), Plain = crypto:crypto_one_time(aes_256_cbc, AesKey, IV, Ct, false), Unpadded = pkcs7_unpad(Plain), {ok, json:decode(Unpadded)} catch Cls:Err -> {error, {Cls, Err}} end. store_backup_session(RoomId, SessionId, Plain, State) -> SenderKey = maps:get(<<"sender_key">>, Plain, <<>>), SessionKey = maps:get(<<"session_key">>, Plain, <<>>), KeyBin = b64d(to_bin(SessionKey)), KeyV2 = case KeyBin of <<1, Rest/binary>> -> <<2, Rest/binary, 0:512>>; Other -> Other end, case matrix_megolm:init_inbound(KeyV2) of {ok, MgmSession} -> K = {RoomId, SessionId, SenderKey}, Ms = maps:put(K, matrix_megolm:pickle(MgmSession), State#state.megolm_sessions), State#state{megolm_sessions = Ms}; Err -> io:format("E2E: backup session init failed ~s/~s: ~p~n", [RoomId, SessionId, Err]), State end. %%%=================================================================== %%% Recovery key decoding %%%=================================================================== decode_recovery_key(Key) when is_list(Key) -> decode_recovery_key(list_to_binary(Key)); decode_recovery_key(Key) when is_binary(Key) -> Stripped = binary:replace(Key, [<<" ">>, <<"-">>, <<"\n">>], <<>>, [global]), case base58_decode(Stripped) of {error, E} -> {error, E}; Bytes when byte_size(Bytes) =:= 35 -> <<16#8B, 16#01, PrivKey:32/binary, Parity>> = Bytes, Expected = lists:foldl(fun(B, Acc) -> Acc bxor B end, 0, binary_to_list(binary:part(Bytes, 0, 34))), case Expected band 16#FF of Parity -> {ok, PrivKey}; _ -> {error, bad_parity} end; Bytes -> {error, {bad_length, byte_size(Bytes)}} end. base58_decode(B58) -> Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz", try Num = lists:foldl(fun(C, Acc) -> case string:chr(Alphabet, C) of 0 -> throw({bad_char, C}); Idx -> Acc * 58 + (Idx - 1) end end, 0, binary_to_list(B58)), num_to_bytes(Num) catch throw:E -> {error, E} end. num_to_bytes(N) -> Bytes = num_to_bytes_acc(N, <<>>), Len = byte_size(Bytes), case Len < 35 of true -> <<(binary:copy(<<0>>, 35 - Len))/binary, Bytes/binary>>; false -> Bytes end. num_to_bytes_acc(0, <<>>) -> <<0>>; num_to_bytes_acc(0, Acc) -> Acc; num_to_bytes_acc(N, Acc) -> num_to_bytes_acc(N bsr 8, <<(N band 16#FF), Acc/binary>>). pad_b64(B) -> case byte_size(B) rem 4 of 0 -> B; R -> <