Device operations like genuine check, managing apps or firmware update involve the establishement of a secure channel between the Ledger devices and a Ledger HSM.
One of the way to do that, that live-common is implementing, is using a WebSocket API: the "script runner". There are many ping-pong exchanges that happens and a WebSocket is appropriate not only for performance but also for having a lifecycle of such bond.
In live-common, we exposes this API using a rxjs.Observable. It's a convenient, composable, functional, stream abstraction. We will document in here the behavior of the underlying function createDeviceSocket
.
Here is the gist of executing a genuine check:
import { createDeviceSocket } from "@ledgerhq/live-common/lib/api/socket";
const genuineCheck = (
transport: Transport<*>,
{ targetId, perso }: { targetId: number, perso: string } // these info are returned in getDeviceInfo
): Observable<boolean> =>
createDeviceSocket(transport, {
url: URL.format({
pathname: `${getEnv("BASE_SOCKET_URL")}/genuine`,
query: { targetId, perso }
})
}).pipe( // example of composing it
filter(e.type === "result"), // let's look at the result
map(e => e.payload === "0000") // success code for genuine check
);
};
// usage:
genuineCheck(hidTransport, meta).subscribe(
isSuccess => { isSuccess ? console.log("yeah!") : console.log("bad result") },
error => { console.error("oops!", error) }
)
(Transport<*>, { url: string }) => Observable<SocketEvent>
It creates a transport (that is coming from ledgerjs
) and open a secure channel to the given wss:// URL. It returns an Observable of SocketEvent
which can be one of these:
type SocketEvent =
| { type: "bulk-progress", progress: number, index: number, total: number }
| { type: "result", payload: any }
| { type: "warning", message: string }
| { type: "device-permission-requested", wording: string }
| { type: "device-permission-granted" }
| { type: "exchange-before", nonce: number, apdu: Buffer }
| {
type: "exchange",
nonce: number,
apdu: Buffer,
data: Buffer,
status: Buffer
}
| { type: "opened" }
| { type: "closed" };
The observable terminates when everything is finished with the device or errors when something unexpected happened (included device errors, see Section "Error cases").
The API is exposed over a secured WebSocket.
Each "script" to run is exposed under a different path, and some parameters are given to contextualize it to the device version.
For instance, installing BTC 1.3.17 on Nano S 1.6.0 will use wss://api.ledgerwallet.com/update/install?targetId=823132164&perso=perso_11&deleteKey=nanos%2F1.6.0%2Fbitcoin%2Fapp_1.3.17_del_key&firmware=nanos%2F1.6.0%2Fbitcoin%2Fapp_1.3.17&firmwareKey=nanos%2F1.6.0%2Fbitcoin%2Fapp_1.3.17_key&hash=8514a59f3ec27eab4637fb6ec626a6ea31f876f429f1e484d29cc8ec5cf15896
Messages of the WebSocket channel are plain text of serialized JSON of shape: { query, nonce, ...otherfields }
.
nonce
is a counter that starts from 1.query
is a discriminant field to handle different cases:
"exchange"
requests the client to execute one APDU command with the device. It is typically used for the initial secure channel establishment.
It is never a terminating event, meaning there are more events to come in the websocket after this one.
In this case, we have also these fields required in the JSON:
data
: an hexadecimal APDU command to send to the device.
At the end of the step, the client send back to the server this event:
{ nonce: number,
response: "success" | "error",
data: string } // hexadecimal
In live-common case, during this step, two events are emitted out of the Observable:
| { type: "exchange-before", nonce: number, apdu: Buffer }
| { type: "exchange", nonce: number, apdu: Buffer, data: Buffer, status: Buffer }
But this step can also emit these events, in case "Allow Manager" needs to be validated by the user:
| { type: "device-permission-requested", wording: string }
| { type: "device-permission-granted" }
"success"
terminates the socket with a payload data.
It is a terminating event, no events are to expect from the server after this event and the websocket will be closed (by server and can be be the client too).
In this case, we have also these fields required in the JSON:
data
orresult
: anything. (can be a string, or a complex JSON object)
For some (legacy?) it's either data
or result
field that is used. In live-common side, we semantically unify this into "payload"
.
In live-common case, an event is emitted at the end and the observable closes:
| { type: "result", payload: any }
"error"
terminates the socket with an error that occured on the HSM.
data
field will contains the error message in String format.
in live-common case, the Observable will terminates with an error DeviceSocketFail
containing the actual error text.
"warning"
query can be emit from the HSM to notify the client of a warning. This is a system in place for Manager notification but that is not yet used at the moment. It does not terminates the stream and can be in the middle of other events.
data
field will contains the warning message in String format.
in live-common case, the Observable emits:
| { type: "warning", message: string }
"bulk"
terminates the socket with a big series of APDUs to execute on the device.
data
an Array of string. Where each string is an hexadecimal APDU to run on the device.
At this point, the client might close the WebSocket connection because we know it's a closing event. Any socket event is also silenced from the client.
In any case, the live-common logic will run all the APDUs and the progress will be notified.
| { type: "bulk-progress", progress: number 0 to 1, index: number 0 to N, total: number N }
If everything succeed, the Observable will normally close.
If one of the APDU exchange result of a non 9000 status from the device, an error TransportStatusError
is thrown.
If the device becomes unresponsive at some point of the execution, this step can throw ManagerDeviceLockedError
because that's a locking case.
There are many cases of errors or channel interruptions.
Any of these error, if comes from the client, will unsubscribe the observable and therefore result of closing the WebSocket channel.
This can typically happens Network can goes down / channel timeout. This error will be thrown in the observable.
An HSM error occurred.
It means user have denied the "Allow manager".
Any transport error can be thrown, for instance DisconnectedDevice
and DisconnectedDeviceDuringOperation
when the user unplug the device.
In any cases that is not a 9000
status code, a TransportStatusError will be thrown. It is usually always managed by Live and remapped to appropriate wording (for instance, install have special code to tell "not enough space" which will be thrown if you use the higher level install app functions).
This can happens because user cancelled/left an action and we no longer need the channel. In that case, we will just close the websocket and no error will be emitted, it's a usual Observable subscription unsubscribe().