cr-xmpp

0.2.0 released
naqvis/cr-xmpp
16 1 4
Ali Naqvi

Crystal XMPP

CI GitHub release Docs

Pure Crystal XMPP Shard, focusing on simplicity, simple automation, and IoT.

The goal is to make simple to write simple XMPP clients and components. It features:

  • Fully OOP
  • Aims at being XMPP compliant
  • Event Based
  • Easy to extend
  • For automation (like for example monitoring of an XMPP service),
  • For building connected "things" by plugging them on an XMPP server,
  • For writing simple chatbot to control a service or a thing,
  • For writing XMPP servers components.

You can basically do everything you want with cr-xmpp. It fully supports XMPP Client and components specification, and also a wide range of extensions (XEPs). And it's very easy to extend :)

Dependencies:

  • openssl_ext - Required for channel binding support (provides extended OpenSSL functionality)

Supported specifications

Clients

Components

XEP Extensions

Security & Channel Binding

Installation

  1. Add the dependency to your shard.yml:

    dependencies:
      cr-xmpp:
        github: naqvis/cr-xmpp
    
  2. Run shards install

    This will automatically install the required openssl_ext dependency for channel binding support.

Usage

require "cr-xmpp"

config = XMPP::Config.new(
  host: "localhost",
  jid: "test@localhost",
  password: "test",
  tls: true,          # Enable TLS for secure connections (required for channel binding)
  log_file: STDOUT,   # Capture all out-going and in-coming messages
  # Order of SASL Authentication Mechanism, first matched method supported by server will be used
  # for authentication. Below is default order that will be used if `sasl_auth_order` param is not set.
  # SCRAM-PLUS variants (with channel binding) are preferred for enhanced security
  sasl_auth_order: [XMPP::AuthMechanism::SCRAM_SHA_512_PLUS, XMPP::AuthMechanism::SCRAM_SHA_256_PLUS,
                    XMPP::AuthMechanism::SCRAM_SHA_1_PLUS, XMPP::AuthMechanism::SCRAM_SHA_512,
                    XMPP::AuthMechanism::SCRAM_SHA_256, XMPP::AuthMechanism::SCRAM_SHA_1,
                    XMPP::AuthMechanism::DIGEST_MD5, XMPP::AuthMechanism::PLAIN,
                    XMPP::AuthMechanism::ANONYMOUS]
)

router = XMPP::Router.new

# router.on "presence" do |_, p|  # OR
router.presence do |_, p|
  if (msg = p.as?(XMPP::Stanza::Presence))
    puts msg
  else
    puts "Ignoring Packet: #{p}"
  end
end

# router.when "chat" do |s, p| # OR
router.message do |s, p|
  handle_message(s, p)
end

# OR
# router.on "message", ->handle_message(XMPP::Sender, XMPP::Stanza::Packet)

client = XMPP::Client.new config, router
# If you pass the client to a connection manager, it will handle the reconnect policy
# for you automatically
sm = XMPP::StreamManager.new client
sm.run


def handle_message(s : XMPP::Sender, p : XMPP::Stanza::Packet)
  if (msg = p.as?(XMPP::Stanza::Message))
    puts "Got message: #{msg.body}"
    reply = XMPP::Stanza::Message.new
    reply.to = msg.from
    reply.body = "#{msg.body}"
    s.send reply
  else
    puts "Ignoring Packet: #{p}"
  end
end

Refer to examples for more usage details.

Development & Testing

A Docker Compose setup is provided for easy testing with a local XMPP server:

# 1. Generate SSL certificates (required for TLS/channel binding)
./docker/prosody/generate-certs.sh

# 2. Start XMPP server (test users are created automatically)
docker compose up -d

# 3. Wait a few seconds for server to be ready
sleep 5

# 4. Run examples
XMPP_HOST=localhost XMPP_JID=test@localhost XMPP_PASSWORD=test crystal run examples/xmpp_echo.cr

# View logs
docker compose logs -f prosody

# Stop server
docker compose down

Test accounts created automatically:

  • admin@localhost (password: admin123)
  • test@localhost (password: test)
  • user2@localhost (password: password2)

Note: On ARM64/Apple Silicon, Prosody runs via Rosetta 2 emulation (automatic in Docker Desktop).

See docker/README.md for detailed documentation.

Channel Binding for Enhanced Security

This library supports channel binding for TLS connections, providing protection against man-in-the-middle attacks by cryptographically binding the SASL authentication to the underlying TLS connection.

What is Channel Binding?

Channel binding ensures that the authentication credentials are tied to the specific TLS connection, preventing attackers from intercepting and relaying authentication over a different connection.

Supported Channel Binding Types

  • tls-exporter (RFC 9266) - For TLS 1.3 connections
  • tls-server-end-point (RFC 5929) - For TLS 1.2, 1.3 (fully implemented)
  • tls-unique (RFC 5929) - For TLS ≤ 1.2 (requires OpenSSL FFI)

SCRAM-PLUS Authentication

Channel binding is used with SCRAM mechanisms that have the -PLUS suffix:

  • SCRAM-SHA-512-PLUS (most secure, recommended)
  • SCRAM-SHA-256-PLUS
  • SCRAM-SHA-1-PLUS

These mechanisms are automatically preferred when:

  • TLS is enabled (tls: true)
  • Server advertises support for -PLUS variants
  • Channel binding data is available

Automatic Downgrade Protection (XEP-0474)

The library automatically detects and warns about potential downgrade attacks where an attacker tries to force the use of weaker authentication mechanisms. When a SCRAM-PLUS mechanism is available but a non-PLUS variant is being used, a warning is logged.

Usage Example

require "cr-xmpp"

# Channel binding is enabled automatically with TLS
config = XMPP::Config.new(
  jid: "user@example.com",
  password: "password",
  host: "example.com",
  tls: true  # Required for channel binding
)

client = XMPP::Client.new(config)
# Authentication will automatically use SCRAM-PLUS if server supports it

Server Requirements

For channel binding to work, your XMPP server must:

  1. Support TLS connections
  2. Advertise SCRAM-PLUS mechanisms
  3. Optionally advertise supported channel binding types (XEP-0440)

Example server stream features:

<stream:features>
  <mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl'>
    <mechanism>SCRAM-SHA-256-PLUS</mechanism>
    <mechanism>SCRAM-SHA-256</mechanism>
    <channel-binding type='tls-exporter'/>
    <channel-binding type='tls-server-end-point'/>
  </mechanisms>
</stream:features>

Implementation Status

  • ✅ SCRAM-SHA-512-PLUS, SCRAM-SHA-256-PLUS, SCRAM-SHA-1-PLUS
  • ✅ XEP-0388: Extensible SASL Profile
  • ✅ XEP-0440: Channel binding type capability
  • ✅ XEP-0474: Downgrade protection
  • ✅ tls-server-end-point (fully functional)
  • ⚠️ tls-unique and tls-exporter (require OpenSSL FFI extensions)

Development

XMPP stanzas are basic and extensible XML elements. Stanzas (or sometimes special stanzas called 'nonzas') are used to leverage the XMPP protocol features. During a session, a client (or a component) and a server will be exchanging stanzas back and forth.

At a low-level, stanzas are XML fragments. However, this shard provides the building blocks to interact with stanzas at a high-level, providing a Crystal-friendly API.

The XMPP::Stanza module provides support for XMPP stream parsing, encoding and decoding of XMPP stanza. It is a bridge between high-level Crystal classes and low-level XMPP protocol.

Parsing, encoding and decoding is automatically handled by Crystal XMPP client shard. As a developer, you will generally manipulates only the high-level classes provided by the XMPP::Stanza module.

The XMPP protocol, as the name implies is extensible. If your application is using custom stanza extensions, you can implement your own extensions directly.

Custom Stanza Support

Below example show how to implement a custom extension for your own client, without having to modify or fork Crystal XMPP shard.

class CustomExtension < Extension
    include IQPayload
    class_getter xml_name : XMLName = XMLName.new("my:custom:payload query")
    property node : String = ""

    def self.new(node : XML::Node)
      raise "Invalid node(#{node.name}, expecting #{@@xml_name.to_s}" unless (node.namespace.try &.href == @@xml_name.space) &&
                                                                             (node.name == @@xml_name.local)
      cls = new()
      node.children.select(&.element?).each do |child|
      case child.name
        when "item" then cls.node = child.content
        end
      end
      cls
    end

    def to_xml(elem : XML::Builder)
      elem.element(@@xml_name.local, xmlns: @@xml_name.space) do
        elem.element("node") { elem.text node } unless node.blank?
      end
    end

    def namespace : String
      @@xml_name.space
    end

    def name : String
      @@xml_name.local
    end
  end

  Registry.map_extension(PacketType::IQ, XMLName.new("my:custom:payload", "query"), CustomExtension)

Contributing

  1. Fork it (https://github.com/naqvis/cr-xmpp/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Contributors

cr-xmpp:
  github: naqvis/cr-xmpp
  version: ~> 0.2.0
License MIT
Crystal >= 0.36.0, < 2.0.0

Authors

Dependencies 1

Development Dependencies 0

Dependents 0

Last synced .
search fire star recently