Connecting

The Nabto Edge platform enables clients to establish direct connections to embedded devices - just like a phone central enables one phone to call another phone. Instead of phone numbers, the Nabto Edge platform uses two unique identifiers - a product id and a device id.

Establishing and Using a Connection

Any Nabto Edge Client SDK based app, ranging from a simple test to an advanced production ready app, must provide the following information to establish a connection to a device:

  • Identification of the target device through a product id and a device id. The device is configured with this using the Nabto Cloud Console. The client can then retrieve the info through device discovery or e.g. scanning a QR code with device identification and credentials.
  • A private key for the client to establish an encrypted connection to the target device, typically created in the client application.

For remote connections (ie, connections mediated by the Nabto Basestation), an additional piece of information is needed:

  • Server Connect Token (SCT): This is a random string shared between client, device and basestation. The basestation uses this to prevent DoS attacks on devices - read more in the SCT Guide.

For simplicity in a quick test, you can just hardcode a dummy string as a Server Connect Token in the device and client. The simple device examples in the Nabto Edge Embedded SDK are hardcoded to use the string demosct. So it of course provides no value in terms of DoS protection - but for an initial quick test, it saves you the hassle of synchronizing the token across devices. How to do this in a production system is explained below.

Finally, it is best practice to verify the device authenticity when connecting:

  • The device public key fingerprint is compared to what is cached from the pairing step: The device’s fingerprint is available to the client application after the connection is established. The application can then decide to close the connection if the fingerprint is not as expected.

Again for simplicity in a quick test, this check can be omitted - but should always be done in production. This is described below.

Minimal Example of Connecting

The following shows a minimal working example to establish and use a connection to invoke a CoAP “Hello, world” service (as served by the simple CoAP device example application).

func sayHello() throws {
    let client = Client()
    let connection = try connect(client: client)
    let coap = try connection.createCoapRequest(method: "GET", path: "/hello-world")
    let response = try coap.execute()
    if (response.status == 205) {
        let reply = String(decoding: response.payload, as: UTF8.self)
        print("Device replied: \(reply)")
    } else {
        print("Device returned non-ok status: \(response.status)")
    }
}

func connect(client: Client) throws -> Connection {
    let key = try client.createPrivateKey()
    let connection = try client.createConnection()
    try connection.setPrivateKey(key: key)
    try connection.setProductId(id: "pr-ghgnhgw7")
    try connection.setDeviceId(id: "de-rod4u3y9")
    try connection.setServerConnectToken(sct: "demosct")
    try connection.connect()
    return connection
}
private void sayHello(Context context) {
    try (NabtoClient client = NabtoClient.create(context)) {
        try (Connection connection = connect(client)) {
            try (Coap coap = connection.createCoap("GET", "/hello-world")) {
                coap.execute();
                int response = coap.getResponseStatusCode();
                if (response == 205) {
                    String reply = new String(coap.getResponsePayload());
                    Log.i("Nabto", "Device replied: " + reply);
                } else {
                    Log.i("Nabto", "Device returned non-ok status: " + response);
                }
            }
        }
    }
}

private Connection connect(NabtoClient client) {
    String key = client.createPrivateKey();
    JSONObject options = new JSONObject();
    try {
        options.put("PrivateKey", key);
        options.put("ProductId", "pr-ghgnhgw7");
        options.put("DeviceId", "de-rod4u3y9");
        options.put("ServerConnectToken", "demosct");
    } catch (JSONException e) {
        e.printStackTrace();
    }

    Connection connection = client.createConnection();
    connection.updateOptions(options.toString());
    connection.connect();
    return connection;
}

fun sayHello(context: Context) {
    val client = NabtoClient.create(context)
    val connection = connect(client)
    val coap = connection.createCoap("GET", "/hello-world")
    coap.execute()
    val response = coap.getResponseStatusCode()
    if (response == 205) {
        val reply = String(coap.getResponsePayload())
        Log.i("Nabto", "Device replied: " + reply)
    } else {
        Log.i("Nabto", "Device returned non-ok status: " + response)
    }
}

fun connect(client: NabtoClient): Connection {
    val key = client.createPrivateKey()
    val options = JSONObject()
    try {
        options.put("PrivateKey", key)
        options.put("ProductId", "pr-ghgnhgw7")
        options.put("DeviceId", "de-rod4u3y9")
        options.put("ServerConnectToken", "demosct")
    } catch (e: JSONException) {
        e.printStackTrace()
    }

    val connection = client.createConnection()
    connection.updateOptions(options.toString())
    connection.connect()
    return connection
}
using Nabto.Edge.Client;
using Microsoft.Extensions.Logging;
using Dahomey.Cbor;

public class BasicConnectExample 
{

    public static async Task SayHello()
    {
        using (var client = INabtoClient.Create()) {
            using var loggerFactory = LoggerFactory.Create (builder => builder.AddConsole());
            var logger = loggerFactory.CreateLogger<INabtoClient>();
            client.SetLogger(logger);
            await using (var connection = await Connect(client)) {
                var coap = connection.CreateCoapRequest("GET", "/hello-world");
                var response = await coap.ExecuteAsync();
                var status = response.GetResponseStatusCode();
                Console.WriteLine($"Device replied with status {status}");
            }
        }
    }

    static async Task<IConnection> Connect(INabtoClient client)
    {
        var options = new ConnectionOptions
        {
            ProductId = "pr-fatqcwj9",
            DeviceId = "de-avmqjaje",
            PrivateKey = client.CreatePrivateKey(),
            ServerConnectToken = "demosct"
        };
        var connection = client.CreateConnection();
        connection.SetOptions(options);
        try {
            await connection.ConnectAsync();
        } catch (Exception e) {
            var localError = connection.GetLocalChannelErrorCode();
            var remoteError = connection.GetRemoteChannelErrorCode();
            Console.WriteLine($"Could not connect to device, reason: {e.Message}");
            Console.WriteLine($"     local channel error: {localError} ({NabtoClientError.GetErrorMessage(localError)})");
            Console.WriteLine($"     remote channel error: {remoteError} ({NabtoClientError.GetErrorMessage(remoteError)})");
            throw;
        }
        return connection;
    }
}

To keep things as simple as possible in this example, a unique keypair is generated for each invocation and there is no authentication taking place. Persistence of this key and support for access control is necessary for a production ready application. We recommend using the Nabto provided means for access control as shown below.

Further parameters can be specified when connecting, but these are not strictly necessary for the simplest scenario - they will be described in the following sections.

This and all examples in this section use the synchronous APIs for simplicity. So if re-using snippets in your own app, make sure to run all potentially slow operations on a different thread than the main thead, e.g. using a global dispatch queue on iOS. Alternatively, use the asynchronous variants of the functions in iOS and the Kotlin extenion’s await variants in Android.

Connecting using authentication

In real life there is access control involved when connecting to a device: Before the device can be used, the client and device must be paired. This basically means public keys are exchanged securely and the device assigns a role to the client, defining which functionality is allowed to be used.

After pairing is complete, client and device are automatically mutually authenticated using public key cryptography when a client connects and uses functionality on the device. This is all explained in the pairing guide.

The device allows clients to pair through a set of Nabto Edge CoAP endpoints implementing the IAM API. The iOS IAM Util and Android IAM Util components wrap all the complexities of invoking these CoAP services - meaning that secure pairing looks as simple as follows:

func pair(connection: Connection,
          username: String,
          password: String) throws -> Bookmark {

    // invoke one of the 4 built-in pairing functions
    try IamUtil.pairPasswordOpen(
            connection: connection,
            desiredUsername: username,
            password: pairingPassword)

    // after successful pairing, store paired device in client for subsequent access
    // - especially important: store the per-user SCT that was assigned by the device
    // as well as the device's fingerprint
    let user = try IamUtil.getCurrentUser(connection: connection)
    let sct = user.Sct
    let fingerprint = try connection.getDeviceFingerprintHex()
    let bookmark = Bookmark(sct, fingerprint)

    return bookmark
    // now use this bookmark later when connecting beyond pairing to actually use the device: The
    // per-user SCT is used for remote connects; the device fingerprint is used to verify the
    // device authencity
}

private Bookmark pair(Connection connection, String username, String password) {
    IamUtil iam = IamUtil.create();

    // Invoke one of the 4 built-in pairing functions.
    iam.pairPasswordOpen(connection, username, password);

    // after successful pairing, store paired device in client for subsequent access
    // - especially important: store the per-user SCT that was assigned by the device
    // as well as the device's fingerprint
    IamUser user = iam.getCurrentUser(connection);
    String sct = user.getSct();
    String fingerprint = connection.getDeviceFingerprint();
    Bookmark bookmark = new Bookmark(sct, fingerprint);

    return bookmark;
    // now use this bookmark later when connecting beyond pairing to actually use the device: The
    // per-user SCT is used for remote connects; the device fingerprint is used to verify the
    // device authencity
}
fun pair(connection: Connection, username: String, password: String): Bookmark {
    val iam = IamUtil.create()

    // Invoke one of the 4 built-in pairing functions.
    iam.pairPasswordOpen(connection, username, password)

    // after successful pairing, store paired device in client for subsequent access
    // - especially important: store the per-user SCT that was assigned by the device
    // as well as the device's fingerprint
    val user = iam.getCurrentUser(connection)
    val sct = user.getSct()
    val fingerprint = connection.getDeviceFingerprint()
    val bookmark = Bookmark(fingerprint, sct)

    return bookmark
    // now use this bookmark later when connecting beyond pairing to actually use the device: The
    // per-user SCT is used for remote connects; the device fingerprint is used to verify the
    // device authencity
}
using Nabto.Edge.Client;
using Nabto.Edge.ClientIam;

public class PairingExample {

    public static async Task<Bookmark> Pair(IConnection connection, string username, string password)
    {
        // Invoke one of the 4 built-in pairing functions.
        await IamUtil.PairPasswordOpenAsync(connection, username, password);

        // after successful pairing, store paired device in client for subsequent access
        // - especially important: store the per-user SCT that was assigned by the device
        // as well as the device's fingerprint
        var user = await IamUtil.GetCurrentUserAsync(connection);
        var sct = user.Sct;
        var fingerprint = connection.GetDeviceFingerprint();
        var bookmark = new Bookmark(null, null, fingerprint, sct);

        return bookmark;
        // now use this bookmark later when connecting beyond pairing to actually use the device: The
        // per-user SCT is used for remote connects; the device fingerprint is used to verify the
        // device authencity
    }

}

In the above snippet, the “Password Openpairing mode is used: It means that any user who knows a common pairing password is allowed to pair with the device. Check out the other 3 pairing modes supported by Nabto Edge IAM in the pairing guide.

After pairing is completed successfully, the device’s public key fingerprint is stored to allow establishing an authenticated connection subsequently.

When pairing a user, the device generates and assigns an SCT for this specific user to allow the user to later connect remotely. This per-user SCT is retrieved from the device and stored for later use.

The details of establishing the connection in the pair() function above have been omitted for clarity. But as seen in the previous example, a device id and a product id must always be specified - and an SCT if connecting remotely. The following section shows a more production-like approach to establishing this connection.

Connecting for the pairing step

For the connection needed in the pairing step, mutual authentication of the client and device takes place as defined by the pairing mode. That is, either by verifying that each party knows the pairing password - without disclosing the password as PAKE is used. Or by trusting the local network.

The following shows how connecting can be implemented for the initial pairing and subsequently when the device fingerprint is known:

func connect(client: Client,
             key: String,
             deviceId: String,
             productId: String,
             sct: String,
             knownFingerprint: String? = nil) throws -> Connection {

    let connection = try client.createConnection()
    try connection.setPrivateKey(key: key)
    try connection.setProductId(id: productId)
    try connection.setDeviceId(id: deviceId)
    try connection.setServerConnectToken(sct: sct)
    try connection.connect()

    if let known = knownFingerprint {
        let deviceFingerprint = try connection.getDeviceFingerprintHex()
        if (deviceFingerprint != known) {
            throw ExampleError.DeviceNotAuthenticated
        }
    }

    return connection
}

enum ExampleError: Error {
    case DeviceNotAuthenticated
    case Ok
}

private Connection connect(NabtoClient client,
                           String key,
                           String deviceId,
                           String productId,
                           String sct,
                           String knownFingerprint) {

    Connection connection = client.createConnection();
    JSONObject options = new JSONObject();
    try {
        options.put("PrivateKey", key);
        options.put("ProductId", productId);
        options.put("DeviceId", deviceId);
        options.put("ServerConnectToken", sct);
    } catch (JSONException e) {
        e.printStackTrace();
    }
    connection.updateOptions(options.toString());
    connection.connect();

    if (knownFingerprint != null && !knownFingerprint.isEmpty()) {
        String deviceFingerprint = connection.getDeviceFingerprint();
        if (!deviceFingerprint.equals(knownFingerprint)) {
            throw new RuntimeException("Device has incorrect fingerprint!");
        }
    }

    return connection
}

fun connect(client: NabtoClient,
            key: String,
            deviceId: String,
            productId: String,
            sct: String,
            knownFingerprint: String): Connection {

    val connection = client.createConnection()
    val options = new JSONObject()
    try {
        options.put("PrivateKey", key)
        options.put("ProductId", productId)
        options.put("DeviceId", deviceId)
        options.put("ServerConnectToken", sct)
    } catch (e: JSONException) {
        e.printStackTrace()
    }
    connection.updateOptions(options.toString())
    connection.connect()

    if (knownFingerprint != null && !knownFingerprint.isEmpty()) {
        val deviceFingerprint = connection.getDeviceFingerprint()
        if (!deviceFingerprint.equals(known)) {
            throw RuntimeException("Device has incorrect fingerprint!")
        }
    }

    return connection
}


using Nabto.Edge.Client;

public class ConnectWithAuthExample {

    public static async Task<IConnection> Connect(
        INabtoClient client,
        String key,
        String deviceId,
        String productId,
        String? sct,
        String? knownFingerprint=null)
    {
        var connection = client.CreateConnection();
        var options = new ConnectionOptions
        {
            ProductId = productId,
            DeviceId = deviceId,
            ServerConnectToken = sct,
            PrivateKey = key
        };
        connection.SetOptions(options);
        try {
            await connection.ConnectAsync();
        } catch (Exception e) {
            Console.WriteLine($"Could not connect to device, reason: {e.Message}");
        }

        if (!String.IsNullOrEmpty(knownFingerprint))
        {
            var deviceFingerprint = connection.GetDeviceFingerprint();
            if (deviceFingerprint != knownFingerprint)
            {
                throw new InvalidOperationException("Device has incorrect fingerprint!");
            }
        }

        return connection;
    }
}

Using a mutually authenticated connection

Pairing and later establishing a mutually authenticated connection to invoke functionality on a device is seen in the following example that uses pair() and connect() functions introduced in the previous examples:

func prepareSayingHello(deviceId: String,
                        productId: String,
                        pairingSct: String,
                        username: String,
                        pairingPassword: String) throws {
    let client = Client()
    let key = try client.createPrivateKey()

    // save this private for later reuse (implementation omitted)
    try storeInKeyChain(key)

    let connection = connect(client: client,
                             key: key,
                             deviceId: String,
                             productId: String,
                             sct: String)
    let bookmark = try pair(connection: connection, username: username, password: pairingPassword)
    bookmark.deviceId = deviceId
    bookmark.productId = productId

    // save this bookmark for later reuse (implementation omitted)
    try storeBookmark(bookmark)
}

func sayAuthenticatedHello(bookmark: Bookmark) {
    let client = Client()
    let key = readFromKeyChain() // implementation omitted
    let connection = connect(client: client,
                             key: key,
                             deviceId: bookmark.deviceId,
                             productId: bookmark.productId,
                             sct: bookmark.sct,
                             knownFingerprint: bookmark.fingerprint)

    let coap = try connection.createCoapRequest(method: "GET", path: "/hello-world")
    let response = try coap.execute()
    if (response.status == 205) {
        let reply = String(decoding: response.payload, as: UTF8.self)
        print("Authenticated device replied: \(reply)")
    } else {
        print("Authenticated device returned non-ok status: \(response.status)")
    }
}

private void prepareSayingHello(String deviceId,
                                String productId,
                                String pairingSct,
                                String username,
                                String pairingPassword) {
    // You will have to get an android Context from somewhere, such as your top-level Application object.
    NabtoClient client = NabtoClient.create(context);
    String key = client.createPrivateKey();

    // save this private for later reuse (implementation omitted)
    storeInKeyChain(key);

    Connection connection = connect(client, key, deviceId, productId, sct);
    Bookmark bookmark = pair(connection, username, pairingPassword);
    bookmark.deviceId = deviceId;
    bookmark.productId = productId;

    // save this bookmark for later reuse (implementation omitted)
    storeBookmark(bookmark);
}

private void sayAuthenticatedHello(NabtoClient client, Bookmark bookmark) {
    String key = readFromKeyChain(); // implementation omitted
    Connection connection = connect(
        client, 
        key, 
        bookmark.deviceId, 
        bookmark.productId, 
        bookmark.sct,
        bookmark.fingerprint
    );

    Coap coap = connection.createCoap("GET", "/hello-world");
    coap.execute();
    int response = coap.getResponseStatusCode();
    if (response == 205) {
        String reply = new String(coap.getResponsePayload());
        Log.i("Nabto", "Authenticated device replied: " + reply);
    } else {
        Log.i("Nabto", "Authenticated device returned non-ok status: " + response);
    }
}

fun prepareSayingHello(deviceId: String,
                       productId: String,
                       pairingSct: String,
                       username: String,
                       pairingPassword: String) {
    // You will have to get an android Context from somewhere, such as your top-level Application object.
    val client = NabtoClient.create(context)
    val key = client.createPrivateKey()

    // save this private for later reuse (implementation omitted)
    storeInKeyChain(key)

    val connection = connect(client, key, deviceId, productId, sct)
    val bookmark = pair(connection, username, pairingPassword)
    bookmark.deviceId = deviceId
    bookmark.productId = productId

    // save this bookmark for later reuse (implementation omitted)
    storeBookmark(bookmark)
}

fun sayAuthenticatedHello(client: NabtoClient, bookmark: Bookmark) {
    val key = readFromKeyChain() // implementation omitted
    val connection = connect(
        client, 
        key, 
        bookmark.deviceId, 
        bookmark.productId, 
        bookmark.sct,
        bookmark.fingerprint
    )

    val coap = connection.createCoap("GET", "/hello-world")
    coap.execute()
    val response = coap.getResponseStatusCode()
    if (response == 205) {
        val reply = String(coap.getResponsePayload())
        Log.i("Nabto", "Authenticated device replied: ${reply}")
    } else {
        Log.i("Nabto", "Authenticated device returned non-ok status: ${response}")
    }
}

using Nabto.Edge.Client;
using Microsoft.Extensions.Logging;

public record struct Bookmark(
    string? ProductId,
    string? DeviceId,
    string? Fingerprint,
    string? Sct
) {}

public class HelloWithAuth
{

    private String _privateKey = "";

    void StoreInKeyChain(String key)
    {
        // You should persist the key somehow. E.g. write it to a file or database or similar.
        Console.WriteLine("Storing key in keychain (TODO)");  
        _privateKey = key;
    }

    string ReadFromKeyChain()
    {
        // Read your private key from storage or similar
        Console.WriteLine("Reading key from keychain (TODO)");
        return _privateKey;
    }

    public async Task<Bookmark> PrepareSayingHello(
        String deviceId,
        String productId,
        String pairingSct,
        String username,
        String pairingPassword)
    {
        using var client = INabtoClient.Create();
        using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
        var logger = loggerFactory.CreateLogger<INabtoClient>();

        // Create and save a private key for later reuse.
        var key = client.CreatePrivateKey();
        StoreInKeyChain(key);

        // Connect and Pair functions are defined in snippets in above sections.
        using var connection = await ConnectWithAuthExample.Connect(client, key, deviceId, productId, pairingSct);

        var bookmark = await PairingExample.Pair(connection, username, pairingPassword);
        bookmark.DeviceId = deviceId;
        bookmark.ProductId = productId;
        return bookmark;
    }

    public async Task SayAuthenticatedHello(INabtoClient client, Bookmark bookmark)
    {
        // var loggerFactory = LoggerFactory.Create (builder => builder.AddConsole().SetMinimumLevel(LogLevel.Trace));
        // var logger = loggerFactory.CreateLogger<NabtoClient>();
        // client.SetLogger(logger);

        if (bookmark.DeviceId == null || bookmark.ProductId == null)
        {
            throw new ArgumentException("Bookmark must have device and product id to be able to connect to it.");
        }

        var key = ReadFromKeyChain();
        using var connection = await ConnectWithAuthExample.Connect(
            client,
            key,
            bookmark.DeviceId!,
            bookmark.ProductId!,
            bookmark.Sct,
            bookmark.Fingerprint);

        using var coap = connection.CreateCoapRequest("GET", "/hello-world");
        var response = await coap.ExecuteAsync();
        var status = response.GetResponseStatusCode();
        if (status == 205)
        {
            var reply = System.Text.Encoding.UTF8.GetString(response.GetResponsePayload());
            Console.WriteLine($"Authenticated device replied: {reply}");
        }
        else
        {
            Console.WriteLine($"Authenticated device returned non-ok status: {status}");
        }
    }

}

This example could be used in a production setup to establish and use a secure connection.

There is still room for improvement before practically usable: In the example, a connection is established whenever the device is used - and establishing such a secure connection is quite expensive (it requires a full DTLS handshake each time, ie with several full network roundtrips). So normally a connection cache is introduced in the client, ie a datastructure that maps device ids to connections.

Keep-alive Settings

A Nabto connection is based on UDP packets. To keep firewalls open for packets and to detect network failure, keep-alive packets are used. Keep-alive settings can be tweaked to a specific use case; it is trade-off between responsiveness to network failure, resilience to network packet loss and resources used.

The keep-alive settings can be controlled by setting the following Connection options (see the simple example on how to set options):

  • KeepAliveInterval: default 30,000 milliseconds; time between keep-alive packets when responses are retrieved timely.
  • KeepAliveRetryInterval: default 2,000 milliseconds; time between keep-alive retransmissions. This comes into play if more than KeepAliveRetryInterval milliseconds elapse between a keep-alive request is sent and a response is retrieved or if the response is lost.
  • KeepAliveMaxRetries: default 15, The maximum Keep Alive Retries before a connection is deemed dead.

The max time it takes from a failure occurring until it is detected by keep-alive timeout is roughly KeepAliveInterval + KeepAliveRetryInterval * KeepAliveMaxRetries.

Keep Alive Settings for Interactive Applications

For Interactive applications we advice to change the default combined keep alive timeout of 60 seconds to about 10 seconds using the following settings:

  • KeepAliveInterval: 2,000 milliseconds
  • KeepAliveRetryInterval: 2,000 milliseconds
  • KeepAliveMaxRetries: 5

These settings reduce the default combined connection timeout from about 60 seconds to about 10 seconds. This leads to faster failure detection at the expense of connection resilience and resource usage. Many mobile networks has problems with bufferbloat, packet loss and high latencies, which means it is not reasonable to have much lower values as keep-alive settings.

Detect network changes proactively

For best user experience in interactive applications like mobile apps, the application should ideally use NWPathMonitor on iOS and ConnectivityManager on Android to detect network changes. For instance, if the network changes (e.g. between 4g and wifi), the connection will in some cases die and the only path forward is to close the old connection and create a new one.

A platform mechanism like NWPathMonitor or ConnectivityManager should be combined with e.g. a Nabto Edge CoAP invocation (e.g. of CoAP GET /iam/me): The network could change without it affecting a p2p connection (if e.g. the wan address changes, but the app is communicating locally with a device on LAN). So you should perform the CoAP request with a very short timeout - if this succeeds, you can ignore the NWPathMonitor path update notification or ConnectivityManager onLost notifications and just continue. If the CoAP invocation is not answered very fast, you should close the connection and create a new one. The recommended timeout for the CoAP request is 2-3 seconds, which is a tradeoff between responsiveness and false positives.