diff --git a/lib/TrustSpanningProtocol/endpoint.ex b/lib/TrustSpanningProtocol/endpoint.ex new file mode 100644 index 0000000..f4f265b --- /dev/null +++ b/lib/TrustSpanningProtocol/endpoint.ex @@ -0,0 +1,181 @@ +defmodule TSP.Endpoint do + alias Cesr.CountCodeKERIv2.CD_A_GenericGroup + alias Cesr.CountCodeKERIv2.CD_dashE_BigESSRWrapperGroup + alias Cesr.CountCodeKERIv2.CD_dashJ_BigGenericListGroup + alias Cesr.CountCodeKERIv2.CD_dashZ_BigESSRPayloadGroup + alias Cesr.Primitive.CesrBytes + alias TSP.Endpoint + alias TSP.Keystore + alias TSP.Message + alias TSP.Relationship + + use GenServer + @enforce_keys [:relationships] + defstruct [:relationships] + + @impl true + def init(:empty) do + {:ok, %Endpoint{relationships: []}} + end + + @impl true + def handle_call({:add_vid, our_vid}, _from, %Endpoint{} = state) do + case Enum.any?(state.relationships, fn r -> r.our_vid == our_vid end) do + true -> {:reply, :error_vid_already_exists, state} # could also be a no-op + false -> {:reply, :ok, %{state | :relationships => state.relationships ++ [Relationship.new(our_vid)]}} + end + end + + def handle_call({:oobi, our_vid, their_vid, their_pid}, _from, %Endpoint{} = state) do + case Enum.find(state.relationships, fn r -> r.our_vid == our_vid end) do + :nil -> {:reply, :error_vid_not_created_yet, state} + old_relationship -> {:ok, their_keystore} = GenServer.call(their_pid, {:public_keys, their_vid}) + new_relationship = %{old_relationship | their_vid: their_vid, their_pid: their_pid, their_keystore: their_keystore} + other_relationships = Enum.filter(state.relationships, fn r -> r.our_vid != our_vid end) + {:reply, :ok, %{state | :relationships => other_relationships ++ [new_relationship]}} + end + end + + def handle_call({:public_keys, our_vid}, _from, %Endpoint{} = state) do + case Enum.find(state.relationships, fn r -> r.our_vid == our_vid end) do + :nil -> {:reply, :error_keystore_not_found, state} + old_relationship -> public_keystore = %{Keystore.new() | crypto_public_key: old_relationship.our_keystore.crypto_public_key, + sig_public_key: old_relationship.our_keystore.sig_public_key} + {:reply, {:ok, public_keystore}, state} + end + end + + def handle_call({:create_message, our_vid, their_vid, route, payload}, _from, %Endpoint{} = state) do + case Enum.find(state.relationships, fn r -> r.our_vid == our_vid end) do + :nil -> {:reply, :error_keystore_not_found, state} + old_relationship -> message = Message.new(our_vid, their_vid, route, payload, + old_relationship.their_keystore.crypto_public_key, + old_relationship.our_keystore.sig_secret_key) + {:reply, {:ok, message}, state} + end + end + + # routed message: verify it, unseal it, reseal for next recipient, sign and pass it on + def handle_call({:route_message, %Message{ + envelope: %CD_dashE_BigESSRWrapperGroup{cesr_elements: + [_tsp_version, %CesrBytes{code: _code_1, payload: vid_sender}, + %CesrBytes{code: _code_2, payload: vid_receiver}]}, + payload: %CD_dashZ_BigESSRPayloadGroup{cesr_elements: + [_payload_tag, %CesrBytes{code: _code_3, payload: vid_sender_payload}, + %CD_dashJ_BigGenericListGroup{cesr_elements: [ + %CesrBytes{code: _code_4, payload: first_hop}, + %CesrBytes{code: _code_5, payload: next_hop} | rest_of_hops]}, _payload_group]}, + signature: _signature} = message}, _from, %Endpoint{} = state) do + with :ok <- (if vid_sender == vid_sender_payload, do: :ok, else: :error_vid_mismatch), + :ok <- (if vid_receiver == first_hop, do: :ok, else: :error_wrong_recipient), + %Relationship{} = incoming_relationship <- Enum.find(state.relationships, + fn r -> r.our_vid == vid_receiver end), + %Relationship{} = outgoing_relationship <- Enum.find(state.relationships, + fn r -> r.their_vid == next_hop end), + :ok <- (if :true == Message.verify_signature(message, incoming_relationship.their_keystore.sig_public_key), + do: :ok, else: :error_sig_not_verified), + unsealed_payload <- Message.unseal_payload(message, incoming_relationship.our_keystore.crypto_public_key, + incoming_relationship.our_keystore.crypto_secret_key), + next_hops_string_list <- [next_hop | Enum.map(rest_of_hops, fn %CesrBytes{code: _code, payload: payload} -> payload end)], + %Message{} = outgoing_message <- Message.new(outgoing_relationship.our_vid, + next_hop, next_hops_string_list, unsealed_payload, + outgoing_relationship.their_keystore.crypto_public_key, + outgoing_relationship.our_keystore.sig_secret_key) do + {:reply, {:next_route, outgoing_relationship.their_pid, outgoing_message}, state} + else + :nil -> {:reply, :error_relationship_not_found, state} + error -> {:reply, error, state} + end + end + # terminating condition: one entry in hop list + def handle_call({:route_message, %Message{ + envelope: %CD_dashE_BigESSRWrapperGroup{cesr_elements: + [_tsp_version, %CesrBytes{code: _code_1, payload: vid_sender}, + %CesrBytes{code: _code_2, payload: vid_receiver}]}, + payload: %CD_dashZ_BigESSRPayloadGroup{cesr_elements: + [_payload_tag, %CesrBytes{code: _code_3, payload: vid_sender_payload}, + %CD_dashJ_BigGenericListGroup{cesr_elements: [ + %CesrBytes{code: _code_4, payload: final_vid}]}, _payload_group]}, + signature: _signature} = message}, _from, %Endpoint{} = state) do + with :ok <- (if vid_sender == vid_sender_payload, do: :ok, else: :error_vid_mismatch), + :ok <- (if vid_receiver == final_vid, do: :ok, else: :error_wrong_recipient), + %Relationship{} = relationship <- Enum.find(state.relationships, + fn r -> r.our_vid == vid_receiver end), + :ok <- (if :true == Message.verify_signature(message, relationship.their_keystore.sig_public_key), + do: :ok, else: :error_sig_not_verified), + unsealed_payload <- Message.unseal_payload(message, relationship.our_keystore.crypto_public_key, + relationship.our_keystore.crypto_secret_key) do + + {:reply, {:route_done, unsealed_payload}, state} + else + :nil -> {:reply, :error_relationship_not_found, state} + error -> {:reply, error, state} + end + end + # invalid case: tried to route a message with an empty hop list + def handle_call({:route_message, %Message{ + envelope: _envelope, + payload: %CD_dashZ_BigESSRPayloadGroup{cesr_elements: + [_payload_tag, _vid_sender_payload, %CD_dashJ_BigGenericListGroup{cesr_elements: []}, + %CD_A_GenericGroup{cesr_elements: [_payload_primitive]}]}, + signature: _signature}}, _from, %Endpoint{} = state) do + {:reply, :error_empty_hop_list, state} + end + + def handle_call({:unseal_payload, our_vid, message}, _from, %Endpoint{} = state) do + case Enum.find(state.relationships, fn r -> r.our_vid == our_vid end) do + :nil -> {:reply, :error_keystore_not_found, state} + old_relationship -> payload = Message.unseal_payload(message, + old_relationship.our_keystore.crypto_public_key, + old_relationship.our_keystore.crypto_secret_key) + {:reply, payload, state} + end + end + + def handle_call({:verify_signature, vid_sender, message}, _from, %Endpoint{} = state) do + # verify from either endpoint for convenience (the sig key is public knowledge) + reply = case Enum.find(state.relationships, fn r -> r.our_vid == vid_sender end) do + :nil -> case Enum.find(state.relationships, fn r -> r.their_vid == vid_sender end) do + :nil -> :error_keystore_not_found + relationship -> Message.verify_signature(message, relationship.their_keystore.sig_public_key) + end + relationship -> Message.verify_signature(message, relationship.our_keystore.sig_public_key) + end + {:reply, reply, state} + end + + # everything below this is just to verify that endpoints control keys + # (https://trustoverip.github.io/tswg-tsp-specification/#verification) + + def handle_call({:verify_crypto_key, our_vid, encrypted_payload}, _from, %Endpoint{} = state) do + case Enum.find(state.relationships, fn r -> r.our_vid == our_vid end) do + :nil -> {:reply, :error_crypto_key_not_found, state} + relationship -> decrypted_payload = :libsodium_crypto_box.seal_open(encrypted_payload, + relationship.our_keystore.crypto_public_key, + relationship.our_keystore.crypto_secret_key) + {:reply, {:ok, decrypted_payload}, state} + end + end + + def handle_call({:verify_sig_key, our_vid, payload}, _from, %Endpoint{} = state) do + case Enum.find(state.relationships, fn r -> r.our_vid == our_vid end) do + :nil -> {:reply, :error_public_key_not_found, state} + relationship -> encrypted_payload = :libsodium_crypto_sign_ed25519.crypto_sign_ed25519( + payload, relationship.our_keystore.sig_secret_key) + {:reply, {:ok, encrypted_payload}, state} + end + end + + def verify_keys(their_vid, their_pid) do + with {:ok, their_keystore} <- GenServer.call(their_pid, {:public_keys, their_vid}), + payload <- :base64.encode(:crypto.strong_rand_bytes(8)), + encrypted_payload <- :libsodium_crypto_box.seal(payload, their_keystore.crypto_public_key), + {:ok, decrypted_payload} <- GenServer.call(their_pid, {:verify_crypto_key, their_vid, encrypted_payload}), + crypto_verified <- payload == decrypted_payload, + {:ok, signed_payload} <- GenServer.call(their_pid, {:verify_sig_key, their_vid, payload}), + sig_verified <- payload == :libsodium_crypto_sign_ed25519.open(signed_payload, their_keystore.sig_public_key) + do + if crypto_verified and sig_verified, do: :ok, else: :error_verification_failed + end + end +end diff --git a/lib/TrustSpanningProtocol/keystore.ex b/lib/TrustSpanningProtocol/keystore.ex new file mode 100644 index 0000000..6414dd5 --- /dev/null +++ b/lib/TrustSpanningProtocol/keystore.ex @@ -0,0 +1,29 @@ +defmodule TSP.Keystore do + alias TSP.Keystore + + @enforce_keys [:sig_public_key, :sig_secret_key, :crypto_public_key, :crypto_secret_key] + defstruct [:sig_public_key, :sig_secret_key, :crypto_public_key, :crypto_secret_key] + + def new() do + %Keystore{ + sig_public_key: :nil, + sig_secret_key: :nil, + crypto_public_key: :nil, + crypto_secret_key: :nil + } + end + + def random() do + # converting as per https://libsodium.gitbook.io/doc/advanced/ed25519-curve25519 + # can also use two different keypairs + with {sig_public_key, sig_secret_key} <- :libsodium_crypto_sign_ed25519.keypair() + do + %Keystore{ + sig_public_key: sig_public_key, + sig_secret_key: sig_secret_key, + crypto_public_key: :libsodium_crypto_sign_ed25519.pk_to_curve25519(sig_public_key), + crypto_secret_key: :libsodium_crypto_sign_ed25519.sk_to_curve25519(sig_secret_key) + } + end + end +end diff --git a/lib/TrustSpanningProtocol/message.ex b/lib/TrustSpanningProtocol/message.ex new file mode 100644 index 0000000..eb15604 --- /dev/null +++ b/lib/TrustSpanningProtocol/message.ex @@ -0,0 +1,119 @@ +defmodule TSP.Message do + alias Cesr + alias Cesr.CountCodeKERIv2.CD_A_GenericGroup + alias Cesr.CountCodeKERIv2.CD_dashC_BigAttachmentGroup + alias Cesr.CountCodeKERIv2.CD_dashE_BigESSRWrapperGroup + alias Cesr.CountCodeKERIv2.CD_dashJ_BigGenericListGroup + alias Cesr.CountCodeKERIv2.CD_dashK_BigControllerIdxSigs + alias Cesr.CountCodeKERIv2.CD_dashZ_BigESSRPayloadGroup + alias Cesr.Primitive.CD_0B_Ed25519_signature + alias Cesr.Primitive.CD_X_Tag3 + alias Cesr.Primitive.CD_Y_Tag7 + alias Cesr.Primitive.CesrBytes + alias TSP.Message + + @enforce_keys [:envelope, :payload, :signature] + defstruct [:envelope, :payload, :signature] + + # 3. Messages (https://trustoverip.github.io/tswg-tsp-specification/#messages) + # TSP_Message = {TSP_Envelope, TSP_Payload, TSP_Signature} + def new(sender, receiver, route, plaintext, receiver_crypto_public_key, sender_sig_secret_key) do + with {:ok, envelope} <- make_envelope(sender, receiver), + {:ok, payload} <- make_payload(sender, route, plaintext, receiver_crypto_public_key), + {:ok, signature} <- make_signature(envelope, payload, sender_sig_secret_key) do + %Message{envelope: envelope, payload: payload, signature: signature} + end + end + + defp make_envelope(sender, receiver) do + # 3.1 TSP Envelope (https://trustoverip.github.io/tswg-tsp-specification/#tsp-envelope) + # TSP_Envelope = {TSP_Tag, TSP_Version, VID_sndr, VID_rcvr | NULL} + with {{:ok, tsp_version}, ""} = CD_Y_Tag7.from_b64("YTSP-AAB"), + {:ok, vid_sender} = CesrBytes.new(sender), + {:ok, vid_receiver} = CesrBytes.new(receiver) do + # 9.1 TSP Envelope Encoding (https://trustoverip.github.io/tswg-tsp-specification/#tsp-envelope-encoding) + # tsp_tag: wrap everything in a BigESSRWrapperGroup (-E) (indicates it's an envelope) + # tsp_version: first version is YTSP-AAB (Y indicates a 7-letter tag primitive, + # TSP == Trust Spanning Protocol, AAB == 001 in base64 indices) + # vid_sender: encode as variable-length primitive (4A/5A/etc depending on padding) + # vid_receiver: optional, same encoding as receiver (we'll use :nil if it's missing) + CD_dashE_BigESSRWrapperGroup.new([tsp_version, vid_sender, vid_receiver]) + end + end + + defp make_cesr_bytes!(string) do + {:ok, cesr} = CesrBytes.new(string) + cesr + end + + defp make_payload(sender, route, plaintext, receiver_crypto_public_key) do + # 9.2 TSP Payload Encoding (https://trustoverip.github.io/tswg-tsp-specification/#tsp-payload-encoding) + # The cesr stream looks something like this, "|" meaning "or": + # -Z## | -0Z####, XSCS, VID_sndr, Padding_field, -A## | -0A####, higher-layer-interleaved-payload-stream + # -Z: wrap everything in a BigESSRPayloadGroup (Encrypt Sender, Sign Receiver) + # XSCS: put "SCS" in a 3-letter tag (payload is a Sniffable CESR Stream) + # VID_sndr: encode as a variable-length primitive (the "encrypt sender" part of ESSR) + # hop_list: list of vids to route the message to (empty if it's a direct message) + # padding: skipping this since our cesr implementation can calculate padding properly + # -A: wrap the payload in a GenericGroup + # higher-layer-interleaved-payload-stream: encrypted payload (or nested TSP message) + with {{:ok, payload_tag}, ""} <- CD_X_Tag3.from_b64("XSCS"), + {:ok, vid_sender} <- CesrBytes.new(sender), + {:ok, hop_list} <- CD_dashJ_BigGenericListGroup.new(Enum.map(route, &make_cesr_bytes!/1)), + # {:ok, padding_field} <- CesrBytes.new(""), + encrypted_payload <- :libsodium_crypto_box.seal(plaintext, receiver_crypto_public_key), + {:ok, payload_cesr} <- CesrBytes.new(encrypted_payload) do + CD_dashZ_BigESSRPayloadGroup.new([payload_tag, vid_sender, hop_list, CD_A_GenericGroup.new([payload_cesr])]) + end + end + + defp make_signature(envelope, payload, sender_sig_secret_key) do + # 3.3 TSP Signature (https://trustoverip.github.io/tswg-tsp-specification/#tsp-signature) + # TSP_Signature = TSP_SIGN({TSP_Envelope, TSP_Payload}) + with text <- Cesr.produce_text_stream([envelope, payload]), + signature <- :libsodium_crypto_sign_ed25519.detached(text, sender_sig_secret_key), + {:ok, sig_cesr} <- CD_0B_Ed25519_signature.new(signature) do + # 9.3 TSP Signature Encoding (https://trustoverip.github.io/tswg-tsp-specification/#tsp-signature-encoding) + # 1. wrap everything in a BigAttachmentGroup (-C) so we know it's signatures + # 2. inside that, wrap signatures & indexes in a BigControllerIdxSigs (-K) + # 3. signature types match the keys, we used ed25519 so Ed25519_signature (0B) + CD_dashC_BigAttachmentGroup.new([CD_dashK_BigControllerIdxSigs.new([sig_cesr])]) + end + end + + def unseal_payload(%Message{envelope: _envelope, + payload: %CD_dashZ_BigESSRPayloadGroup{cesr_elements: + [_payload_tag, _vid_sender, _hop_list, %CD_A_GenericGroup{cesr_elements: [payload_primitive]}]}, + signature: _signature}, receiver_crypto_public_key, receiver_crypto_secret_key) do + :libsodium_crypto_box.seal_open(payload_primitive.payload, receiver_crypto_public_key, + receiver_crypto_secret_key) + end + def unseal_payload(_message, _crypto_public_key, _crypto_secret_key), do: :error_malformed_message + + def verify_signature(%Message{envelope: envelope, payload: payload, + signature: %CD_dashC_BigAttachmentGroup{cesr_elements: [ + %CD_dashK_BigControllerIdxSigs{cesr_elements: [signature]} + ]}}, sender_sig_public_key) do + text_to_sign = Cesr.produce_text_stream([envelope, payload]) + case :libsodium_crypto_sign_ed25519.verify_detached(signature.payload, text_to_sign, sender_sig_public_key) do + 0 -> :true # (it's a port of a C API) + _ -> :false + end + end + def verify_signature(_message, _sig_public_key), do: :error_malformed_message + + def to_b64(%Message{envelope: envelope, payload: payload, signature: signature}) do + # have to wrap it or it's not a valid count code... + with {:ok, wrapped_message} <- CD_A_GenericGroup.new([envelope, payload, signature]) do + Cesr.produce_text_stream([wrapped_message]) + end + end + def to_b64(_message), do: :error_malformed_message + + def from_b64(bytes) do + with {:ok, %CD_A_GenericGroup{cesr_elements: [envelope, payload, signature]}} + <- Cesr.consume_primitive_T(bytes) do + %Message{envelope: envelope, payload: payload, signature: signature} + end + end +end diff --git a/lib/TrustSpanningProtocol/relationship.ex b/lib/TrustSpanningProtocol/relationship.ex new file mode 100644 index 0000000..17f883e --- /dev/null +++ b/lib/TrustSpanningProtocol/relationship.ex @@ -0,0 +1,17 @@ +defmodule TSP.Relationship do + alias TSP.Keystore + alias TSP.Relationship + + @enforce_keys [:our_vid, :their_vid, :our_keystore, :their_keystore, :their_pid] + defstruct [:our_vid, :their_vid, :our_keystore, :their_keystore, :their_pid] + + def new(our_vid) do + %Relationship{ + our_vid: our_vid, + their_vid: :nil, + our_keystore: Keystore.random(), + their_keystore: Keystore.new(), + their_pid: :nil + } + end +end diff --git a/mix.exs b/mix.exs index 123f593..d448bb4 100644 --- a/mix.exs +++ b/mix.exs @@ -49,7 +49,8 @@ defmodule Cesr.MixProject do # has support for OrdMap serialization/deserialization. {:cbor, "~> 1.0", hex: :cbor_ordmap}, {:dialyxir, "~> 1.4", only: [:dev], runtime: false}, - {:ex_doc, "0.39.1", only: [:dev], runtime: false} + {:ex_doc, "0.39.1", only: [:dev], runtime: false}, + {:libsodium, "~> 2.0.0"} ] end end diff --git a/mix.lock b/mix.lock index 928e276..6b084a6 100644 --- a/mix.lock +++ b/mix.lock @@ -5,6 +5,7 @@ "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, "ex_doc": {:hex, :ex_doc, "0.39.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "libsodium": {:hex, :libsodium, "2.0.1", "9bebad374e1b312f620ee6b28a69670b4ba1011b365f91be9271b02f5383570d", [:rebar3], [], "hexpm", "c189198acddc101f9a9f2b94bf29a1132119a982c3738a4679fc5d411f10b58b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, diff --git a/test/trust_spanning_protocol_test.exs b/test/trust_spanning_protocol_test.exs new file mode 100644 index 0000000..31008a6 --- /dev/null +++ b/test/trust_spanning_protocol_test.exs @@ -0,0 +1,318 @@ +defmodule TrustSpanningProtocolTest do + alias Cesr.CesrElement + alias Cesr.CountCodeKERIv2.CD_A_GenericGroup + alias Cesr.CountCodeKERIv2.CD_dashC_BigAttachmentGroup + alias Cesr.CountCodeKERIv2.CD_dashE_BigESSRWrapperGroup + alias Cesr.CountCodeKERIv2.CD_dashJ_BigGenericListGroup + alias Cesr.CountCodeKERIv2.CD_dashK_BigControllerIdxSigs + alias Cesr.CountCodeKERIv2.CD_dashZ_BigESSRPayloadGroup + alias Cesr.Primitive.CD_0B_Ed25519_signature + alias Cesr.Primitive.CD_X_Tag3 + alias Cesr.Primitive.CD_Y_Tag7 + alias Cesr.Primitive.CesrBytes + alias TSP.Endpoint + alias TSP.Keystore + alias TSP.Message + alias TSP.Relationship + + use ExUnit.Case, async: true + + # OOBI == out of band introduction + test "OOBI 2 Endpoints" do + # create endpoints and vids (each vid automatically creates a random keystore) + {:ok, endpoint_1} = GenServer.start_link(Endpoint, :empty, []) + {:ok, endpoint_2} = GenServer.start_link(Endpoint, :empty, []) + vid_1 = "AAAAAAAAAA" + vid_2 = "BBBBBBBBBB" + :ok = GenServer.call(endpoint_1, {:add_vid, vid_1}) + :ok = GenServer.call(endpoint_2, {:add_vid, vid_2}) + + # we don't normally grab the keystores like this but we want to verify them + keystore_1 = Enum.at(:sys.get_state(endpoint_1).relationships, 0).our_keystore + keystore_1_public = %{keystore_1 | sig_secret_key: :nil, crypto_secret_key: :nil} + keystore_2 = Enum.at(:sys.get_state(endpoint_2).relationships, 0).our_keystore + keystore_2_public = %{keystore_2 | sig_secret_key: :nil, crypto_secret_key: :nil} + assert :sys.get_state(endpoint_1).relationships == [%Relationship{ + our_vid: vid_1, + their_vid: :nil, + our_keystore: keystore_1, + their_keystore: Keystore.new(), + their_pid: :nil + }] + assert :sys.get_state(endpoint_2).relationships == [%Relationship{ + our_vid: vid_2, + their_vid: :nil, + our_keystore: keystore_2, + their_keystore: Keystore.new(), + their_pid: :nil + }] + + # oobi endpoints and check public keys were retrieved + :ok = GenServer.call(endpoint_1, {:oobi, vid_1, vid_2, endpoint_2}) + assert :sys.get_state(endpoint_1).relationships == [%Relationship{ + our_vid: vid_1, + their_vid: vid_2, + our_keystore: keystore_1, + their_keystore: keystore_2_public, + their_pid: endpoint_2 + }] + :ok = GenServer.call(endpoint_2, {:oobi, vid_2, vid_1, endpoint_1}) + assert :sys.get_state(endpoint_2).relationships == [%Relationship{ + our_vid: vid_2, + their_vid: vid_1, + our_keystore: keystore_2, + their_keystore: keystore_1_public, + their_pid: endpoint_1 + }] + + # cryptographically verify that they also have the private keys + # (https://trustoverip.github.io/tswg-tsp-specification/#verification) + assert :ok == Endpoint.verify_keys(vid_1, endpoint_1) + assert :ok == Endpoint.verify_keys(vid_2, endpoint_2) + end + + test "Message" do + # create endpoints and vids, oobi endpoints + vid_1 = "AAAAAAAAAA" + vid_2 = "BBBBBBBBBB" + payload = "12345" + {:ok, endpoint_1} = GenServer.start_link(Endpoint, :empty, []) + {:ok, endpoint_2} = GenServer.start_link(Endpoint, :empty, []) + :ok = GenServer.call(endpoint_1, {:add_vid, vid_1}) + :ok = GenServer.call(endpoint_2, {:add_vid, vid_2}) + :ok = GenServer.call(endpoint_1, {:oobi, vid_1, vid_2, endpoint_2}) + :ok = GenServer.call(endpoint_2, {:oobi, vid_2, vid_1, endpoint_1}) + {:ok, message} = GenServer.call(endpoint_1, {:create_message, vid_1, vid_2, [], payload}) + + # verify envelope struct + %CD_dashE_BigESSRWrapperGroup{ + cesr_elements: [ + %CD_Y_Tag7{ + code: "Y", + payload: <<77, 35, 254, 0, 0, 1::size(2)>> # see test "Tag" + }, + %CesrBytes{code: :auto, payload: ^vid_1}, + %CesrBytes{code: :auto, payload: ^vid_2} + ] + } = message.envelope + + # verify payload struct + %CD_dashZ_BigESSRPayloadGroup{ + cesr_elements: [ + %CD_X_Tag3{ + code: "X", + payload: <<72, 36, 2::size(2)>> + }, + %CesrBytes{code: :auto, payload: ^vid_1}, + %CD_dashJ_BigGenericListGroup{cesr_elements: []}, + %CD_A_GenericGroup{ + cesr_elements: [ + %CesrBytes{ + code: :auto, + payload: _encrypted_payload + } + ] + } + ] + } = message.payload + + # verify signature struct + %CD_dashC_BigAttachmentGroup{ + cesr_elements: [ + %CD_dashK_BigControllerIdxSigs{ + cesr_elements: [ + %CD_0B_Ed25519_signature{ + code: "0B", + payload: _signature + } + ] + } + ] + } = message.signature + + # unseal payload + assert payload == GenServer.call(endpoint_2, {:unseal_payload, vid_2, message}) + + # verify signature (either endpoint can verify with the sender's public sig key) + assert :true == GenServer.call(endpoint_1, {:verify_signature, vid_1, message}) + assert :true == GenServer.call(endpoint_2, {:verify_signature, vid_1, message}) + end + + test "Nested Message" do + # Payload Nesting (https://trustoverip.github.io/tswg-tsp-specification/#payload-nesting) + # Endpoints A & B have a prior relationship (vid_a0, vid_b0). They can embed a new + # relationship (vid_a1, vid_b1) in the encrypted payload of (vid_a0, vid_b0) messages. + # + # Outer_Message = {Envelope_0, Payload_0, Signature_0}, + # Inner_Message = {Envelope_1, Payload_1, Signature_1}, + # Nested_Message = {Envelope_0, Control_Fields_0, TSP_SEAL_0(Inner_Message), Signature0} + + # create endpoints and vids, oobi endpoints + {:ok, endpoint_A} = GenServer.start_link(Endpoint, :empty, []) + {:ok, endpoint_B} = GenServer.start_link(Endpoint, :empty, []) + [vid_a0, vid_a1, vid_b0, vid_b1] = ["a0", "a1", "b0", "b1"] + :ok = GenServer.call(endpoint_A, {:add_vid, vid_a0}) + :ok = GenServer.call(endpoint_A, {:add_vid, vid_a1}) + :ok = GenServer.call(endpoint_B, {:add_vid, vid_b0}) + :ok = GenServer.call(endpoint_B, {:add_vid, vid_b1}) + :ok = GenServer.call(endpoint_A, {:oobi, vid_a0, vid_b0, endpoint_B}) + :ok = GenServer.call(endpoint_A, {:oobi, vid_a1, vid_b1, endpoint_B}) + :ok = GenServer.call(endpoint_B, {:oobi, vid_b0, vid_a0, endpoint_A}) + :ok = GenServer.call(endpoint_B, {:oobi, vid_b1, vid_a1, endpoint_A}) + + # sender (A) creates inner message + payload = "12345" + {:ok, inner_message} = GenServer.call(endpoint_A, {:create_message, vid_a0, vid_b0, [], payload}) + inner_message_bytes = Message.to_b64(inner_message) + + # sender (A) creates outer message + {:ok, outer_message} = GenServer.call(endpoint_A, {:create_message, vid_a1, vid_b1, [], inner_message_bytes}) + + # verify outer signature (either endpoint can verify with the sender's public sig key) + assert :true == GenServer.call(endpoint_A, {:verify_signature, vid_a1, outer_message}) + assert :true == GenServer.call(endpoint_B, {:verify_signature, vid_a1, outer_message}) + + # receiving endpoint (B) unseals the inner message + unsealed_bytes = GenServer.call(endpoint_B, {:unseal_payload, vid_b1, outer_message}) + assert unsealed_bytes == inner_message_bytes + unsealed_message = Message.from_b64(unsealed_bytes) + + # verify inner signature + assert :true == GenServer.call(endpoint_A, {:verify_signature, vid_a0, unsealed_message}) + assert :true == GenServer.call(endpoint_B, {:verify_signature, vid_a0, unsealed_message}) + + # verify inner payload + assert payload == GenServer.call(endpoint_B, {:unseal_payload, vid_b0, unsealed_message}) + end + + test "Routed Messages" do + # Direct Neighbor Relationship and Routing (https://trustoverip.github.io/tswg-tsp-specification/#direct-neighbor-relationship-and-routing) + # +-+ p0 q0 +-+ A & B are endpoints. They want to hide the fact they're communicating. + # |P| <----> |Q| P is A's intermediary and Q is B's intermediary. + # +-+ +-+ A & P communicate with vids a1 and p1. + # ^ p1 ^ q1 P & Q communicate with vids p0 and q0. + # | | Q & B communicate with vids q1 and b1. + # v a1 v b1 A & B don't communicate directly. Assuming that P & Q are high-traffic + # +-+ +-+ nodes, an attacker can't prove that A & B have communicated. + # |A| |B| + # +-+ +-+ + + # set up endpoints, intermediaries, and vids + {:ok, endpoint_A} = GenServer.start_link(Endpoint, :empty, []) + {:ok, intermediary_P} = GenServer.start_link(Endpoint, :empty, []) + {:ok, intermediary_Q} = GenServer.start_link(Endpoint, :empty, []) + {:ok, endpoint_B} = GenServer.start_link(Endpoint, :empty, []) + [vid_a1, vid_p1, vid_p0, vid_q0, vid_q1, vid_b1] = ["a1", "p1", "p0", "q0", "q1", "b1"] + :ok = GenServer.call(endpoint_A, {:add_vid, vid_a1}) + :ok = GenServer.call(intermediary_P, {:add_vid, vid_p1}) + :ok = GenServer.call(intermediary_P, {:add_vid, vid_p0}) + :ok = GenServer.call(intermediary_Q, {:add_vid, vid_q0}) + :ok = GenServer.call(intermediary_Q, {:add_vid, vid_q1}) + :ok = GenServer.call(endpoint_B, {:add_vid, vid_b1}) + :ok = GenServer.call(endpoint_A, {:oobi, vid_a1, vid_p1, intermediary_P}) # A <-> P + :ok = GenServer.call(intermediary_P, {:oobi, vid_p1, vid_a1, endpoint_A}) # A <-> P + :ok = GenServer.call(intermediary_P, {:oobi, vid_p0, vid_q0, intermediary_Q}) # P <-> Q + :ok = GenServer.call(intermediary_Q, {:oobi, vid_q0, vid_p0, intermediary_P}) # P <-> Q + :ok = GenServer.call(intermediary_Q, {:oobi, vid_q1, vid_b1, endpoint_B}) # Q <-> B + :ok = GenServer.call(endpoint_B, {:oobi, vid_b1, vid_q1, intermediary_Q}) # Q <-> B + payload = "12345" + + # try some shorter routes first + {:ok, empty_hop_list} = GenServer.call(endpoint_A, {:create_message, vid_a1, vid_p1, [], payload}) + :error_empty_hop_list = GenServer.call(intermediary_P, {:route_message, empty_hop_list}) + + {:ok, wrong_recipient} = GenServer.call(endpoint_A, {:create_message, vid_a1, vid_p1, [vid_q1], payload}) + :error_wrong_recipient = GenServer.call(intermediary_P, {:route_message, wrong_recipient}) + + {:ok, single_hop_message} = GenServer.call(endpoint_A, {:create_message, vid_a1, vid_p1, [vid_p1], payload}) + {:route_done, ^payload} = GenServer.call(intermediary_P, {:route_message, single_hop_message}) + + # and now the full route from A -> P -> Q -> B + {:ok, message_A_to_P} = GenServer.call(endpoint_A, {:create_message, vid_a1, vid_p1, + [vid_p1, vid_q0, vid_b1], payload}) + {:next_route, ^intermediary_Q, message_P_to_Q} = GenServer.call(intermediary_P, {:route_message, message_A_to_P}) + {:next_route, ^endpoint_B, message_Q_to_B} = GenServer.call(intermediary_Q, {:route_message, message_P_to_Q}) + {:route_done, ^payload} = GenServer.call(endpoint_B, {:route_message, message_Q_to_B}) + end + + test "Endpoint-to-Endpoint Message" do + # 5.4 Endpoint-to-Endpoint Messages + # https://trustoverip.github.io/tswg-tsp-specification/#endpoint-to-endpoint-messages + + # set up endpoints, intermediaries, and vids + {:ok, endpoint_A} = GenServer.start_link(Endpoint, :empty, []) + {:ok, intermediary_P} = GenServer.start_link(Endpoint, :empty, []) + {:ok, intermediary_Q} = GenServer.start_link(Endpoint, :empty, []) + {:ok, endpoint_B} = GenServer.start_link(Endpoint, :empty, []) + [vid_a1, vid_a2, vid_p1, vid_p0, vid_q0, vid_q1, vid_b1, vid_b2] = + ["a1", "a2", "p1", "p0", "q0", "q1", "b1", "b2"] + :ok = GenServer.call(endpoint_A, {:add_vid, vid_a1}) + :ok = GenServer.call(endpoint_A, {:add_vid, vid_a2}) + :ok = GenServer.call(intermediary_P, {:add_vid, vid_p1}) + :ok = GenServer.call(intermediary_P, {:add_vid, vid_p0}) + :ok = GenServer.call(intermediary_Q, {:add_vid, vid_q0}) + :ok = GenServer.call(intermediary_Q, {:add_vid, vid_q1}) + :ok = GenServer.call(endpoint_B, {:add_vid, vid_b1}) + :ok = GenServer.call(endpoint_B, {:add_vid, vid_b2}) + :ok = GenServer.call(endpoint_A, {:oobi, vid_a1, vid_p1, intermediary_P}) # A <-> P + :ok = GenServer.call(intermediary_P, {:oobi, vid_p1, vid_a1, endpoint_A}) # A <-> P + :ok = GenServer.call(intermediary_P, {:oobi, vid_p0, vid_q0, intermediary_Q}) # P <-> Q + :ok = GenServer.call(intermediary_Q, {:oobi, vid_q0, vid_p0, intermediary_P}) # P <-> Q + :ok = GenServer.call(intermediary_Q, {:oobi, vid_q1, vid_b1, endpoint_B}) # Q <-> B + :ok = GenServer.call(endpoint_B, {:oobi, vid_b1, vid_q1, intermediary_Q}) # Q <-> B + :ok = GenServer.call(endpoint_A, {:oobi, vid_a2, vid_b2, endpoint_B}) # A <-> B + :ok = GenServer.call(endpoint_B, {:oobi, vid_b2, vid_a2, endpoint_A}) # A <-> B + payload = "12345" + + # Create the inner message first and convert to bytes + {:ok, a_end_to_end_b} = GenServer.call(endpoint_A, {:create_message, vid_a2, vid_b2, [], payload}) + a_end_to_end_b_bytes = Message.to_b64(a_end_to_end_b) + + # Route the inner message from A -> P -> Q -> B + {:ok, message_A_to_P} = GenServer.call(endpoint_A, {:create_message, vid_a1, vid_p1, + [vid_p1, vid_q0, vid_b1], a_end_to_end_b_bytes}) + {:next_route, ^intermediary_Q, message_P_to_Q} = GenServer.call(intermediary_P, {:route_message, message_A_to_P}) + {:next_route, ^endpoint_B, message_Q_to_B} = GenServer.call(intermediary_Q, {:route_message, message_P_to_Q}) + {:route_done, ^a_end_to_end_b_bytes} = GenServer.call(endpoint_B, {:route_message, message_Q_to_B}) + + # Unseal and verify the inner message + unsealed_message = Message.from_b64(a_end_to_end_b_bytes) + + # Verify inner signature + assert :true == GenServer.call(endpoint_A, {:verify_signature, vid_a2, unsealed_message}) + assert :true == GenServer.call(endpoint_B, {:verify_signature, vid_a2, unsealed_message}) + + # Verify inner payload + assert payload == GenServer.call(endpoint_B, {:unseal_payload, vid_b2, unsealed_message}) + end + + test "Tag" do + {{:ok, tag}, ""} = CD_Y_Tag7.from_b64("YTSP-AAB") + assert tag == %CD_Y_Tag7{ + code: "Y", + payload: <<77, 35, 254, 0, 0, 1::size(2)>> + } + assert CesrElement.to_b64(tag) == "YTSP-AAB" + # to translate the payload back to base64 manually: + # 77 35 254 0 0 1::size(2) <- payload in bytes + # 01001101 00100011 11111110 00000000 00000000 01 <- convert to bits + # 010011 010010 001111 111110 000000 000000 000001 <- group by 6 (=sizeof(b64)) + # 19 18 15 62 0 0 1 <- convert to decimal + # T S P - A A B <- lookup in b64 table + end + + test "Basic encryption" do + keystore = Keystore.random() + payload = "12345" + encrypted = :libsodium_crypto_box.seal(payload, keystore.crypto_public_key) + assert payload == :libsodium_crypto_box.seal_open(encrypted, + keystore.crypto_public_key, keystore.crypto_secret_key) + end + + test "Basic signing" do + keystore = Keystore.random() + payload = "12345" + signed_msg = :libsodium_crypto_sign_ed25519.crypto_sign_ed25519(payload, keystore.sig_secret_key) + assert payload == :libsodium_crypto_sign_ed25519.open(signed_msg, keystore.sig_public_key) + end +end