%%%=================================================================== %%% matrix_olm_session.erl — Pure Erlang Olm inbound session %%% %%% Added: create_olm_prekey_message/4 — build an Olm type-0 pre-key %%% message that encrypts a plaintext for a remote device. %%% Used to share Megolm room keys. %%%=================================================================== -module(matrix_olm_session). -export([ create_account/0, account_identity_keys/1, account_ed25519_keypair/1, account_one_time_keys/1, account_generate_otks/2, account_mark_otks_published/1, account_remove_otk/2, create_inbound/3, decrypt/3, pickle_account/1, unpickle_account/1, pickle_session/1, unpickle_session/1, %% NEW create_olm_prekey_message/4 ]). -type curve25519_keypair() :: {binary(), binary()}. -record(account, { identity_keypair :: curve25519_keypair(), ed25519_keypair :: {binary(), binary()}, one_time_keys :: #{binary() => curve25519_keypair()}, published_otk_ids :: [binary()] }). -record(olm_session, { root_key :: binary(), recv_chain :: binary(), dh_keypair :: curve25519_keypair(), their_ratchet_pub :: binary(), skipped = #{} :: #{} }). -define(OLM_ROOT_INFO, <<"OLM_ROOT">>). -define(OLM_RATCHET_INFO, <<"OLM_RATCHET">>). -define(OLM_KEYS_INFO, <<"OLM_KEYS">>). -define(MSG_KEY_SEED, <<1>>). -define(CHAIN_KEY_SEED, <<2>>). -define(MSG_VERSION, 3). %%%=================================================================== %%% Account Management %%%=================================================================== -spec create_account() -> {ok, #account{}}. create_account() -> {ok, #account{ identity_keypair = generate_curve25519_keypair(), ed25519_keypair = generate_ed25519_keypair(), one_time_keys = #{}, published_otk_ids = [] }}. -spec account_identity_keys(#account{}) -> #{binary() => binary()}. account_identity_keys(#account{identity_keypair = {Pub, _}, ed25519_keypair = {EdPub, _}}) -> #{<<"curve25519">> => base64url(Pub), <<"ed25519">> => base64url(EdPub)}. -spec account_ed25519_keypair(#account{}) -> {binary(), binary()}. account_ed25519_keypair(#account{ed25519_keypair = KP}) -> KP. -spec account_one_time_keys(#account{}) -> #{binary() => binary()}. account_one_time_keys(#account{one_time_keys = OTKs, published_otk_ids = Published}) -> maps:fold(fun(KeyId, {Pub, _}, Acc) -> case lists:member(KeyId, Published) of true -> Acc; false -> maps:put(<<"curve25519:", KeyId/binary>>, base64url(Pub), Acc) end end, #{}, OTKs). -spec account_generate_otks(#account{}, pos_integer()) -> #account{}. account_generate_otks(Acc = #account{one_time_keys = OTKs}, N) -> NewOTKs = lists:foldl(fun(_, Map) -> maps:put(generate_key_id(), generate_curve25519_keypair(), Map) end, OTKs, lists:seq(1, N)), Acc#account{one_time_keys = NewOTKs}. -spec account_mark_otks_published(#account{}) -> #account{}. account_mark_otks_published(Acc = #account{one_time_keys = OTKs, published_otk_ids = Published}) -> Acc#account{published_otk_ids = lists:usort(Published ++ maps:keys(OTKs))}. -spec account_remove_otk(#account{}, binary()) -> #account{}. account_remove_otk(Acc = #account{one_time_keys = OTKs}, OtkPubB64) -> Acc#account{one_time_keys = maps:filter( fun(_, {Pub, _}) -> base64url(Pub) =/= OtkPubB64 end, OTKs)}. %%%=================================================================== %%% Outbound: create an Olm pre-key message (NEW) %%% %%% Olm pre-key message wire format (version 3): %%% 0x03 — version byte %%% protobuf: %%% field 1 (bytes) = one_time_key pub B64 (their OTK) %%% field 2 (bytes) = base_key pub B64 (our ephemeral) %%% field 3 (bytes) = identity_key pub B64 (our identity) %%% field 4 (bytes) = message ciphertext+mac %%% %%% The inner "message" is itself a ratchet message (version byte 0x03): %%% 0x03 %%% protobuf: %%% field 1 (bytes) = ratchet_key (= base_key pub B64, first message) %%% field 2 (varint)= index (0 for first message) %%% field 3 (bytes) = ciphertext (AES-CBC encrypted, pkcs7-padded) %%% + 8-byte HMAC-SHA256 MAC (truncated) %%%=================================================================== -spec create_olm_prekey_message(#account{}, binary(), binary(), binary()) -> {ok, binary()} | {error, term()}. %% %% Produces a libolm-compatible Olm pre-key message (type 0). %% %% Wire format (matches libolm / Element): %% Outer: VERSION(0x03) | PROTOBUF | MAC(8 bytes) %% protobuf fields (raw bytes, NOT base64): %% field 1 = one_time_key — recipient's OTK raw Curve25519 pub (32 bytes) %% field 2 = base_key — sender's ephemeral Curve25519 pub (32 bytes) %% field 3 = identity_key — sender's identity Curve25519 pub (32 bytes) %% field 4 = message — inner ratchet message bytes %% Inner (Olm ratchet message): %% VERSION(0x03) | PROTOBUF | MAC(8 bytes) %% field 1 = ratchet_key — sender's ratchet pub (raw bytes, 32 bytes) %% field 2 = index — varint, 0 for first message %% field 3 = ciphertext — AES-256-CBC encrypted bytes %% create_olm_prekey_message(Account, TheirCurveB64, TheirOtkB64, Plaintext) -> try TheirIdKey = b64_decode(TheirCurveB64), %% OTK from /keys/claim may be bare base64 OR a signed map {<<"key">>: base64} TheirOtk = extract_otk_bytes(TheirOtkB64), #account{identity_keypair = {IdPub, IdPriv}} = Account, %% Fresh ephemeral base key {BasePub, BasePriv} = generate_curve25519_keypair(), %% X3DH outbound (Alice), matching libolm / Element: %% master_secret = ECDH(I_A, I_B) || ECDH(E_A, I_B) || ECDH(E_A, OT_B) %% S1 = ECDH(identity_priv, their_identity_pub) [I_A, I_B] %% S2 = ECDH(base_priv, their_identity_pub) [E_A, I_B] %% S3 = ECDH(base_priv, their_otk_pub) [E_A, OT_B] S1 = ecdh(IdPriv, TheirIdKey), S2 = ecdh(BasePriv, TheirIdKey), S3 = ecdh(BasePriv, TheirOtk), IKM = <<(binary:copy(<<16#FF>>, 32))/binary, S1/binary, S2/binary, S3/binary>>, <<_RootKey:32/binary, ChainKey:32/binary>> = crypto:hkdf(sha256, IKM, <<0:256>>, ?OLM_ROOT_INFO, 64), MsgKey = hmac256(ChainKey, ?MSG_KEY_SEED), {AesKey, MacKey, AesIv} = derive_olm_keys(MsgKey), %% The ratchet key for this first message = BasePub (as per Olm spec, %% the initial ratchet key is the base/ephemeral key). RatchetPub = BasePub, %% Encrypt plaintext Padded = pkcs7pad(Plaintext, 16), Ct = crypto:crypto_one_time(aes_256_cbc, AesKey, AesIv, Padded, true), %% Inner ratchet message (type 1) — libolm lib/message.cpp field IDs: %% field 1 = ratchet_key (bytes) %% field 2 = counter (varint) %% field 4 = ciphertext (bytes) ← NOT 3, libolm CIPHERTEXT_ID = 4 %% Wire: VERSION(0x03) | PROTOBUF | MAC(8 bytes) InnerPb = encode_pb([{1, RatchetPub}, {2, 0}, {4, Ct}]), InnerHead = <<3:8, InnerPb/binary>>, InnerMac = binary:part(hmac256(MacKey, InnerHead), 0, 8), InnerMsg = <>, %% Outer pre-key message (type 0) — libolm lib/message.cpp: %% field 1 = one_time_key (bytes) %% field 2 = base_key (bytes) %% field 3 = identity_key (bytes) %% field 4 = message (bytes) = the inner message %% Wire: VERSION(0x03) | PROTOBUF ← NO MAC on outer message! OuterPb = encode_pb([{1, TheirOtk}, {2, BasePub}, {3, IdPub}, {4, InnerMsg}]), OuterMsg = <<3:8, OuterPb/binary>>, {ok, OuterMsg} catch C:R:St -> {error, {prekey_failed, C, R, St}} end. %% Extract raw OTK bytes from either a bare base64 string %% or a signed key map #{<<"key">> => Base64}. -spec extract_otk_bytes(binary() | map()) -> binary(). extract_otk_bytes(B) when is_binary(B) -> b64_decode(B); extract_otk_bytes(M) when is_map(M) -> b64_decode(maps:get(<<"key">>, M)). %%%=================================================================== %%% Session Creation (Inbound) %%%=================================================================== -spec create_inbound(#account{}, binary(), binary()) -> {ok, binary(), #olm_session{}, #account{}} | {error, term()}. create_inbound(Account, SenderIdentityKeyB64, PreKeyMsgBin) -> try #{base_key := BaseKeyB64, one_time_key := OtkKeyB64, message := InnerMsg} = parse_prekey_message(PreKeyMsgBin), SenderIdentityKey = maybe_decode(SenderIdentityKeyB64), BaseKey = maybe_decode(BaseKeyB64), OtkPub = maybe_decode(OtkKeyB64), #account{identity_keypair = {_IdPub, IdPriv}, one_time_keys = OTKs} = Account, {_, OtkPrivKey} = find_otk_by_pub(OTKs, OtkPub), S1 = ecdh(IdPriv, BaseKey), S2 = ecdh(OtkPrivKey, SenderIdentityKey), S3 = ecdh(OtkPrivKey, BaseKey), IKM = <<(binary:copy(<<16#FF>>, 32))/binary, S1/binary, S2/binary, S3/binary>>, <> = crypto:hkdf(sha256, IKM, <<0:256>>, ?OLM_ROOT_INFO, 64), Session = #olm_session{ root_key = RootKey, recv_chain = ChainKey, dh_keypair = generate_curve25519_keypair(), their_ratchet_pub = BaseKey }, Account2 = account_remove_otk(Account, OtkKeyB64), {ok, Plaintext, Session2} = decrypt_ratchet(Session, InnerMsg, BaseKey), {ok, Plaintext, Session2, Account2} catch C:R:St -> {error, {inbound_session_failed, C, R, St}} end. maybe_decode(B) when byte_size(B) =:= 32 -> B; maybe_decode(B) -> b64_decode(B). %%%=================================================================== %%% Message Decryption %%%=================================================================== -spec decrypt(#olm_session{}, non_neg_integer(), binary()) -> {ok, binary(), #olm_session{}} | {error, term()}. decrypt(Session, 1, CiphertextBin) -> try #{ratchet_key := RatchetKeyB64, message := Ciphertext} = parse_ratchet_message(CiphertextBin), decrypt_ratchet(Session, Ciphertext, b64_decode(RatchetKeyB64)) catch C:R -> {error, {decrypt_failed, C, R}} end; decrypt(_Session, Type, _) -> {error, {unsupported_msg_type, Type}}. %%%=================================================================== %%% Internal — Double Ratchet %%%=================================================================== decrypt_ratchet(Session = #olm_session{ their_ratchet_pub = CurrentPub, recv_chain = ChainKey, root_key = RootKey, dh_keypair = {_MyPub, MyPriv} }, Ciphertext, TheirRatchetKey) -> {ChainKey2, Session2} = case TheirRatchetKey =:= CurrentPub of true -> {ChainKey, Session}; false -> DhSecret = ecdh(MyPriv, TheirRatchetKey), <> = crypto:hkdf(sha256, <>, <<0:256>>, ?OLM_RATCHET_INFO, 64), S2 = Session#olm_session{ root_key = NewRootKey, recv_chain = NewChainKey, dh_keypair = generate_curve25519_keypair(), their_ratchet_pub = TheirRatchetKey }, {NewChainKey, S2} end, MsgKey = hmac256(ChainKey2, ?MSG_KEY_SEED), NewChainKey2 = hmac256(ChainKey2, ?CHAIN_KEY_SEED), {AesKey, MacKey, AesIv} = derive_olm_keys(MsgKey), MsgLen = byte_size(Ciphertext), MacStart = MsgLen - 8, <> = Ciphertext, ExpMac = binary:part(crypto:mac(hmac, sha256, MacKey, Body), 0, 8), case ExpMac =:= Mac of false -> {error, mac_mismatch}; true -> case keylara_aes:decrypt(Body, AesKey, AesIv) of {ok, Plaintext} -> {ok, Plaintext, Session2#olm_session{recv_chain = NewChainKey2}}; Err -> Err end end. derive_olm_keys(MsgKey) -> <> = crypto:hkdf(sha256, MsgKey, <<>>, ?OLM_KEYS_INFO, 80), {AesKey, MacKey, AesIv}. %%%=================================================================== %%% Message Parsing %%%=================================================================== parse_prekey_message(Bin) -> Fields = decode_protobuf(Bin), #{one_time_key => maps:get(1, Fields), base_key => maps:get(2, Fields), identity_key => maps:get(3, Fields), message => maps:get(4, Fields)}. parse_ratchet_message(Bin) -> <> = Bin, Fields = decode_protobuf(Rest), #{ratchet_key => maps:get(1, Fields), index => varint_to_integer(maps:get(2, Fields, <<0>>)), message => maps:get(4, Fields)}. varint_to_integer(B) when is_binary(B) -> element(1, decode_varint(B)); varint_to_integer(N) when is_integer(N) -> N. decode_protobuf(Bin) -> decode_protobuf(Bin, #{}). decode_protobuf(<<>>, Acc) -> Acc; decode_protobuf(Bin, Acc) -> {Tag, Rest} = decode_varint(Bin), FieldNum = Tag bsr 3, WireType = Tag band 7, case WireType of 0 -> {Value, Rest2} = decode_varint(Rest), decode_protobuf(Rest2, maps:put(FieldNum, Value, Acc)); 2 -> {Len, Rest2} = decode_varint(Rest), <> = Rest2, decode_protobuf(Rest3, maps:put(FieldNum, Value, Acc)); _ -> Acc end. decode_varint(Bin) -> decode_varint(Bin, 0, 0). decode_varint(<<1:1, B:7, Rest/binary>>, Shift, Acc) -> decode_varint(Rest, Shift + 7, Acc bor (B bsl Shift)); decode_varint(<<0:1, B:7, Rest/binary>>, Shift, Acc) -> {Acc bor (B bsl Shift), Rest}. %%%=================================================================== %%% Protobuf encoder (NEW — used by create_olm_prekey_message) %%%=================================================================== encode_pb(Fields) -> iolist_to_binary([encode_pb_field(FN, V) || {FN, V} <- Fields]). encode_pb_field(FN, V) when is_binary(V) -> Tag = encode_varint((FN bsl 3) bor 2), [Tag, encode_varint(byte_size(V)), V]; encode_pb_field(FN, V) when is_integer(V) -> Tag = encode_varint((FN bsl 3) bor 0), [Tag, encode_varint(V)]. encode_varint(N) when N < 128 -> <>; encode_varint(N) -> <<1:1, (N band 127):7, (encode_varint(N bsr 7))/binary>>. %%%=================================================================== %%% Crypto helpers %%%=================================================================== ecdh(PrivKey, PubKey) -> crypto:compute_key(ecdh, PubKey, PrivKey, x25519). hmac256(Key, Data) -> crypto:mac(hmac, sha256, Key, Data). pkcs7pad(Bin, BlockSize) -> Pad = BlockSize - (byte_size(Bin) rem BlockSize), <>, Pad))/binary>>. generate_curve25519_keypair() -> crypto:generate_key(ecdh, x25519). generate_ed25519_keypair() -> crypto:generate_key(eddsa, ed25519). generate_key_id() -> Bytes = try case keylara:get_entropy_bytes(4) of {ok, B} -> B; _ -> crypto:strong_rand_bytes(4) end catch _:_ -> crypto:strong_rand_bytes(4) end, binary:encode_hex(Bytes). find_otk_by_pub(OTKs, OtkPub) -> case maps:fold(fun(_, KP = {Pub, _}, Acc) -> case Pub =:= OtkPub of true -> KP; false -> Acc end end, not_found, OTKs) of not_found -> throw({error, one_time_key_not_found}); KP -> KP end. base64url(Bin) -> base64:encode(Bin). b64_decode(B64) -> Padded = case byte_size(B64) rem 4 of 0 -> B64; N -> <