Mock ECDSA

Let’s build a canister that simulates the methods ecdsa_public_key and sign_with_ecdsa provided by the management canister:

ecdsa_public_key : (record {
  canister_id : opt canister_id;
  derivation_path : vec blob;
  key_id : record { curve: ecdsa_curve; name: text; };
}) -> (record { public_key : blob; chain_code : blob; });

sign_with_ecdsa : (record {
  message_hash : blob;
  derivation_path : vec blob;
  key_id : record { curve: ecdsa_curve; name: text };
}) -> (record { signature : blob });

Our canister is useful for testing, though not for much else. The private key is stored in the clear, so node providers hosting the canister can see it; do we trust all of them all of the time? (In contrast, thanks to threshold cryptography, compromising signatures from the real management canister would require several corrupt node providers to collude.)

Our low-level approach makes cryptography easy: we simply compile an existing library to WebAssembly. In contrast, if we had, say, chosen a special-purpose high-level language lacking FFI, we’d have to roll our own crypto.

MIRACL

We lean on the MIRACL Core Cryptographic Library. This library already compiles to WebAssembly, but uses emscripten to accomplish this feat. We want to stay true to our minimalist ethos and directly use LLVM.

We only need secp256k1 signatures, which is Miracl’s configuration option 17 alone. In this mode, it turns out memset is the only part of the standard C ilbrary Miracl needs.

The following script downloads the Miracl library and builds a WebAssembly edition of the library archive core.a, modifying source files to do so. It also edits the signing function so that if given a zero hash length, it uses the identity function as the hash function. This matches the sign_with_ecdsa method, which takes a hash as input, not a message.

#!/usr/bin/env bash
# Works on commit b085fe.
# Needs llvm-ar: apt-get install llvm.
set -euxo pipefail
git clone https://github.com/miracl/core
cd core/c
sed -i 's/\<NULL\>/0/g' *.[ch]
sed -i -r '/<(time|stdio|stdlib)\.h/d' *.[ch]
sed -i 's/<inttypes/<stdint/' *.[ch]
sed -i 's/gcc/clang -isystem . --target=wasm32/' config32.py
sed -i 's/\<ar\>/llvm-ar/' config32.py
sed -i 's/SPhash(.*/if (hlen) & else H = *F;/' ecdh.c
cat > string.h << EOF
#include <stddef.h>
void *memset(void *, int, size_t);
EOF
(echo 17; echo 0) | ./config32.py
cp core.a ../../

The Canister

We hardwire a particular key pair in the mock ECDSA canister, which you might recognize as the "zoo" key from our adventure with principals. This plays the role of the subnet key in the management canister.

When a method is called we use our primitive Candid-decoding library to get at the canister ID, derivation path, and input hash fields. There is almost no input validation: for example, we completely ignore the key_id field. The real management canister is fussier.

We call Miracl routines to derive keys and sign according to the specification. It turns out there is an unwritten requirement: we should output the smaller of $s$ and $N - s$ where $N$ is the order of the secp256k1 curve.

To derive a canister’s key from the master key, we set the chain code to 32 zero bytes and the derivation path to the singleton list containing the principal of the canister. This mimics the real management canister, though really we could have done anything here. In practice, there is no way to get the master key, so there is no way to check the real management canister indeed behaves this way.

This is by design: in future, the IC may use a different method to derive the public key and chain code of a given canister. From the user’s point of view, a canister’s public key and chain code appear to be randomly generated.

Lastly we stuff the result in a suitable Candid message, whose template we find via our Rebel Candid tool.

#include <stdint.h>
#include <stddef.h>
void *memset(void *, int, size_t);
#include "core/c/core.h"
#include "core/c/ecdh_SECP256K1.h"
#include "core/c/randapi.h"
#include "candy.h"
#define IMPORT(m,n) __attribute__((import_module(m))) __attribute__((import_name(n)));
#define EXPORT(n) asm(n) __attribute__((visibility("default")))
typedef unsigned u32;
typedef uint8_t u8;
void reply_append(void*, u32)  IMPORT("ic0", "msg_reply_data_append");
void reply(void)               IMPORT("ic0", "msg_reply");
void reject(void*, u32)        IMPORT("ic0", "msg_reject");
u32 arg_size(void)             IMPORT("ic0", "msg_arg_data_size");
void arg_copy(void*, u32, u32) IMPORT("ic0", "msg_arg_data_copy");
u32 caller_size(void)             IMPORT("ic0", "msg_caller_size");
void caller_copy(void*, u32, u32) IMPORT("ic0", "msg_caller_copy");

void err() {
  char msg[] = "TODO: better error messages";
  reject(msg, sizeof(msg) - 1);
}

char master_secret_key[32] = {0xdd,0x73,0x0a,0x61,0xf9,0x8f,0xe5,0x73,0xc7,0x67,0x6e,0x59,0x57,0x72,0x7c,0x01,0xce,0xdf,0x3c,0x5a,0x04,0xbe,0xba,0x39,0xdf,0x92,0x44,0x5e,0x0b,0xd2,0xec,0x87};

char master_public_key[33] = {0x03,0x3c,0xc8,0x49,0xc7,0x7d,0x5e,0xad,0x3a,0xea,0xf2,0xea,0x82,0x1d,0xc8,0x5d,0x6b,0xb1,0x04,0x83,0xbb,0xe9,0x78,0x75,0xd0,0x10,0xad,0xa2,0x62,0x9e,0x4a,0x86,0x3e};

static csprng* get_rnd() {
  static octet seed[1] = { 32, 32, master_secret_key };
  static csprng rnd[1];
  static int done = 0;
  if (!done) {
    CREATE_CSPRNG(rnd, seed);
    done = 1;
  }
  return rnd;
}

static char scratch[1024];

static u8 arg_read(u32 cursor) {
  static u8 tmp[1];
  arg_copy(tmp, cursor, 1);
  return *tmp;
}

void pub() EXPORT("canister_query ecdsa_public_key");
void pub() {
  candy(arg_read);
  seek(0);
  u32 n;
  if (!descend(1313628723) && read(&the_value)) {  // canister_id
    n = unleb128(&the_value);
    arg_copy(scratch + 33, the_value, n);
  } else { // Principal is absent.
    n = caller_size();
    if (n > 256) return err();
    caller_copy(scratch + 33, 0, n);
  }
  seek(0);
  if (descend(1445762093)) return err(); // derivation_path
  u32 path_size = unleb128(&the_value);
  for (int i = 0; i < 33; i++) scratch[i] = master_public_key[i];
  octet scratcho[1] = { 0, 0, scratch };
  char lr[64];
  memset(lr + 32, 0, 32);
  octet lro[1] = { 64, 64, lr };
  octet chaino[1] = { 32, 32, lr + 32 };
  BIG_256_28 x, secret, order;
  BIG_256_28_rcopy(order, CURVE_Order_SECP256K1);
  BIG_256_28_fromBytes(secret, master_secret_key);
  for(;;) {
    scratcho->len = scratcho->max = n+33;
    HMAC(MC_SHA2, 64, lro, 64, chaino, scratcho);
    BIG_256_28_fromBytes(x, lr);
    BIG_256_28_modadd(secret, secret, x, order);
    ECP_SECP256K1 p[1];
    ECP_SECP256K1_generator(p);
    ECP_SECP256K1_mul(p, secret);
    ECP_SECP256K1_toOctet(scratcho, p, 1);
    if (!path_size--) break;
    n = unleb128(&the_value);
    arg_copy(scratch + 33, the_value, n);
    the_value += n;
  }

  // record { public_key = blob $A ; chain_code = blob $B }
  // 4449444c026d7b6c02c9afa1a40300ebe885cb0b000101"$A""$B"
  char buf[23] = "DIDL\x02\x6d\x7b\x6c\x02\xc9\xaf\xa1\xa4\x03\x00\xeb\xe8\x85\xcb\x0b\x00\x01\x01";
  reply_append(buf, 23);
  char tmp[1];
  *tmp = 33;
  reply_append(tmp, 1);
  reply_append(scratch, 33);
  *tmp = 32;
  reply_append(tmp, 1);

  reply_append(lr + 32, 32);
  reply();
}

void sign() EXPORT("canister_update sign_with_ecdsa");
void sign() {
  candy(arg_read);
  for (int i = 0; i < 33; i++) scratch[i] = master_public_key[i];
  u32 n = caller_size();
  caller_copy(scratch + 33, 0, n);
  seek(0);
  if (descend(1445762093)) return err(); // derivation_path
  u32 path_size = unleb128(&the_value);
  for (int i = 0; i < 33; i++) scratch[i] = master_public_key[i];
  octet scratcho[1] = { 0, 0, scratch };
  char lr[64];
  memset(lr + 32, 0, 32);
  octet lro[1] = { 64, 64, lr };
  octet chaino[1] = { 32, 32, lr + 32 };
  BIG_256_28 x, secret, order;
  BIG_256_28_rcopy(order, CURVE_Order_SECP256K1);
  BIG_256_28_fromBytes(secret, master_secret_key);
  for(;;) {
    scratcho->len = scratcho->max = n+33;
    HMAC(MC_SHA2, 64, lro, 64, chaino, scratcho);
    BIG_256_28_fromBytes(x, lr);
    BIG_256_28_modadd(secret, secret, x, order);
    if (!path_size--) break;
    ECP_SECP256K1 p[1];
    ECP_SECP256K1_generator(p);
    ECP_SECP256K1_mul(p, secret);
    ECP_SECP256K1_toOctet(scratcho, p, 1);
    n = unleb128(&the_value);
    arg_copy(scratch + 33, the_value, n);
    the_value += n;
  }
  BIG_256_28_toBytes(scratch, secret);
  seek(0);
  descend(3146564742);  // message_hash
  char hash[32];
  if (unleb128(&the_value) != 32) return err();
  arg_copy(hash, the_value, 32);

  char sig[64];
  octet msgo[1] = { 32, 32, hash },
        seco[1] = { 32, 32, scratch },
        ro[1] = { 32, 32, sig },
        so[1] = { 32, 32, sig + 32 };
  int status = ECP_SECP256K1_SP_DSA(0, get_rnd(), 0, seco, msgo, ro, so);
  if (status) return err();

  {
    BIG_256_28 s, smn;
    BIG_256_28_fromBytes(s, sig+32);
    BIG_256_28_sub(smn, order, s);

    BIG_256_28_norm(s);
    BIG_256_28_norm(smn);

    if(BIG_256_28_comp(smn, s) < 0) BIG_256_28_toBytes(sig+32, smn);
  }

  // record { signature = blob $SIG }
  // 4449444c026d7b6c01f8c5aeab01000101"$SIG"
  char buf[17] = "DIDL\x02\x6d\x7b\x6c\x01\xf8\xc5\xae\xab\x01\x00\x01\x01";
  reply_append(buf, 17);
  char tmp[1];
  *tmp = 64;
  reply_append(tmp, 1);
  reply_append(sig, 64);
  reply();
}

If we call this file mock.c, then the following produces a mock ECDSA canister:

$ clang -O3 --target=wasm32 -c mock.c
$ wasm-ld --export-dynamic --allow-undefined --no-entry -o mock.wasm mock.o candy.o libc.o core.a
$ git clone https://fxa77-fiaaa-aaaae-aaana-cai.raw.ic0.app/organic.git
$ cd organic
$ ./mkcore
$ make mock.wasm

On my system, this produces the following binary:


💡