February 22, 2023

By swvheerden and CjS77

Ledger with Rust

The Tari development community recently started talking about what it would take to add hardware wallet support for Tari.

There are a few challenges here:

  • Tari uses a different elliptic curve (Ristretto) to Bitcoin (secp256k1) and so there is probably no signing support “out of the box”.
  • We like Rust, but what does Rust support for hardware wallets look like?
  • Can we compile our crypto libraries to fit inside the extremely constrained resources of a crypto wallet (128kB in some cases)?

As a start, @SWvheerden tried to get some preliminary answers to these questions by trying not to brick a brand-new Ledger Nano S+. This is what he found.

So what’s the official stance?

The operating system behind all Ledger personal security devices is called the Blockchain Open Ledger Operating System, or BOLOS for short. Developer support is heavily skewed towards C code and Linux, but there are unofficial tools for developing in other languages, including Rust.

Although Windows gets unofficial support through its own WSL program which runs a very light Linux VM inside of windows, MacOS support is completely lacking. For application development, support exists for mobile through BLE, react for android only, and for desktop, this is limited to NodeJS.

What are we going to try to accomplish?

We are going to see if we can create and load a sample BOLOS application with Rust without the use of an FFI onto a Ledger NanoS+ using MacOS. We also need to create a desktop application counterpart for this BOLOS application. Because we are going to attempt to use Rust this process should be multi-platform working on Linux and Windows. The complete code for the BOLOS application and Desktop application can be found here: github


Before we can think about setting up our machine for the development of Ledger, we need to ensure that the machine is set up for the general development of Rust. This is going to focus on MacOS only, installing the dependencies for other OS should point to missing and otherwise required packages.

First is a Mac only requirement, installing homebrew, you might get around this, but it will make your life much easier if you do.

Second, up is Rust. This needs to be installed if regardless of your choice of OS. We can verify the installation with:

rustup --version

rustup 1.25.2 (17db695f1 2023-02-01)
info: This is the version for the rustup toolchain manager, not the rustc compiler.
info: The currently active `rustc` version is `rustc 1.69.0-nightly (e1eaa2d5d 2023-02-06)`

Next up is Python 3 which importantly comes with pip3. This needs to be installed regardless of your choice of OS. Verify this with

python3 --version

Python 3.11.2
pip3 --version

pip 22.3.1 from /opt/homebrew/lib/python3.11/site-packages/pip (python 3.11)

Lastly a Mac only requirement again, Xcode, this can be installed from the app store. Make sure the version installed is higher than 14.2 verify this with:

xcodebuild -version

Xcode 14.2
Build version 14C18

Something to note, it’s possible to run multiple versions of Xcode on the same machine. This is required because to build a Tari Base node you need Xcode 14.0, see here

The development environment

Now, let’s start with the Ledger specific dependencies now that we have a general dev machine setup.

For loading a BOLOS application to a Ledger device, Ledger has written a command, called Cargo Ledger. This we need to install with:

cargo install --git https://github.com/LedgerHQ/cargo-ledger

Next up we need to install the supporting Python libraries from Ledger to control Ledger devices, LedgerCTL. This we do with:

pip3 install --upgrade protobuf setuptools ecdsa
pip3 install ledgerwallet

Lastly install the ARM GCC toolchain: arm-none-eabi-gcc for your OS. We are using MacOS, so we can use brew with:

brew install armmbed/formulae/arm-none-eabi-gcc

That’s it for the development environment

The BOLOS application

The BOLOS application runs on the Ledger device itself and thus needs to be super simple. And to give you an idea of how low performant they are, they only have 64kB of RAM, use 32-bit only, and have about 1.5MB of storage with the NanoS+. Compare this to modern smartphones with an excess of 6GB of RAM and 128GB of storage.

640k meme

This means we need to target a small footprint application targeting the llvm-target: thumbv8m.main-none-eabi with no std support. Compiling Rust without std limits our choices of both internal and external libraries we can use, but it at least means we can run our Rust application on the Ledger.

Happily, Ledger does provide a Rust SDK that provides access to the secure chip running inside of BOLOS as well as communication out to some external applications.

For our demo BOLOS application, we will provide two commands: get-version and sign-data.

We want get-version to return information about the BOLOS application. sign-data will sign a challenge with a randomly created private key, returning the public key, nonce, and a Schnorr signature, all using the Ristretto curve.

Let’s go over the Rust code to sign the challenge and return the results:

match comm.next_event() {
    io::Event::Command(Instruction::Sign) => {
        // first bytes are instruction details
        let offset = 5;
        let challenge = ArrayString::<32>::from_bytes(comm.get(offset, offset + 32));

        let k = RistrettoSecretKey::random();
        let r = RistrettoSecretKey::random();
        let signature = SchnorrSignature::sign_raw(&k, r, challenge.bytes()).unwrap();
        let public_key = RistrettoPublicKey::from_secret_key(&k);
        let sig = signature.get_signature().as_bytes();
        let nonce = signature.get_public_nonce().as_bytes();

        comm.append(&[1]); // version

BOLOS applications communicate using APDU. This means the first 5 bytes are information such as the instruction, arguments, etc. Because we know our challenge will be 32 bytes we can ignore byte 4 which will be the length of the byte and just straight up read our challenge from the buffer. We then construct our signature using stripped-down code from Tari crypto. We can then fill in the buffer with comm.append to send the results back to the desktop application. Following the APDU standard we should send over the byte length of each field before its field, but because each field will be exactly 32 bytes we can ignore it.

Before compiling the code, we need to copy over the target information depending on which device we want to target. These target files are located with the Rust SDK as NanoS, NanoX,NanoS+. We want to target the NanoS+ for now, so we copy that file over to our root directory of the project.

To compile our project we use the command:

cargo +nightly build -Zbuild-std --release --target=nanosplus.json

Out of interest for anyone following so far, this builds a 216kB application.

And finally to load it onto our ledger we use:

cargo +nightly ledger --load nanosplus

Following the prompts on the NanoS+ to install it.

The Desktop application

Luckily here we have the power to use Rust std so we don’t have the same limitations as the BOLOS counterpart. Also Zondax the developers of Polkadot have published some very nice helper libraries to help us develop our application: ledger-rs.

Let’s highlight certain parts of the code.

The first important step is to get a connection to the ledger:

let ledger = TransportNativeHID::new(hidapi()).expect("Could not get a device");

The next important step is to construct the ADPU command:

let command = APDUCommand {
        cla: 0x80,
        ins: 0x02,
        p1: 0x00,
        p2: 0x00,
        data: challenge.as_bytes().clone(),

It is important to note that the eventual command will insert as byte 5, the length of the data field before the data. But here with 0x02 we specify for the ledger we want to call the sign command as above, and we include the challenge it must use.

Then we send it to the ledger with:

let result = ledger.exchange(&command2).unwrap();

We then read and verify our results by getting the data back from the ADPU:

    let public_key = &result.data()[1..33];
    let public_key = RistrettoPublicKey::from_bytes(public_key).unwrap();

    let sig = &result.data()[33..65];
    let sig = RistrettoSecretKey::from_bytes(sig).unwrap();

    let nonce = &result.data()[65..97];
    let nonce = RistrettoPublicKey::from_bytes(nonce).unwrap();

    let signature = RistrettoSchnorr::new(nonce, sig);
    let result = signature.verify(&public_key, &challenge);
    println!("sign: {}", result);

Running them both

Because we don’t open up the ledger application with the desktop one, we need to open up the ledger application on the device first. After which we can run our desktop application. The application will generate a random challenge, which will then be sent to the ledger for signing. The end result looks as follows:

 cargo run
   Compiling legder_integration v0.1.0 
    Finished dev [unoptimized + debuginfo] target(s) in 3.41s
     Running `target/debug/legder_integration`
name: tari
package version: 0.0.1
sign: true

This means we have a valid signature from the ledger for our random challenge.


Here we can see a very simple working example written in Rust and on a non-Linux machine. The complete code is available here: github