Skip to main content

Custom WebSocket client

Overview

Protocol traffic between your app and the cloud peer flows through a NetworkClient — a small transport contract the SDK drives to run each MPC step. The SDK ships a WebSocket implementation, DuoNetworkClient, and uses it automatically when you pass a WebsocketConfig to duoInitiator. You only need this guide if you want to customize that transport.

The NetworkClient contract is a fixed rhythm the session repeats for every protocol step:

MethodResponsibility
connect(action, params)Open a channel for one step. action carries the routing (which operation, which algorithm); params is optional detail such as a keyId.
send(bytes) / send(text)Send one framed message to the peer.
read()Suspend until the next message arrives, and return its bytes.
disconnect()Close the channel. The client stays reusable for the next connect.

DuoNetworkClient implements this contract over Ktor WebSockets. On connect it:

  • opens a session at wss://<host>/v3/{algorithm}/{action} — for example /v3/ecdsa/keygen;
  • negotiates the duo-instance WebSocket subprotocol;
  • sends WebsocketConfig.authenticationToken as the first message, if you set one.

The underlying HTTP engine is created on connect and released on disconnect, so an idle client holds no transport resources.

Three ways to provide a transport

duoInitiator has three overloads, from zero-config to full control. Pick the one that matches how much of the transport you need to own.

#ApproachWhen to use
1WebsocketConfigThe default. The SDK builds and owns the WebSocket client.
2Subclass DuoNetworkClientKeep the WebSocket transport, but hook connect / send / read — logging, headers, retry, or route rewriting.
3Custom NetworkClient + DuoNetworkActionProviderRoute protocol traffic over a different transport entirely.

1. Default WebSocket — pass a WebsocketConfig

Give the factory a WebsocketConfig and the SDK builds and owns the default DuoNetworkClient. There is nothing to implement — this is the standard path used in the session setup.

Example.kt
// The default transport. Pass a WebsocketConfig and the SDK builds and owns the
// default DuoNetworkClient (a Ktor WebSocket client) for you — nothing to implement.
val websocketConfig = WebsocketConfig(url = "wss://your-server.example.com")

val duoSession = SilentShard.ECDSA.duoInitiator(
messageSigner = messageSigner,
cloudVerifyingKey = cloudPublicKey,
websocketConfig = websocketConfig,
storageClient = storageClient,
)

2. Customize the WebSocket — subclass DuoNetworkClient

Subclass DuoNetworkClient and override connect (or send / read) to add logging, inject headers, add retry/back-off, or rewrite a route — while reusing the default framing, /v3/{algorithm}/{action} routing, subprotocol negotiation, and engine lifecycle. Pass your subclass to the duoInitiator overload that accepts a DuoNetworkClient.

Import DuoNetworkClient and the DuoNetworkAction types from com.silencelaboratories.silentshard.duo.initiator.network.

CustomNetworkClient.kt
import com.silencelaboratories.silentshard.duo.initiator.network.DuoNetworkAction
import com.silencelaboratories.silentshard.duo.initiator.network.DuoNetworkClient
import com.silencelaboratories.silentshard.network.websocket.WebsocketConfig

// Subclass the default client to hook connect / send / read while reusing its framing
// and /v3/{algorithm}/{action} routing. Here we rewrite the keygen route as an example
// and delegate every other action unchanged; you could just as easily log, add headers,
// or wrap the call in retry/back-off before calling super.
class CustomNetworkClient(websocketConfig: WebsocketConfig) : DuoNetworkClient(websocketConfig) {

override suspend fun connect(action: DuoNetworkAction, params: String?) {
val routed = when (action) {
is DuoNetworkAction.ECDSA.Keygen ->
DuoNetworkAction.ECDSA.Keygen(path = "custom-path-for-keygen")
else -> action
}
super.connect(routed, params)
}
}

// Pass your subclass to the DuoNetworkClient overload of duoInitiator.
val duoSession = SilentShard.ECDSA.duoInitiator(
messageSigner = messageSigner,
cloudVerifyingKey = cloudPublicKey,
networkClient = CustomNetworkClient(websocketConfig),
storageClient = storageClient,
)

3. Bring your own transport — implement NetworkClient

The most control: carry protocol traffic over any channel you like. Because the transport only has to move framed bytes between the two parties, this can be a shared app socket, a native bridge, or an already-open host connection — or a peer-to-peer link such as Bluetooth Low Energy (BLE), letting two nearby devices run the protocol directly with no cloud relay (you would map connect / send / read / disconnect onto GATT characteristic writes and notifications).

A custom transport has two halves, and you supply both to the duoInitiator overload that accepts a NetworkClient + DuoNetworkActionProvider.

a. The transport — implement NetworkClient

How bytes move. The SDK drives your client with connectsend/readdisconnect for each step; the action it hands connect is the step your routing selected.

CustomTransport.kt
import com.silencelaboratories.silentshard.network.NetworkAction
import com.silencelaboratories.silentshard.network.NetworkClient

// How bytes move. The SDK drives your client with the same rhythm for every step:
// connect → send/read → disconnect. `action` is the concrete step your routing selected.
class CustomTransport : NetworkClient {
override suspend fun connect(action: NetworkAction, params: String?) {
// `action` is the step the provider chose; `params` may carry a keyId.
// Open a channel for it over your transport (WebSocket, app socket, BLE, …).
}

override suspend fun send(bytes: ByteArray) { /* send framed bytes to the peer */ }
override suspend fun send(text: String) { /* send framed text to the peer */ }
override suspend fun read(): ByteArray = TODO("suspend until the next peer message, then return its bytes")
override suspend fun disconnect() { /* close the channel; stay reusable for the next connect */ }
}

b. The routing — implement DuoNetworkActionProvider

How each step is addressed. It exposes one NetworkAction per operation (keygen, reconcile, refresh, sign, import), and the action it supplies is exactly what your client's connect receives. Take DuoNetworkActionProvider.Default.ECDSA / .EdDSA for the standard /v3/{algorithm}/{action} routing, or implement it yourself — every DuoNetworkAction lets you override path / action / protocol / algorithm.

CustomActions.kt
import com.silencelaboratories.silentshard.duo.initiator.network.DuoNetworkAction
import com.silencelaboratories.silentshard.duo.initiator.network.DuoNetworkActionProvider

// How each step is addressed. Every DuoNetworkAction constructor takes
// path / action / protocol / algorithm — override whichever fields you want: pass one,
// spell them all out, or take the defaults, per step.
class CustomActions : DuoNetworkActionProvider {
// Override a single field (here, the route's `path`).
override val keygen = DuoNetworkAction.ECDSA.Keygen(path = "my-keygen-route")

// Spell out every field explicitly.
override val reconcile = DuoNetworkAction.ECDSA.Reconcile(
path = "duo", action = "reconcile", protocol = "duo-instance", algorithm = "ecdsa",
)

// Override the WebSocket subprotocol negotiated on connect.
override val refresh = DuoNetworkAction.ECDSA.KeyRefresh(protocol = "my-duo-instance")

// Override the `action` segment of the /v3/{algorithm}/{action} route.
override val sign = DuoNetworkAction.ECDSA.Signature(action = "signature")

// …or just take the stock routing.
override val import = DuoNetworkAction.ECDSA.Import()
}

// Note: `algorithm` + `action` form the /v3/{algorithm}/{action} route and `protocol`
// is the subprotocol header, so change those only to match your own server; `path`
// only feeds the HTTP POST URL, which duo flows don't use.

c. Wire the two halves into duoInitiator

Example.kt
// Pass both halves — your transport and your routing — to the custom-transport
// overload of duoInitiator.
val duoSession = SilentShard.ECDSA.duoInitiator(
messageSigner = messageSigner,
cloudVerifyingKey = cloudPublicKey,
storageClient = storageClient,
networkClient = CustomTransport(),
actionProvider = CustomActions(), // or DuoNetworkActionProvider.Default.ECDSA
)

Most protocol steps run over the open WebSocket; a few request-response steps run over HTTP POST (sendPostRequest). Duo never needs the POST path — it is only exercised by trio pre-signatures — so a custom duo transport can leave it out.