From aa7a5e89ec043c0a96adbbebc599100faee6cb90 Mon Sep 17 00:00:00 2001 From: veclav talica Date: Sat, 26 Aug 2023 20:16:36 +0500 Subject: [PATCH] initial tls, sasl and bind stages impl --- project.godot | 35 +++++ scenes/App.gd | 6 + scenes/App.tscn | 14 ++ scenes/Connections.gd | 292 ++++++++++++++++++++++++++++++++++++++++++ scenes/Xml.gd | 92 +++++++++++++ 5 files changed, 439 insertions(+) create mode 100644 project.godot create mode 100644 scenes/App.gd create mode 100644 scenes/App.tscn create mode 100644 scenes/Connections.gd create mode 100644 scenes/Xml.gd diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..e4d9048 --- /dev/null +++ b/project.godot @@ -0,0 +1,35 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=4 + +[application] + +config/name="tochie" +run/main_scene="res://scenes/App.tscn" +run/low_processor_mode=true +config/icon="res://icon.png" + +[autoload] + +Xml="*res://scenes/Xml.gd" + +[gui] + +common/drop_mouse_on_gui_input_disabled=true + +[physics] + +common/enable_pause_aware_picking=true + +[rendering] + +quality/driver/driver_name="GLES2" +vram_compression/import_etc=true +vram_compression/import_etc2=false +environment/default_environment="res://default_env.tres" diff --git a/scenes/App.gd b/scenes/App.gd new file mode 100644 index 0000000..9c65ba4 --- /dev/null +++ b/scenes/App.gd @@ -0,0 +1,6 @@ +extends Node + +func _ready(): + var connection = $Connections.establish_new_connection("poto.cafe", "veclavtalica", "-") + if connection == null: + push_error("Connection failed") diff --git a/scenes/App.tscn b/scenes/App.tscn new file mode 100644 index 0000000..c86c635 --- /dev/null +++ b/scenes/App.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=3 format=2] + +[ext_resource path="res://scenes/Connections.gd" type="Script" id=1] +[ext_resource path="res://scenes/App.gd" type="Script" id=2] + +[node name="App" type="Node"] +script = ExtResource( 2 ) + +[node name="Shell" type="VBoxContainer" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Connections" type="Node" parent="."] +script = ExtResource( 1 ) diff --git a/scenes/Connections.gd b/scenes/Connections.gd new file mode 100644 index 0000000..7c29592 --- /dev/null +++ b/scenes/Connections.gd @@ -0,0 +1,292 @@ +extends Node + +class Connection extends Reference: + var stream: StreamPeer + var identity: String + var domain: String + var bare_jid: String + var jid: String + var id_counter: int = 0 + + func generate_id() -> int: + self.id_counter += 1 + return hash(self.id_counter) + +func establish_new_connection(domain: String, identity: String, password: String) -> Connection: + ## XMPP uses unidirectional pipes for communication, which means + ## multiple connections are open over different predefined ports. + var stream := StreamPeerTCP.new() + if stream.connect_to_host(domain, 5222) != OK: + push_error("Cannot establish client->server pipe to " + domain) + return null + + while stream.get_status() == StreamPeerTCP.STATUS_CONNECTING: + pass + + if stream.get_status() == StreamPeerTCP.STATUS_ERROR: + push_error("Cannot establish client->server pipe to " + domain) + return null + + var result := Connection.new() + result.stream = stream + result.identity = identity + result.domain = domain + result.bare_jid = identity + '@' + domain + + if _negotiate_connection(result, password) != OK: + return null + + return result + +# todo: Make it async +func _negotiate_connection(connection: Connection, password: String) -> int: + ## See [9. Detailed Examples] of https://datatracker.ietf.org/doc/rfc6120/ + var error: int = OK + + error = _negotiate_tls(connection) + if error != OK: return error + + error = _negotiate_sasl(connection, password) + if error != OK: return error + + error = _bind_resource(connection) + if error != OK: return error + + return OK + +func _form_initial_message(domain: String, bare_jid: String) -> String: + # todo: XML builder interface, writing by hand is extremely error prone. + return """""".format({ + "bare_jid": bare_jid.xml_escape(), "domain": domain.xml_escape()}) + +func _negotiate_tls(connection: Connection) -> int: + var error: int = OK + var response + var parsed_response = Xml.Parser.new() + + ## Step 1: Client initiates stream to server: + if connection.stream.put_data(_form_initial_message( + connection.domain, connection.bare_jid).to_utf8()) != OK: + return ERR_CONNECTION_ERROR + + ## Step 2: Server responds by sending a response stream header to client: + response = _wait_blocking_for_utf8_data(connection.stream) + if response == null: return ERR_CONNECTION_ERROR + if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR + # If is closed, - connection is closed too. + + ## Check that server response corresponds to what we ask for. + # todo: For conformity client must send closing stream tag on error. + if parsed_response.root.node_name != "stream:stream": return ERR_CANT_CONNECT + if parsed_response.root.attributes["from"] != connection.domain: return ERR_CANT_CONNECT + if "to" in parsed_response.root.attributes: + if parsed_response.root.attributes["to"] != connection.bare_jid: return ERR_CANT_CONNECT + if parsed_response.root.attributes["version"] != "1.0": return ERR_CANT_CONNECT + # todo: Assert for namespaces. + + ## Step 3: Server sends stream features to client (only the STARTTLS + ## extension at this point, which is mandatory-to-negotiate): + if parsed_response.root.children.size() == 0: + response = _wait_blocking_for_utf8_data(connection.stream) + if response == null: return ERR_CONNECTION_ERROR + if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR + + var features = parsed_response.root.take_named_child_element("stream:features") + if features == null: return ERR_CANT_CONNECT + var starttls = features.take_named_child_element("starttls") + if starttls == null or starttls.attributes["xmlns"] != "urn:ietf:params:xml:ns:xmpp-tls": + push_error("Connection declined, TLS is not a listed feature") + return ERR_CANT_CONNECT + + ## Step 4: Client sends STARTTLS command to server: + if connection.stream.put_data("".to_utf8()) != OK: + return ERR_CONNECTION_ERROR + + ## Step 5: Server informs client that it is allowed to proceed: + response = _wait_blocking_for_utf8_data(connection.stream) + if response == null: return ERR_CONNECTION_ERROR + if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR + + # todo: Handle failure case. + + var proceed = parsed_response.root.take_named_child_element("proceed") + if proceed == null: return ERR_CANT_CONNECT + if proceed.attributes["xmlns"] != "urn:ietf:params:xml:ns:xmpp-tls": return ERR_CANT_CONNECT + + ## Step 6: Client and server attempt to complete TLS negotiation over + ## the existing TCP connection (see [TLS] for details). + var tls_stream := StreamPeerSSL.new() + error = tls_stream.connect_to_stream(connection.stream, true, connection.domain) + if error != OK: + push_error("Cannot establish client->server TLS connection") + return error + + connection.stream = tls_stream + + ## Step 7 (alt): If TLS negotiation is unsuccessful, server closes TCP + ## connection (thus, the stream negotiation process ends unsuccessfully + ## and the parties do not move on to the next step): + + return OK + +func _negotiate_sasl(connection: Connection, password: String) -> int: + var response + var parsed_response = Xml.Parser.new() + + ## Step 7: If TLS negotiation is successful, client initiates a new + ## stream to server over the TLS-protected TCP connection: + if connection.stream.put_data(_form_initial_message( + connection.domain, connection.bare_jid).to_utf8()) != OK: + return ERR_CONNECTION_ERROR + + ## Step 8: Server responds by sending a stream header to client along + ## with any available stream features: + + response = _wait_blocking_for_utf8_data(connection.stream) + if response == null: return ERR_CONNECTION_ERROR + if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR + + ## Check that server response corresponds to what we ask for. + # todo: For conformity client must send closing stream tag on error. + if parsed_response.root.node_name != "stream:stream": return ERR_CANT_CONNECT + if parsed_response.root.attributes["from"] != connection.domain: return ERR_CANT_CONNECT + if "to" in parsed_response.root.attributes: + if parsed_response.root.attributes["to"] != connection.bare_jid: return ERR_CANT_CONNECT + if parsed_response.root.attributes["version"] != "1.0": return ERR_CANT_CONNECT + # todo: Assert for namespaces. + + if parsed_response.root.children.size() == 0: + response = _wait_blocking_for_utf8_data(connection.stream) + if response == null: return ERR_CONNECTION_ERROR + if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR + + var features = parsed_response.root.take_named_child_element("stream:features") + if features == null: return ERR_CANT_CONNECT + var mechanisms = features.take_named_child_element("mechanisms") + if mechanisms == null or mechanisms.attributes["xmlns"] != "urn:ietf:params:xml:ns:xmpp-sasl": + push_error("Connection declined, mechanisms is not a listed feature") + return ERR_CANT_CONNECT + + ## We only support PLAIN mechanism as it's sufficient over TLS. + var plain_found := false + for machanism in mechanisms.children: + if machanism.is_element() and machanism.node_name == "mechanism": + if machanism.children[0].data == "PLAIN": + plain_found = true + break + + if not plain_found: + push_error("Connection declined, mechanisms don't include PLAIN method") + return ERR_CANT_CONNECT + + ## Step 9: Client selects an authentication mechanism, including initial response data: + ## https://datatracker.ietf.org/doc/html/rfc4616 + if connection.stream.put_data(( + "%s" % + [_plain_auth_message(connection.identity, password)]).to_utf8()) != OK: + return ERR_CONNECTION_ERROR + + ## (Skipped steps related to another auth machanism) + + ## Step 12: Server informs client of success, including additional data + ## with success: + response = _wait_blocking_for_utf8_data(connection.stream) + if response == null: return ERR_CONNECTION_ERROR + if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR + + var success = parsed_response.root.take_named_child_element("success") + if success == null or success.attributes["xmlns"] != "urn:ietf:params:xml:ns:xmpp-sasl": + return ERR_CANT_CONNECT + + return OK + +func _bind_resource(connection: Connection, resource: String = "tochie-facade") -> int: + var response + var parsed_response = Xml.Parser.new() + + ## Step 13: Client initiates a new stream to server: + if connection.stream.put_data(_form_initial_message( + connection.domain, connection.bare_jid).to_utf8()) != OK: + return ERR_CONNECTION_ERROR + + ## Step 14: Server responds by sending a stream header to client along + response = _wait_blocking_for_utf8_data(connection.stream) + if response == null: return ERR_CONNECTION_ERROR + if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR + + ## Check that server response corresponds to what we ask for. + # todo: For conformity client must send closing stream tag on error. + if parsed_response.root.node_name != "stream:stream": return ERR_CANT_CONNECT + if parsed_response.root.attributes["from"] != connection.domain: return ERR_CANT_CONNECT + if "to" in parsed_response.root.attributes: + if parsed_response.root.attributes["to"] != connection.bare_jid: return ERR_CANT_CONNECT + if parsed_response.root.attributes["version"] != "1.0": return ERR_CANT_CONNECT + # todo: Assert for namespaces. + + if parsed_response.root.children.size() == 0: + response = _wait_blocking_for_utf8_data(connection.stream) + if response == null: return ERR_CONNECTION_ERROR + if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR + + var features = parsed_response.root.take_named_child_element("stream:features") + if features == null: return ERR_CANT_CONNECT + var bind = features.take_named_child_element("bind") + if bind == null or bind.attributes["xmlns"] != "urn:ietf:params:xml:ns:xmpp-bind": + push_error("Connection declined, bind is not a listed feature") + return ERR_CANT_CONNECT + + ## Step 15: Client binds a resource: + var iq_id := connection.generate_id() + if connection.stream.put_data(""" + + {resource} + """.format({ + "id": iq_id, + "resource": resource + }).to_utf8()) != OK: + return ERR_CONNECTION_ERROR + + response = _wait_blocking_for_utf8_data(connection.stream) + if response == null: return ERR_CONNECTION_ERROR + if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR + + ## Step 16: Server accepts submitted resourcepart and informs client of + ## successful resource binding: + var iq = parsed_response.root.take_named_child_element("iq") + if iq.attributes["id"] != String(iq_id): + push_error("iq id mismatch, expected: " + String(iq_id) + " but got: " + iq.attributes["id"]) + return ERR_CANT_CONNECT + + bind = iq.take_named_child_element("bind") + if bind == null or bind.attributes["xmlns"] != "urn:ietf:params:xml:ns:xmpp-bind": + return ERR_CANT_CONNECT + + var jid = bind.take_named_child_element("jid") + if jid == null: return ERR_CANT_CONNECT + + connection.jid = jid.children[0].data + + return OK + +func _plain_auth_message(identity: String, password: String) -> String: + var raw = PoolByteArray([0]) + raw.append_array(identity.to_utf8() + PoolByteArray([0])) + raw.append_array(password.to_utf8()) + return Marshalls.raw_to_base64(raw) + +func _wait_blocking_for_utf8_data(stream: StreamPeer, timeout_ms: int = 1000): # -> String or null: + var enter_time := OS.get_system_time_msecs() + + if stream is StreamPeerSSL: stream.poll() + var available_bytes := stream.get_available_bytes() + + while available_bytes == 0: + if OS.get_system_time_msecs() - enter_time >= timeout_ms: + return null + if stream is StreamPeerSSL: stream.poll() + available_bytes = stream.get_available_bytes() + + return stream.get_utf8_string(available_bytes) diff --git a/scenes/Xml.gd b/scenes/Xml.gd new file mode 100644 index 0000000..f53a445 --- /dev/null +++ b/scenes/Xml.gd @@ -0,0 +1,92 @@ +extends Node + +class XmlNode extends Reference: + func is_element() -> bool: return false + func is_text() -> bool: return false + +class XmlElement extends XmlNode: + var node_name: String + var attributes: Dictionary # String to String + var children: Array # of XmlNode + + func is_element() -> bool: return true + + func take_named_child_element(p_node_name: String) -> XmlElement: + for idx in range(self.children.size()): + var child := self.children[idx] as XmlElement + if child != null and child.node_name == p_node_name: + self.children.remove(idx) + return child + return null + + func as_string(level = 0) -> String: + var result = '\t'.repeat(level) + "XmlElement \"%s\"" % self.node_name + if self.attributes.size() > 0: + result += ", Attributes: " + String(self.attributes) + for child in self.children: + result += '\n' + '\t '.repeat(level) + child.as_string(level + 1) + return result + +class XmlText extends XmlNode: + var data: String + + func is_text() -> bool: return true + + func as_string(level = 0) -> String: + return "XmlText \"%s\"" % self.data + +## Wrapper over XMLParser for TCP stream oriented XML data. +# todo: Save namespaces. +class Parser extends Reference: + var root: XmlElement = null + var _element_stack: Array # of XmlElement + + ## Returns true if root element is closed, otherwise more tags are expected to come later. + func parse_a_bit(data): # -> bool or Error: + var error: int + var parser := XMLParser.new() + if data is String: data = data.to_utf8() + error = parser.open_buffer(data) + if error != OK: + push_error("Error opening a buffer for XML parsing") + return error + + while parser.read() == OK: + if parser.get_node_type() == XMLParser.NODE_ELEMENT: + var element := XmlElement.new() + + if self.root != null: + self._element_stack[self._element_stack.size() - 1].children.push_back(element) + else: self.root = element + + if not parser.is_empty(): + self._element_stack.push_back(element) + + element.node_name = parser.get_node_name() + var attribute_count := parser.get_attribute_count() + for idx in range(attribute_count): + element.attributes[parser.get_attribute_name(idx)] = parser.get_attribute_value(idx) + + elif parser.get_node_type() == XMLParser.NODE_ELEMENT_END: + if self._element_stack.size() == 0: + push_error("Closing element closes nothing" % [parser.get_node_name()]) + return ERR_PARSE_ERROR + + var popped := self._element_stack.pop_back() as XmlElement + if popped.node_name != parser.get_node_name(): + push_error("Element <%s> closes sooner than <%s>" % [parser.get_node_name(), popped.node_name]) + return ERR_PARSE_ERROR + + elif parser.get_node_type() == XMLParser.NODE_TEXT: + if self._element_stack.size() == 0: + push_error("Text node should be a child of an element") + return ERR_PARSE_ERROR + + var text = XmlText.new() + text.data = parser.get_node_data() + self._element_stack[self._element_stack.size() - 1].children.push_back(text) + + else: + push_warning("Node type unimplemented and ignored") + + return self._element_stack.size() == 0