Implementing a simple DTLS CoAP client on Raspberry Pi

7 May 2022
# Span# DTLS# Raspberry Pi# ESP32# ESP-IDF

About CoAP

If you aren't familiar with CoAP here's a quick feature list:

  • Lightweight protocol
  • Roughly based on the HTTP protocol
  • Suitable for UDP (and DTLS) transports

The protocol itself is quite lightweight and you can fit the header in a few bytes. At first glance the nomenclature is quite familiar if you've dug into HTTP. There's requests, responses, error codes and content types. The response codes are more or less identical to HTTP but the notation is slightly different. In HTTP every status code is three-digit integer but in CoAP it's split into two parts so rather than 200 OK you'll get a 2.00 response code. Ditto for 404 NOT FOUND in HTTP - the CoAP equivalent is 4.04. There's no redirects or continue response in CoAP so you'll only encounter 2.xx, 4.xx and 5.xx response codes. Sadly there's no 418 I'm a teapot either.

A subset of the verbs in HTTP is also used so you'll see GET, POST and DELETE in CoAP but no HEADER, OPTIONS or TRACE verbs.

A request can be confirmable or non-confirmable. A confirmable request gets a response from the server (or client).

Most major embedded frameworks have support for CoAP but implementing a small subset of the protocol is quite straightforward.

You might se "blockwise transfers" mentioned and it's the CoAP way of transferring large files but as a rule of thumb you want to keep the payloads reasonable small so that a single packet fits into the MTU of the network. A good rule of thumb is to keep the packets under 256 bytes for both headers and payload.

You can run CoAP on top of any transport but it's commonly used on top of UDP and DTLS.

CoAP is specified in RFC 7252.

Using libcoap

Libcoap is a library that is used by the ESP-IDF framework but works equally well standalone. Libcoap wraps the mbedtls library for DTLS support.

Setting up

Start by initialising the library with a call to coap_startup and optionally set the log levels. Increase the log levels if are troubleshooting but LOG_NOTICE should be fine for normal operations.

coap_startup();
coap_dtls_set_log_level(LOG_NOTICE);
coap_set_log_level(LOG_NOTICE);

Next, resolve the addresses you are going to use. You'll have to resolve both the server and the local address. I'm using a helper function named resolve_address that will resolve a string to a struct sockaddr:

coap_address_t server;
coap_address_init(&server);
if (!resolve_address("data.lab5e.com", &server.addr.sa)) {
  exit(1);
}
server.addr.sin.sin_port = htons(5684);

coap_address_t local;
coap_address_init(&local);
if (!resolve_address("0.0.0.0", &local.addr.sa)) {
  exit(2);
}

Once the addresses are resolved you'll need a context for the CoAP client. This is a simple one-liner:

coap_context_t *ctx = coap_new_context(NULL);
if (!ctx) {
  exit(3);
}
coap_context_set_keepalive(ctx, 10);

Next the client certificate must be configured. This will read the certificate chain from cert.crt and the private key from key.pem:

coap_dtls_pki_t dtls;
memset(&dtls, 0, sizeof(dtls));
dtls.version = COAP_DTLS_PKI_SETUP_VERSION;

dtls.verify_peer_cert = 1;        // Verify peer certificate
dtls.require_peer_cert = 1;       // Require a server certificate
dtls.allow_self_signed = 1;       // Allow self signed certificate
dtls.allow_expired_certs = 0;     // No expired certificates
dtls.cert_chain_validation = 1;   // Validate the chain
dtls.check_cert_revocation = 0;   // Check the revocation list
dtls.cert_chain_verify_depth = 2; // Depth of validation.

dtls.pki_key.key_type = COAP_PKI_KEY_PEM;
dtls.pki_key.key.pem.public_cert = "cert.crt";
dtls.pki_key.key.pem.private_key = "key.pem";
dtls.pki_key.key.pem.ca_file = "cert.crt";

coap_session_t *session =
    coap_new_client_session_pki(ctx, &local, &server, COAP_PROTO_DTLS, &dtls);

if (!session) {
  exit(4);
}

Callbacks

This should take care of all the basics. The responses and status updates are handled via callbacks that you must implement. There are two callbacks that you'll need. The first one is the message handler which is called every time a message is received from the server:

static void message_handler(coap_context_t *ctx, coap_session_t *session,
                            coap_pdu_t *sent, coap_pdu_t *received,
                            const coap_tid_t id) {
  switch (COAP_RESPONSE_CLASS(received->code)) {

  case 4:
    // Server reported a 4.xx error, ie a "not found" response
    break;

  case 5:
    // Server reported a 5.xx error, ie an error server-side
    break;

  case 2:
    // 2.xx is a success, either when POSTing something or GETting something. There might be a payload in the
    // response message that you should read.
    size_t len = 0;
    uint8_t *data = NULL;
    if (coap_get_data(received, &len, &data) == 0) {
      break;
    }
    if (len > 0) {
      char str[512];
      memset(str, 0, sizeof(str));
      strncpy(str, data, sizeof(str) - 1);
      // Process response data
    }

    break;
  default:
    // Some other response code was returnwed.
    break;
  }
}

Next is the NACK handler. This is called when there are other kinds of errors:

void nack_handler(coap_context_t *context, coap_session_t *session,
                  coap_pdu_t *sent, coap_nack_reason_t reason,
                  const coap_tid_t id) {
  switch (reason) {
  case COAP_NACK_TOO_MANY_RETRIES:
    break;
  case COAP_NACK_NOT_DELIVERABLE:
    break;
  case COAP_NACK_RST:
    break;
  case COAP_NACK_TLS_FAILED:
    break;
  default:
    break;
  }
}

In addition there's a DTLS status handler that can be useful for diagnosing DTLS issues. The sample code includes a handler for this.

Wiring the handlers is just a matter of calling the following functions:

coap_register_response_handler(ctx, message_handler);
coap_register_nack_handler(ctx, nack_handler);
coap_register_event_handler(ctx, event_handler);

Sending a request

To send a request you create a PDU struct, set the type, message ID and appropriate options.

coap_pdu_t *request = coap_new_pdu(session);
if (!request) {
  exit(5);
}

request->type = COAP_MESSAGE_CON;
request->tid = coap_new_message_id(session);
request->code = COAP_REQUEST_POST;

coap_optlist_t *optlist = NULL;

const char *path = "path-for-data";
const char *hostname = "data.lab5e.com";
const char *port = "5684";

coap_insert_optlist(&optlist, coap_new_optlist(COAP_OPTION_URI_PORT,
                                                coap_opt_length(portstr),
                                                coap_opt_value(portstr)));
coap_insert_optlist(&optlist, coap_new_optlist(COAP_OPTION_URI_HOST,
                                                coap_opt_length(hostname),
                                                coap_opt_value(hostname)));
coap_insert_optlist(&optlist, coap_new_optlist(COAP_OPTION_URI_PATH,
                                                coap_opt_length(path),
                                                coap_opt_value(path)));

coap_add_optlist_pdu(request, &optlist);

Call coap_add_data to set the payload:

const char *data = "this is the payload";
coap_add_data(request, sizeof(data), data);

Next the coap_send enqueues the message to the server:

coap_tid_t tid = coap_send(session, request);
if (tid == COAP_INVALID_TID) {
  exit(6);
}

Note that this won't send the message. To send it you must call coap_run_once regularly. This runs until the exchange has completed:

while (!coap_can_exit(ctx)) {
  coap_run_once(ctx, 1000);
}

The second parameter is a timeout (in milliseconds) for the read loop.

Shutting down

Cleaning up and shutting down is quite simple. Delete any option lists you have created, release the session and free the context before calling coap_cleanup:

coap_delete_optlist(optlist);
coap_session_release(session);
coap_free_context(ctx);

coap_cleanup();
}

A complete working example is avialable at GitHub.

Getting everything set up

It's quite easy to get a Raspberry Pi up and running nowadays. I'm using a Raspberry Pi Zero so the majority of Raspberry Pis will work for this. The Raspberry Pi Imager gets a new image ready in a few minutes and since I can't be bothered with digging out yet another HDMI cable so I'm adding the usual /boot/ssh,/boot/userconf.txt and /boot/wpa_supplicant.conf files before unmounting the image.

Plug in the SD card, connect USB power and since mDNS via Avahi is turned on by default you should be able to run ssh pi@raspberry.local a minute or two later.

Change the default password (via passwd) and set the hostname via sudo hostnamectl and editing the /etc/avahi/avahi-daemon.conf file to get the appropriate mDNS name. To make my life a little bit easier I'm adding my ssh public key into ~/.ssh/authorized_keys so I won't have to bother with passwords anymore.

Start by cloning the repository from Git:

git clone https://github.com/lab5e/libcoap-dtls-sample

Then install the libcoap development headers:

sudo apt-get install libcoap2-dev gcc

..and you're set. Build it by running cd ~/libcoap-dtls-sample && make.

To run the sample you'll have to get hold of a client certificate for the device. Run the Span CLI to create a new collection and device:

eval $(span collection add --eval --tag=name:"CoAP Demo")
eval $(span device add --eval --tag="name:"RPI Zero CoAP")

The eval command is just for convenience. This will set an environment variable that the Span CLI will use later and it saves us a bit of typing.

Add collection and device

span cert csr --email=[email address] --org="[org]"

This should leave you with two new files in the current directory -- cert.crt with the client certificate and key.pem with the private key.

Create certificate

If you're running the Span CLI on a different computer copy the two via scp:

scp cert.crt pi@raspberry.local:libcoap-dtls-sample/
scp key.pem pi@raspberry.local:libcoap-dtls-sample/

SSH into the Pi Zero and when you run the sample it should connect and send a sample message:

Send message to Span

Either go to the Span Console to see the payload or use the Span CLI:

span inbox list

You can add --decode to the command line to decode the payload. Note that this might give unpredicatble results if the payload isn't in plain text:

List inbox

Downloading messages from the server

You can enqueue messages to the client with the outbox command. The next time the device sends a payload to the server it will get the enqueued message from the server.

Start by adding a new message via the Span CLI:

span outbox add --format=text Hello this is a downstream message

If you run span outbox list before you run the sample program you'll see that the message state is set to pending. Once the message is delivered it wil be flagged as sent:

Send downstream message

Play around with the inbox watch and outbox watch to see messages in real-time from the device:

Watch inbox